mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 01:43:29 +08:00
Compare commits
10 Commits
v2026.6.10
...
impact/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e34dce4be | ||
|
|
1846919706 | ||
|
|
cd39018c61 | ||
|
|
f295de3cca | ||
|
|
24aa7a4f68 | ||
|
|
aed2b2eb4e | ||
|
|
ea6223ea40 | ||
|
|
7c0aea8535 | ||
|
|
c86cefa967 | ||
|
|
01fc86696b |
@@ -81,6 +81,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
|
||||
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
|
||||
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
|
||||
- CLI/setup: reject invalid `openclaw configure --section` values before opening the full wizard and show config issue details when non-interactive setup is blocked by invalid config.
|
||||
- CLI/channels: reject unknown `openclaw channels logs --channel` values and invalid `--lines` values instead of silently showing all/default logs.
|
||||
- CLI/agent: reject `--timeout` values with junk suffixes or fractions instead of partially parsing them.
|
||||
- CLI/sessions: reject `--active` values with junk suffixes instead of partially parsing them.
|
||||
- CLI/models: reject fractional `models scan --max-candidates` and `--concurrency` values before starting a scan.
|
||||
- Config: label root-level `${VAR}` substitution failures as `<root>` instead of printing a blank config path.
|
||||
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
|
||||
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
|
||||
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
|
||||
|
||||
@@ -171,6 +171,45 @@ describe("agentCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects timeout values with junk suffixes", async () => {
|
||||
await withTempStore(async () => {
|
||||
await expect(
|
||||
agentCliCommand({ message: "hi", to: "+1555", timeout: "10wat" }, runtime),
|
||||
).rejects.toThrow(
|
||||
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
|
||||
);
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional timeout values", async () => {
|
||||
await withTempStore(async () => {
|
||||
await expect(
|
||||
agentCliCommand({ message: "hi", to: "+1555", timeout: "1.5" }, runtime),
|
||||
).rejects.toThrow(
|
||||
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
|
||||
);
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects blank timeout values instead of disabling the timeout", async () => {
|
||||
await withTempStore(async () => {
|
||||
await expect(
|
||||
agentCliCommand({ message: "hi", to: "+1555", timeout: " " }, runtime),
|
||||
).rejects.toThrow(
|
||||
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
|
||||
);
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses gateway by default", async () => {
|
||||
await withTempStore(async () => {
|
||||
mockGatewaySuccessReply();
|
||||
|
||||
@@ -71,16 +71,20 @@ function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
|
||||
}
|
||||
|
||||
function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) {
|
||||
const raw =
|
||||
opts.timeout !== undefined
|
||||
? Number.parseInt(opts.timeout, 10)
|
||||
: (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
|
||||
if (Number.isNaN(raw) || raw < 0) {
|
||||
const raw = opts.timeout !== undefined ? opts.timeout.trim() : undefined;
|
||||
if (raw !== undefined && !/^\d+$/.test(raw)) {
|
||||
throw new Error(
|
||||
`Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
const parsed =
|
||||
raw !== undefined ? Number(raw) : (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new Error(
|
||||
`Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatPayloadForLog(payload: {
|
||||
|
||||
@@ -87,6 +87,54 @@ describe("channelsLogsCommand", () => {
|
||||
expect(payload.lines.map((line) => line.message)).toEqual(["external sent"]);
|
||||
});
|
||||
|
||||
it("rejects unknown channel filters instead of falling back to all logs", async () => {
|
||||
await fs.writeFile(
|
||||
logPath,
|
||||
[
|
||||
logLine({ module: "gateway/channels/external-chat/send", message: "external sent" }),
|
||||
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await channelsLogsCommand({ channel: "typo", json: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Unknown channel "typo" for logs. Run `openclaw channels list --all` to see configured and installable channels.',
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid line limits instead of silently using the default", async () => {
|
||||
await fs.writeFile(
|
||||
logPath,
|
||||
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
|
||||
);
|
||||
|
||||
await channelsLogsCommand({ channel: "slack", lines: "wat", json: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Invalid --lines. Use a positive integer, for example --lines 200.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects fractional line limits instead of truncating", async () => {
|
||||
await fs.writeFile(
|
||||
logPath,
|
||||
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
|
||||
);
|
||||
|
||||
await channelsLogsCommand({ channel: "slack", lines: "2.5", json: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Invalid --lines. Use a positive integer, for example --lines 200.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the latest rolling log when the configured rolling file is missing", async () => {
|
||||
const configuredFile = path.join(tempDir, "openclaw-2026-04-26.log");
|
||||
const fallbackFile = path.join(tempDir, "openclaw-2026-04-25.log");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
|
||||
import { formatUnknownChannelMessage } from "../../cli/error-format.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { resolveLogFile } from "../../logging/log-tail.js";
|
||||
import { parseLogLine } from "../../logging/parse-log-line.js";
|
||||
@@ -37,7 +38,19 @@ function parseChannelFilter(raw?: string) {
|
||||
if (bundled) {
|
||||
return bundled;
|
||||
}
|
||||
return listManifestChannelIds().has(trimmed) ? trimmed : "all";
|
||||
return listManifestChannelIds().has(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseLineLimit(raw: string | number | undefined): number | null {
|
||||
if (raw === undefined) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const value = typeof raw === "string" ? raw.trim() : String(raw);
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
|
||||
@@ -91,11 +104,22 @@ export async function channelsLogsCommand(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const channel = parseChannelFilter(opts.channel);
|
||||
const limitRaw = typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
|
||||
const limit =
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.floor(limitRaw)
|
||||
: DEFAULT_LIMIT;
|
||||
if (!channel) {
|
||||
runtime.error(
|
||||
formatUnknownChannelMessage({
|
||||
channel: opts.channel ?? "",
|
||||
purpose: "logs",
|
||||
}),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const limit = parseLineLimit(opts.lines);
|
||||
if (limit === null) {
|
||||
runtime.error("Invalid --lines. Use a positive integer, for example --lines 200.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await resolveLogFile(getResolvedLoggerSettings().file);
|
||||
const rawLines = await readTailLines(file, limit * 4);
|
||||
|
||||
75
src/commands/configure.commands.test.ts
Normal file
75
src/commands/configure.commands.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { CONFIGURE_WIZARD_SECTIONS } from "./configure.shared.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runConfigureWizard: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./configure.wizard.js", () => ({
|
||||
runConfigureWizard: mocks.runConfigureWizard,
|
||||
}));
|
||||
|
||||
import { configureCommandFromSectionsArg } from "./configure.commands.js";
|
||||
|
||||
function makeRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn() as unknown as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("configureCommandFromSectionsArg", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs the full configure wizard when no sections are provided", async () => {
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await configureCommandFromSectionsArg(undefined, runtime);
|
||||
|
||||
expect(mocks.runConfigureWizard).toHaveBeenCalledWith({ command: "configure" }, runtime);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs only the requested valid sections", async () => {
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await configureCommandFromSectionsArg(["gateway", "model"], runtime);
|
||||
|
||||
expect(mocks.runConfigureWizard).toHaveBeenCalledWith(
|
||||
{ command: "configure", sections: ["gateway", "model"] },
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid-only section input instead of falling back to the full wizard", async () => {
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await configureCommandFromSectionsArg(["typo"], runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
`Invalid --section: typo. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runConfigureWizard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects mixed valid and invalid section input without running a partial wizard", async () => {
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await configureCommandFromSectionsArg(["gateway", "bogus"], runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
`Invalid --section: bogus. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runConfigureWizard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,6 @@ export async function configureCommandFromSectionsArg(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
const { sections, invalid } = parseConfigureWizardSections(rawSections);
|
||||
if (sections.length === 0) {
|
||||
await configureCommand(runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (invalid.length > 0) {
|
||||
runtime.error(
|
||||
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
|
||||
@@ -34,5 +29,10 @@ export async function configureCommandFromSectionsArg(
|
||||
return;
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
await configureCommand(runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
await configureCommandWithSections(sections as never, runtime);
|
||||
}
|
||||
|
||||
@@ -148,4 +148,30 @@ describe("models scan command", () => {
|
||||
|
||||
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects fractional count options before scanning", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(modelsScanCommand({ maxCandidates: "1.5" }, runtime)).rejects.toThrow(
|
||||
"--max-candidates must be a positive integer",
|
||||
);
|
||||
await expect(modelsScanCommand({ concurrency: "2.5" }, runtime)).rejects.toThrow(
|
||||
"--concurrency must be a positive integer",
|
||||
);
|
||||
|
||||
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects blank count options before scanning", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(modelsScanCommand({ maxCandidates: "" }, runtime)).rejects.toThrow(
|
||||
"--max-candidates must be a positive integer",
|
||||
);
|
||||
await expect(modelsScanCommand({ concurrency: "" }, runtime)).rejects.toThrow(
|
||||
"--concurrency must be a positive integer",
|
||||
);
|
||||
|
||||
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,21 @@ function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(
|
||||
raw: string | undefined,
|
||||
fallback: number | undefined,
|
||||
): number | undefined {
|
||||
if (raw === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(trimmed);
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export async function modelsScanCommand(
|
||||
opts: {
|
||||
minParams?: string;
|
||||
@@ -178,17 +193,17 @@ export async function modelsScanCommand(
|
||||
if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) {
|
||||
throw new Error("--max-age-days must be >= 0");
|
||||
}
|
||||
const maxCandidates = opts.maxCandidates ? Number(opts.maxCandidates) : 6;
|
||||
if (!Number.isFinite(maxCandidates) || maxCandidates <= 0) {
|
||||
throw new Error("--max-candidates must be > 0");
|
||||
const maxCandidates = parsePositiveIntegerOption(opts.maxCandidates, 6);
|
||||
if (maxCandidates === undefined) {
|
||||
throw new Error("--max-candidates must be a positive integer");
|
||||
}
|
||||
const timeout = opts.timeout ? Number(opts.timeout) : undefined;
|
||||
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
|
||||
throw new Error("--timeout must be > 0");
|
||||
}
|
||||
const concurrency = opts.concurrency ? Number(opts.concurrency) : undefined;
|
||||
if (concurrency !== undefined && (!Number.isFinite(concurrency) || concurrency <= 0)) {
|
||||
throw new Error("--concurrency must be > 0");
|
||||
const concurrency = parsePositiveIntegerOption(opts.concurrency, undefined);
|
||||
if (opts.concurrency !== undefined && concurrency === undefined) {
|
||||
throw new Error("--concurrency must be a positive integer");
|
||||
}
|
||||
|
||||
const requestedProbe = opts.probe ?? true;
|
||||
|
||||
61
src/commands/onboard-non-interactive.invalid-config.test.ts
Normal file
61
src/commands/onboard-non-interactive.invalid-config.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
runNonInteractiveLocalSetup: vi.fn(async () => {}),
|
||||
runNonInteractiveRemoteSetup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/io.js", () => ({
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-non-interactive/local.js", () => ({
|
||||
runNonInteractiveLocalSetup: mocks.runNonInteractiveLocalSetup,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-non-interactive/remote.js", () => ({
|
||||
runNonInteractiveRemoteSetup: mocks.runNonInteractiveRemoteSetup,
|
||||
}));
|
||||
|
||||
import { runNonInteractiveSetup } from "./onboard-non-interactive.js";
|
||||
|
||||
function makeRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn() as unknown as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("runNonInteractiveSetup invalid config handling", () => {
|
||||
it("includes config issue details before exiting", async () => {
|
||||
const runtime = makeRuntime();
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: false,
|
||||
config: {},
|
||||
sourceConfig: {},
|
||||
issues: [
|
||||
{
|
||||
path: "",
|
||||
message: "JSON5 parse failed: JSON5: invalid character '}' at 2:20",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runNonInteractiveSetup({ nonInteractive: true, acceptRisk: true }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
[
|
||||
"Config invalid.",
|
||||
"Issues: <root>: JSON5 parse failed: JSON5: invalid character '}' at 2:20",
|
||||
"Run `openclaw doctor` to repair it, then re-run setup.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runNonInteractiveLocalSetup).not.toHaveBeenCalled();
|
||||
expect(mocks.runNonInteractiveRemoteSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import { formatConfigIssueSummary } from "../config/issue-format.js";
|
||||
import { readConfigFileSnapshot } from "../config/io.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -99,8 +100,15 @@ export async function runNonInteractiveSetup(
|
||||
) {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
const issueSummary = formatConfigIssueSummary(snapshot.issues ?? []);
|
||||
runtime.error(
|
||||
`Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run setup.`,
|
||||
[
|
||||
"Config invalid.",
|
||||
issueSummary ? `Issues: ${issueSummary}` : null,
|
||||
`Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run setup.`,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n"),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stripAnsi } from "../terminal/ansi.js";
|
||||
import {
|
||||
makeRuntime,
|
||||
mockSessionsConfig,
|
||||
@@ -48,7 +49,7 @@ describe("sessionsCommand", () => {
|
||||
expect(logs.join("\n")).toContain("Tokens (ctx %");
|
||||
|
||||
const row = logs.find((line) => line.includes("+15555550123")) ?? "";
|
||||
expect(row).toBe(
|
||||
expect(stripAnsi(row)).toBe(
|
||||
"direct +15555550123 45m ago pi:opus OpenAI Codex 2.0k/32k (6%) id:abc123",
|
||||
);
|
||||
});
|
||||
@@ -85,7 +86,7 @@ describe("sessionsCommand", () => {
|
||||
expect(logs.join("\n")).toContain("Runtime");
|
||||
|
||||
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
|
||||
expect(row).toBe(
|
||||
expect(stripAnsi(row)).toBe(
|
||||
"direct agent:main:main 1m ago claude-opus-4-7 Claude CLI unknown/200k (?%) id:main-session",
|
||||
);
|
||||
});
|
||||
@@ -120,7 +121,7 @@ describe("sessionsCommand", () => {
|
||||
fs.rmSync(store);
|
||||
|
||||
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
|
||||
expect(row).toBe(
|
||||
expect(stripAnsi(row)).toBe(
|
||||
"direct agent:main:main 1m ago claude-opus-4-7 Claude CLI unknown/200k (?%) id:main-session",
|
||||
);
|
||||
});
|
||||
@@ -140,7 +141,7 @@ describe("sessionsCommand", () => {
|
||||
fs.rmSync(store);
|
||||
|
||||
const row = logs.find((line) => line.includes("quietchat:group:demo")) ?? "";
|
||||
expect(row).toBe(
|
||||
expect(stripAnsi(row)).toBe(
|
||||
"group quietchat:group:demo 5m ago pi:opus OpenAI Codex unknown/32k (?%) think:high id:xyz",
|
||||
);
|
||||
});
|
||||
@@ -325,6 +326,26 @@ describe("sessionsCommand", () => {
|
||||
fs.rmSync(store);
|
||||
});
|
||||
|
||||
it("rejects --active values with junk suffixes", async () => {
|
||||
const store = writeStore(
|
||||
{
|
||||
demo: {
|
||||
sessionId: "demo",
|
||||
updatedAt: Date.now() - 5 * 60_000,
|
||||
},
|
||||
},
|
||||
"sessions-active-junk",
|
||||
);
|
||||
const { runtime, errors } = makeRuntime();
|
||||
|
||||
await expect(sessionsCommand({ store, active: "10wat" }, runtime)).rejects.toThrow("exit 1");
|
||||
expect(errors).toStrictEqual([
|
||||
"--active must be a positive number of minutes, for example --active 30.",
|
||||
]);
|
||||
|
||||
fs.rmSync(store);
|
||||
});
|
||||
|
||||
it("rejects invalid --limit values", async () => {
|
||||
const store = writeStore(
|
||||
{
|
||||
|
||||
@@ -134,6 +134,18 @@ function parseSessionsLimit(value: string | number | undefined): number | undefi
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseActiveMinutes(value: string | undefined): number | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
const colorByPct = (label: string, pct: number | null, rich: boolean) => {
|
||||
if (!rich || pct === null) {
|
||||
return label;
|
||||
@@ -254,8 +266,8 @@ export async function sessionsCommand(
|
||||
|
||||
let activeMinutes: number | undefined;
|
||||
if (opts.active !== undefined) {
|
||||
const parsed = Number.parseInt(opts.active, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
const parsed = parseActiveMinutes(opts.active);
|
||||
if (parsed === null) {
|
||||
runtime.error("--active must be a positive number of minutes, for example --active 30.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
|
||||
@@ -116,6 +116,13 @@ describe("resolveConfigEnvVars", () => {
|
||||
describe("missing env var handling", () => {
|
||||
it("throws MissingEnvVarError with var name and config path details", () => {
|
||||
const scenarios: MissingEnvScenario[] = [
|
||||
{
|
||||
name: "missing root string var",
|
||||
config: "${MISSING_ROOT}",
|
||||
env: {},
|
||||
varName: "MISSING_ROOT",
|
||||
configPath: "",
|
||||
},
|
||||
{
|
||||
name: "missing top-level var",
|
||||
config: { key: "${MISSING}" },
|
||||
@@ -148,6 +155,12 @@ describe("resolveConfigEnvVars", () => {
|
||||
|
||||
expectMissingScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("labels root-level missing env var errors instead of printing a blank path", () => {
|
||||
expect(() => resolveConfigEnvVars("${MISSING_ROOT}", {})).toThrow(
|
||||
'Missing env var "MISSING_ROOT" referenced at config path: <root>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape syntax", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export class MissingEnvVarError extends Error {
|
||||
public readonly varName: string,
|
||||
public readonly configPath: string,
|
||||
) {
|
||||
super(`Missing env var "${varName}" referenced at config path: ${configPath}`);
|
||||
super(`Missing env var "${varName}" referenced at config path: ${configPath || "<root>"}`);
|
||||
this.name = "MissingEnvVarError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,19 @@ describe("resolveConfigIncludes", () => {
|
||||
expectResolveIncludeError(run, pattern);
|
||||
});
|
||||
|
||||
it("includes the JSON parser detail when an included file cannot be parsed", () => {
|
||||
const error = expectResolveIncludeError(() =>
|
||||
resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, {
|
||||
readFile: () => "{ invalid json }",
|
||||
parseJson: JSON.parse,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error.message).toContain("Failed to parse include file: ./bad.json");
|
||||
expect(error.message).toContain("JSON");
|
||||
expect(error.message).toContain("line 1 column 3");
|
||||
});
|
||||
|
||||
it("throws CircularIncludeError for circular includes", () => {
|
||||
const aPath = configPath("a.json");
|
||||
const bPath = configPath("b.json");
|
||||
|
||||
@@ -14,6 +14,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { canUseRootFileOpen, openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { isPathInside } from "../security/scan-paths.js";
|
||||
import { isPlainObject } from "../utils.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
@@ -319,7 +320,9 @@ class IncludeProcessor {
|
||||
return this.resolver.parseJson(raw);
|
||||
} catch (err) {
|
||||
throw new ConfigIncludeError(
|
||||
`Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`,
|
||||
`Failed to parse include file: ${includePath} (resolved: ${resolvedPath}): ${formatErrorMessage(
|
||||
err,
|
||||
)}`,
|
||||
includePath,
|
||||
err instanceof Error ? err : undefined,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user