test(config): consolidate env/include scenario coverage

This commit is contained in:
Peter Steinberger
2026-02-23 22:16:30 +00:00
parent c248c515a3
commit cd5f3fe0c1
3 changed files with 587 additions and 466 deletions

View File

@@ -3,287 +3,349 @@ import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"
describe("resolveConfigEnvVars", () => {
describe("basic substitution", () => {
it("substitutes a single env var", () => {
const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "bar" });
expect(result).toEqual({ key: "bar" });
});
it("substitutes direct, inline, repeated, and multi-var patterns", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
name: "single env var",
config: { key: "${FOO}" },
env: { FOO: "bar" },
expected: { key: "bar" },
},
{
name: "multiple env vars in same string",
config: { key: "${A}/${B}" },
env: { A: "x", B: "y" },
expected: { key: "x/y" },
},
{
name: "inline prefix/suffix",
config: { key: "prefix-${FOO}-suffix" },
env: { FOO: "bar" },
expected: { key: "prefix-bar-suffix" },
},
{
name: "same var repeated",
config: { key: "${FOO}:${FOO}" },
env: { FOO: "bar" },
expected: { key: "bar:bar" },
},
];
it("substitutes multiple different env vars in same string", () => {
const result = resolveConfigEnvVars({ key: "${A}/${B}" }, { A: "x", B: "y" });
expect(result).toEqual({ key: "x/y" });
});
it("substitutes inline with prefix and suffix", () => {
const result = resolveConfigEnvVars({ key: "prefix-${FOO}-suffix" }, { FOO: "bar" });
expect(result).toEqual({ key: "prefix-bar-suffix" });
});
it("substitutes same var multiple times", () => {
const result = resolveConfigEnvVars({ key: "${FOO}:${FOO}" }, { FOO: "bar" });
expect(result).toEqual({ key: "bar:bar" });
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
});
describe("nested structures", () => {
it("substitutes in nested objects", () => {
const result = resolveConfigEnvVars(
it("substitutes variables in nested objects and arrays", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
outer: {
inner: {
key: "${API_KEY}",
},
name: "nested object",
config: { outer: { inner: { key: "${API_KEY}" } } },
env: { API_KEY: "secret123" },
expected: { outer: { inner: { key: "secret123" } } },
},
{
name: "flat array",
config: { items: ["${A}", "${B}", "${C}"] },
env: { A: "1", B: "2", C: "3" },
expected: { items: ["1", "2", "3"] },
},
{
name: "array of objects",
config: {
providers: [
{ name: "openai", apiKey: "${OPENAI_KEY}" },
{ name: "anthropic", apiKey: "${ANTHROPIC_KEY}" },
],
},
env: { OPENAI_KEY: "sk-xxx", ANTHROPIC_KEY: "sk-yyy" },
expected: {
providers: [
{ name: "openai", apiKey: "sk-xxx" },
{ name: "anthropic", apiKey: "sk-yyy" },
],
},
},
{ API_KEY: "secret123" },
);
expect(result).toEqual({
outer: {
inner: {
key: "secret123",
},
},
});
});
];
it("substitutes in arrays", () => {
const result = resolveConfigEnvVars(
{ items: ["${A}", "${B}", "${C}"] },
{ A: "1", B: "2", C: "3" },
);
expect(result).toEqual({ items: ["1", "2", "3"] });
});
it("substitutes in deeply nested arrays and objects", () => {
const result = resolveConfigEnvVars(
{
providers: [
{ name: "openai", apiKey: "${OPENAI_KEY}" },
{ name: "anthropic", apiKey: "${ANTHROPIC_KEY}" },
],
},
{ OPENAI_KEY: "sk-xxx", ANTHROPIC_KEY: "sk-yyy" },
);
expect(result).toEqual({
providers: [
{ name: "openai", apiKey: "sk-xxx" },
{ name: "anthropic", apiKey: "sk-yyy" },
],
});
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
});
describe("missing env var handling", () => {
it("throws MissingEnvVarError for missing env var", () => {
expect(() => resolveConfigEnvVars({ key: "${MISSING}" }, {})).toThrow(MissingEnvVarError);
});
it("throws MissingEnvVarError with var name and config path details", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
varName: string;
configPath: string;
}> = [
{
name: "missing top-level var",
config: { key: "${MISSING}" },
env: {},
varName: "MISSING",
configPath: "key",
},
{
name: "missing nested var",
config: { outer: { inner: { key: "${MISSING_VAR}" } } },
env: {},
varName: "MISSING_VAR",
configPath: "outer.inner.key",
},
{
name: "missing var in array element",
config: { items: ["ok", "${MISSING}"] },
env: { OK: "val" },
varName: "MISSING",
configPath: "items[1]",
},
{
name: "empty string env value treated as missing",
config: { key: "${EMPTY}" },
env: { EMPTY: "" },
varName: "EMPTY",
configPath: "key",
},
];
it("includes var name in error", () => {
try {
resolveConfigEnvVars({ key: "${MISSING_VAR}" }, {});
throw new Error("Expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(MissingEnvVarError);
const error = err as MissingEnvVarError;
expect(error.varName).toBe("MISSING_VAR");
for (const scenario of scenarios) {
try {
resolveConfigEnvVars(scenario.config, scenario.env);
expect.fail(`${scenario.name}: expected MissingEnvVarError`);
} catch (err) {
expect(err, scenario.name).toBeInstanceOf(MissingEnvVarError);
const error = err as MissingEnvVarError;
expect(error.varName, scenario.name).toBe(scenario.varName);
expect(error.configPath, scenario.name).toBe(scenario.configPath);
}
}
});
it("includes config path in error", () => {
try {
resolveConfigEnvVars({ outer: { inner: { key: "${MISSING}" } } }, {});
throw new Error("Expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(MissingEnvVarError);
const error = err as MissingEnvVarError;
expect(error.configPath).toBe("outer.inner.key");
}
});
it("includes array index in config path", () => {
try {
resolveConfigEnvVars({ items: ["ok", "${MISSING}"] }, { OK: "val" });
throw new Error("Expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(MissingEnvVarError);
const error = err as MissingEnvVarError;
expect(error.configPath).toBe("items[1]");
}
});
it("treats empty string env var as missing", () => {
expect(() => resolveConfigEnvVars({ key: "${EMPTY}" }, { EMPTY: "" })).toThrow(
MissingEnvVarError,
);
});
});
describe("escape syntax", () => {
it("outputs literal ${VAR} when escaped with $$", () => {
const result = resolveConfigEnvVars({ key: "$${VAR}" }, { VAR: "value" });
expect(result).toEqual({ key: "${VAR}" });
});
it("handles escaped placeholders alongside regular substitutions", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
name: "escaped placeholder stays literal",
config: { key: "$${VAR}" },
env: { VAR: "value" },
expected: { key: "${VAR}" },
},
{
name: "mix of escaped and unescaped vars",
config: { key: "${REAL}/$${LITERAL}" },
env: { REAL: "resolved" },
expected: { key: "resolved/${LITERAL}" },
},
{
name: "escaped first, unescaped second",
config: { key: "$${FOO} ${FOO}" },
env: { FOO: "bar" },
expected: { key: "${FOO} bar" },
},
{
name: "unescaped first, escaped second",
config: { key: "${FOO} $${FOO}" },
env: { FOO: "bar" },
expected: { key: "bar ${FOO}" },
},
{
name: "multiple escaped placeholders",
config: { key: "$${A}:$${B}" },
env: {},
expected: { key: "${A}:${B}" },
},
{
name: "env values are not unescaped",
config: { key: "${FOO}" },
env: { FOO: "$${BAR}" },
expected: { key: "$${BAR}" },
},
];
it("handles mix of escaped and unescaped", () => {
const result = resolveConfigEnvVars({ key: "${REAL}/$${LITERAL}" }, { REAL: "resolved" });
expect(result).toEqual({ key: "resolved/${LITERAL}" });
});
it("handles escaped and unescaped of the same var (escaped first)", () => {
const result = resolveConfigEnvVars({ key: "$${FOO} ${FOO}" }, { FOO: "bar" });
expect(result).toEqual({ key: "${FOO} bar" });
});
it("handles escaped and unescaped of the same var (unescaped first)", () => {
const result = resolveConfigEnvVars({ key: "${FOO} $${FOO}" }, { FOO: "bar" });
expect(result).toEqual({ key: "bar ${FOO}" });
});
it("handles multiple escaped vars", () => {
const result = resolveConfigEnvVars({ key: "$${A}:$${B}" }, {});
expect(result).toEqual({ key: "${A}:${B}" });
});
it("does not unescape $${VAR} sequences from env values", () => {
const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "$${BAR}" });
expect(result).toEqual({ key: "$${BAR}" });
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
});
describe("non-matching patterns unchanged", () => {
it("leaves $VAR (no braces) unchanged", () => {
const result = resolveConfigEnvVars({ key: "$VAR" }, { VAR: "value" });
expect(result).toEqual({ key: "$VAR" });
describe("pattern matching rules", () => {
it("leaves non-matching placeholders unchanged", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
name: "$VAR (no braces)",
config: { key: "$VAR" },
env: { VAR: "value" },
expected: { key: "$VAR" },
},
{
name: "lowercase placeholder",
config: { key: "${lowercase}" },
env: { lowercase: "value" },
expected: { key: "${lowercase}" },
},
{
name: "mixed-case placeholder",
config: { key: "${MixedCase}" },
env: { MixedCase: "value" },
expected: { key: "${MixedCase}" },
},
{
name: "invalid numeric prefix",
config: { key: "${123INVALID}" },
env: {},
expected: { key: "${123INVALID}" },
},
];
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
it("leaves ${lowercase} unchanged (uppercase only)", () => {
const result = resolveConfigEnvVars({ key: "${lowercase}" }, { lowercase: "value" });
expect(result).toEqual({ key: "${lowercase}" });
});
it("substitutes valid uppercase/underscore placeholder names", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
name: "underscore-prefixed name",
config: { key: "${_UNDERSCORE_START}" },
env: { _UNDERSCORE_START: "valid" },
expected: { key: "valid" },
},
{
name: "name with numbers",
config: { key: "${VAR_WITH_NUMBERS_123}" },
env: { VAR_WITH_NUMBERS_123: "valid" },
expected: { key: "valid" },
},
];
it("leaves ${MixedCase} unchanged", () => {
const result = resolveConfigEnvVars({ key: "${MixedCase}" }, { MixedCase: "value" });
expect(result).toEqual({ key: "${MixedCase}" });
});
it("leaves ${123INVALID} unchanged (must start with letter or underscore)", () => {
const result = resolveConfigEnvVars({ key: "${123INVALID}" }, {});
expect(result).toEqual({ key: "${123INVALID}" });
});
it("substitutes ${_UNDERSCORE_START} (valid)", () => {
const result = resolveConfigEnvVars(
{ key: "${_UNDERSCORE_START}" },
{ _UNDERSCORE_START: "valid" },
);
expect(result).toEqual({ key: "valid" });
});
it("substitutes ${VAR_WITH_NUMBERS_123} (valid)", () => {
const result = resolveConfigEnvVars(
{ key: "${VAR_WITH_NUMBERS_123}" },
{ VAR_WITH_NUMBERS_123: "valid" },
);
expect(result).toEqual({ key: "valid" });
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
});
describe("passthrough behavior", () => {
it("passes through primitives unchanged", () => {
expect(resolveConfigEnvVars("hello", {})).toBe("hello");
expect(resolveConfigEnvVars(42, {})).toBe(42);
expect(resolveConfigEnvVars(true, {})).toBe(true);
expect(resolveConfigEnvVars(null, {})).toBe(null);
for (const value of ["hello", 42, true, null]) {
expect(resolveConfigEnvVars(value, {})).toBe(value);
}
});
it("passes through empty object", () => {
expect(resolveConfigEnvVars({}, {})).toEqual({});
});
it("preserves empty and non-string containers", () => {
const scenarios: Array<{ config: unknown; expected: unknown }> = [
{ config: {}, expected: {} },
{ config: [], expected: [] },
{
config: { num: 42, bool: true, nil: null, arr: [1, 2] },
expected: { num: 42, bool: true, nil: null, arr: [1, 2] },
},
];
it("passes through empty array", () => {
expect(resolveConfigEnvVars([], {})).toEqual([]);
});
it("passes through non-string values in objects", () => {
const result = resolveConfigEnvVars({ num: 42, bool: true, nil: null, arr: [1, 2] }, {});
expect(result).toEqual({ num: 42, bool: true, nil: null, arr: [1, 2] });
for (const scenario of scenarios) {
expect(resolveConfigEnvVars(scenario.config, {})).toEqual(scenario.expected);
}
});
});
describe("real-world config patterns", () => {
it("substitutes API keys in provider config", () => {
const config = {
models: {
providers: {
"vercel-gateway": {
apiKey: "${VERCEL_GATEWAY_API_KEY}",
it("substitutes provider, gateway, and base URL config values", () => {
const scenarios: Array<{
name: string;
config: unknown;
env: Record<string, string>;
expected: unknown;
}> = [
{
name: "provider API keys",
config: {
models: {
providers: {
"vercel-gateway": { apiKey: "${VERCEL_GATEWAY_API_KEY}" },
openai: { apiKey: "${OPENAI_API_KEY}" },
},
},
openai: {
apiKey: "${OPENAI_API_KEY}",
},
env: {
VERCEL_GATEWAY_API_KEY: "vg_key_123",
OPENAI_API_KEY: "sk-xxx",
},
expected: {
models: {
providers: {
"vercel-gateway": { apiKey: "vg_key_123" },
openai: { apiKey: "sk-xxx" },
},
},
},
},
};
const env = {
VERCEL_GATEWAY_API_KEY: "vg_key_123",
OPENAI_API_KEY: "sk-xxx",
};
const result = resolveConfigEnvVars(config, env);
expect(result).toEqual({
models: {
providers: {
"vercel-gateway": {
apiKey: "vg_key_123",
{
name: "gateway auth token",
config: { gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } } },
env: { OPENCLAW_GATEWAY_TOKEN: "secret-token" },
expected: { gateway: { auth: { token: "secret-token" } } },
},
{
name: "provider base URL composition",
config: {
models: {
providers: {
custom: { baseUrl: "${CUSTOM_API_BASE}/v1" },
},
},
openai: {
apiKey: "sk-xxx",
},
env: { CUSTOM_API_BASE: "https://api.example.com" },
expected: {
models: {
providers: {
custom: { baseUrl: "https://api.example.com/v1" },
},
},
},
},
});
});
];
it("substitutes gateway auth token", () => {
const config = {
gateway: {
auth: {
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
};
const result = resolveConfigEnvVars(config, {
OPENCLAW_GATEWAY_TOKEN: "secret-token",
});
expect(result).toEqual({
gateway: {
auth: {
token: "secret-token",
},
},
});
});
it("substitutes base URL with env var", () => {
const config = {
models: {
providers: {
custom: {
baseUrl: "${CUSTOM_API_BASE}/v1",
},
},
},
};
const result = resolveConfigEnvVars(config, {
CUSTOM_API_BASE: "https://api.example.com",
});
expect(result).toEqual({
models: {
providers: {
custom: {
baseUrl: "https://api.example.com/v1",
},
},
},
});
for (const scenario of scenarios) {
const result = resolveConfigEnvVars(scenario.config, scenario.env);
expect(result, scenario.name).toEqual(scenario.expected);
}
});
});
});

View File

@@ -63,28 +63,22 @@ function expectResolveIncludeError(
}
describe("resolveConfigIncludes", () => {
it("passes through primitives unchanged", () => {
expect(resolve("hello")).toBe("hello");
expect(resolve(42)).toBe(42);
expect(resolve(true)).toBe(true);
expect(resolve(null)).toBe(null);
});
it("passes through non-include values unchanged", () => {
const cases = [
{ value: "hello", expected: "hello" },
{ value: 42, expected: 42 },
{ value: true, expected: true },
{ value: null, expected: null },
{ value: [1, 2, { a: 1 }], expected: [1, 2, { a: 1 }] },
{
value: { foo: "bar", nested: { x: 1 } },
expected: { foo: "bar", nested: { x: 1 } },
},
] as const;
it("passes through arrays with recursion", () => {
expect(resolve([1, 2, { a: 1 }])).toEqual([1, 2, { a: 1 }]);
});
it("passes through objects without $include", () => {
const obj = { foo: "bar", nested: { x: 1 } };
expect(resolve(obj)).toEqual(obj);
});
it("resolves single file $include", () => {
const files = { [configPath("agents.json")]: { list: [{ id: "main" }] } };
const obj = { agents: { $include: "./agents.json" } };
expect(resolve(obj, files)).toEqual({
agents: { list: [{ id: "main" }] },
});
for (const { value, expected } of cases) {
expect(resolve(value)).toEqual(expected);
}
});
it("rejects absolute path outside config directory (CWE-22)", () => {
@@ -94,44 +88,66 @@ describe("resolveConfigIncludes", () => {
expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/);
});
it("resolves array $include with deep merge", () => {
const files = {
[configPath("a.json")]: { "group-a": ["agent1"] },
[configPath("b.json")]: { "group-b": ["agent2"] },
};
const obj = { broadcast: { $include: ["./a.json", "./b.json"] } };
expect(resolve(obj, files)).toEqual({
broadcast: {
"group-a": ["agent1"],
"group-b": ["agent2"],
it("resolves single and array include merges", () => {
const cases = [
{
name: "single file include",
files: { [configPath("agents.json")]: { list: [{ id: "main" }] } },
obj: { agents: { $include: "./agents.json" } },
expected: {
agents: { list: [{ id: "main" }] },
},
},
});
});
it("deep merges overlapping keys in array $include", () => {
const files = {
[configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } },
[configPath("b.json")]: { agents: { list: [{ id: "main" }] } },
};
const obj = { $include: ["./a.json", "./b.json"] };
expect(resolve(obj, files)).toEqual({
agents: {
defaults: { workspace: "~/a" },
list: [{ id: "main" }],
{
name: "array include deep merge",
files: {
[configPath("a.json")]: { "group-a": ["agent1"] },
[configPath("b.json")]: { "group-b": ["agent2"] },
},
obj: { broadcast: { $include: ["./a.json", "./b.json"] } },
expected: {
broadcast: {
"group-a": ["agent1"],
"group-b": ["agent2"],
},
},
},
});
{
name: "array include overlapping keys",
files: {
[configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } },
[configPath("b.json")]: { agents: { list: [{ id: "main" }] } },
},
obj: { $include: ["./a.json", "./b.json"] },
expected: {
agents: {
defaults: { workspace: "~/a" },
list: [{ id: "main" }],
},
},
},
] as const;
for (const testCase of cases) {
expect(resolve(testCase.obj, testCase.files), testCase.name).toEqual(testCase.expected);
}
});
it("merges $include with sibling keys", () => {
it("merges include content with sibling keys and sibling overrides", () => {
const files = { [configPath("base.json")]: { a: 1, b: 2 } };
const obj = { $include: "./base.json", c: 3 };
expect(resolve(obj, files)).toEqual({ a: 1, b: 2, c: 3 });
});
it("sibling keys override included values", () => {
const files = { [configPath("base.json")]: { a: 1, b: 2 } };
const obj = { $include: "./base.json", b: 99 };
expect(resolve(obj, files)).toEqual({ a: 1, b: 99 });
const cases = [
{
obj: { $include: "./base.json", c: 3 },
expected: { a: 1, b: 2, c: 3 },
},
{
obj: { $include: "./base.json", b: 99 },
expected: { a: 1, b: 99 },
},
] as const;
for (const testCase of cases) {
expect(resolve(testCase.obj, files)).toEqual(testCase.expected);
}
});
it("throws when sibling keys are used with non-object includes", () => {
@@ -160,21 +176,25 @@ describe("resolveConfigIncludes", () => {
});
});
it("throws ConfigIncludeError for missing file", () => {
const obj = { $include: "./missing.json" };
expectResolveIncludeError(() => resolve(obj), /Failed to read include file/);
});
it("surfaces include read and parse failures", () => {
const cases = [
{
run: () => resolve({ $include: "./missing.json" }),
pattern: /Failed to read include file/,
},
{
run: () =>
resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, {
readFile: () => "{ invalid json }",
parseJson: JSON.parse,
}),
pattern: /Failed to parse include file/,
},
] as const;
it("throws ConfigIncludeError for invalid JSON", () => {
const resolver: IncludeResolver = {
readFile: () => "{ invalid json }",
parseJson: JSON.parse,
};
const obj = { $include: "./bad.json" };
expectResolveIncludeError(
() => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
/Failed to parse include file/,
);
for (const testCase of cases) {
expectResolveIncludeError(testCase.run, testCase.pattern);
}
});
it("throws CircularIncludeError for circular includes", () => {
@@ -268,43 +288,53 @@ describe("resolveConfigIncludes", () => {
);
});
it("handles relative paths correctly", () => {
const files = {
[configPath("clients", "mueller", "agents.json")]: { id: "mueller" },
};
const obj = { agent: { $include: "./clients/mueller/agents.json" } };
expect(resolve(obj, files)).toEqual({
agent: { id: "mueller" },
});
it("handles relative paths and nested-include override ordering", () => {
const cases = [
{
files: {
[configPath("clients", "mueller", "agents.json")]: { id: "mueller" },
},
obj: { agent: { $include: "./clients/mueller/agents.json" } },
expected: {
agent: { id: "mueller" },
},
},
{
files: {
[configPath("base.json")]: { nested: { $include: "./nested.json" } },
[configPath("nested.json")]: { a: 1, b: 2 },
},
obj: { $include: "./base.json", nested: { b: 9 } },
expected: {
nested: { a: 1, b: 9 },
},
},
] as const;
for (const testCase of cases) {
expect(resolve(testCase.obj, testCase.files)).toEqual(testCase.expected);
}
});
it("applies nested includes before sibling overrides", () => {
const files = {
[configPath("base.json")]: { nested: { $include: "./nested.json" } },
[configPath("nested.json")]: { a: 1, b: 2 },
};
const obj = { $include: "./base.json", nested: { b: 9 } };
expect(resolve(obj, files)).toEqual({
nested: { a: 1, b: 9 },
});
});
it("rejects parent directory traversal escaping config directory (CWE-22)", () => {
const files = { [sharedPath("common.json")]: { shared: true } };
const obj = { $include: "../../shared/common.json" };
it("enforces traversal boundaries while allowing safe nested-parent paths", () => {
expectResolveIncludeError(
() => resolve(obj, files, configPath("sub", "openclaw.json")),
() =>
resolve(
{ $include: "../../shared/common.json" },
{ [sharedPath("common.json")]: { shared: true } },
configPath("sub", "openclaw.json"),
),
/escapes config directory/,
);
});
it("allows nested parent traversal when path stays under top-level config directory", () => {
const files = {
[configPath("sub", "child.json")]: { $include: "../shared/common.json" },
[configPath("shared", "common.json")]: { shared: true },
};
const obj = { $include: "./sub/child.json" };
expect(resolve(obj, files)).toEqual({
expect(
resolve(
{ $include: "./sub/child.json" },
{
[configPath("sub", "child.json")]: { $include: "../shared/common.json" },
[configPath("shared", "common.json")]: { shared: true },
},
),
).toEqual({
shared: true,
});
});
@@ -536,27 +566,30 @@ describe("security: path traversal protection (CWE-22)", () => {
});
describe("prototype pollution protection", () => {
it("blocks __proto__ keys from polluting Object.prototype", () => {
const result = deepMerge({}, JSON.parse('{"__proto__":{"polluted":true}}'));
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(result).toEqual({});
});
it("blocks prototype pollution vectors in shallow and nested merges", () => {
const cases = [
{
base: {},
incoming: JSON.parse('{"__proto__":{"polluted":true}}'),
expected: {},
},
{
base: { safe: 1 },
incoming: { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 },
expected: { safe: 1, normal: 3 },
},
{
base: { nested: { a: 1 } },
incoming: { nested: JSON.parse('{"__proto__":{"polluted":true}}') },
expected: { nested: { a: 1 } },
},
] as const;
it("blocks prototype and constructor keys", () => {
const result = deepMerge(
{ safe: 1 },
{ prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 },
);
expect(result).toEqual({ safe: 1, normal: 3 });
});
it("blocks __proto__ in nested merges", () => {
const result = deepMerge(
{ nested: { a: 1 } },
{ nested: JSON.parse('{"__proto__":{"polluted":true}}') },
);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(result).toEqual({ nested: { a: 1 } });
for (const testCase of cases) {
const result = deepMerge(testCase.base, testCase.incoming);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(result).toEqual(testCase.expected);
}
});
});

View File

@@ -54,145 +54,171 @@ describe("stripReasoningTagsFromText", () => {
}
});
it("handles mixed real tags and code tags", () => {
const input = "<think>hidden</think>Visible text with `<think>` example.";
expect(stripReasoningTagsFromText(input)).toBe("Visible text with `<think>` example.");
});
it("handles code block followed by real tags", () => {
const input = "```\n<think>code</think>\n```\n<think>real hidden</think>visible";
expect(stripReasoningTagsFromText(input)).toBe("```\n<think>code</think>\n```\nvisible");
it("handles mixed code-tag and real-tag content", () => {
const cases = [
{
input: "<think>hidden</think>Visible text with `<think>` example.",
expected: "Visible text with `<think>` example.",
},
{
input: "```\n<think>code</think>\n```\n<think>real hidden</think>visible",
expected: "```\n<think>code</think>\n```\nvisible",
},
] as const;
for (const { input, expected } of cases) {
expect(stripReasoningTagsFromText(input)).toBe(expected);
}
});
});
describe("edge cases", () => {
it("preserves unclosed <think without angle bracket", () => {
const input = "Here is how to use <think tags in your code";
expect(stripReasoningTagsFromText(input)).toBe(input);
it("handles malformed tags and null-ish inputs", () => {
const cases = [
{
input: "Here is how to use <think tags in your code",
expected: "Here is how to use <think tags in your code",
},
{
input: "You can start with <think and then close with </think>",
expected: "You can start with <think and then close with",
},
{
input: "A < think >content< /think > B",
expected: "A B",
},
{
input: "",
expected: "",
},
{
input: null as unknown as string,
expected: null,
},
] as const;
for (const { input, expected } of cases) {
expect(stripReasoningTagsFromText(input)).toBe(expected);
}
});
it("strips lone closing tag outside code", () => {
const input = "You can start with <think and then close with </think>";
expect(stripReasoningTagsFromText(input)).toBe(
"You can start with <think and then close with",
);
it("handles fenced and inline code edge behavior", () => {
const cases = [
{
input: "Example:\n~~~\n<think>reasoning</think>\n~~~\nDone!",
expected: "Example:\n~~~\n<think>reasoning</think>\n~~~\nDone!",
},
{
input: "Example:\n~~~js\n<think>code</think>\n~~~",
expected: "Example:\n~~~js\n<think>code</think>\n~~~",
},
{
input: "Use ``code`` with <think>hidden</think> text",
expected: "Use ``code`` with text",
},
{
input: "Before\n```\ncode\n```\nAfter with <think>hidden</think>",
expected: "Before\n```\ncode\n```\nAfter with",
},
{
input: "```\n<think>not protected\n~~~\n</think>text",
expected: "```\n<think>not protected\n~~~\n</think>text",
},
{
input: "Start `unclosed <think>hidden</think> end",
expected: "Start `unclosed end",
},
] as const;
for (const { input, expected } of cases) {
expect(stripReasoningTagsFromText(input)).toBe(expected);
}
});
it("handles tags with whitespace", () => {
const input = "A < think >content< /think > B";
expect(stripReasoningTagsFromText(input)).toBe("A B");
it("handles nested and final tag behavior", () => {
const cases = [
{
input: "<think>outer <think>inner</think> still outer</think>visible",
expected: "still outervisible",
},
{
input: "A<final>1</final>B<final>2</final>C",
expected: "A1B2C",
},
{
input: "`<final>` in code, <final>visible</final> outside",
expected: "`<final>` in code, visible outside",
},
] as const;
for (const { input, expected } of cases) {
expect(stripReasoningTagsFromText(input)).toBe(expected);
}
});
it("handles empty and null-ish inputs", () => {
expect(stripReasoningTagsFromText("")).toBe("");
expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null);
it("handles unicode, attributes, and case-insensitive tag names", () => {
const cases = [
{
input: "你好 <think>思考 🤔</think> 世界",
expected: "你好 世界",
},
{
input: "A <think id='test' class=\"foo\">hidden</think> B",
expected: "A B",
},
{
input: "A <THINK>hidden</THINK> <Thinking>also hidden</Thinking> B",
expected: "A B",
},
] as const;
for (const { input, expected } of cases) {
expect(stripReasoningTagsFromText(input)).toBe(expected);
}
});
it("preserves think tags inside tilde fenced code blocks", () => {
const input = "Example:\n~~~\n<think>reasoning</think>\n~~~\nDone!";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
it("preserves tags in tilde block at EOF without trailing newline", () => {
const input = "Example:\n~~~js\n<think>code</think>\n~~~";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
it("handles nested think patterns (first close ends block)", () => {
const input = "<think>outer <think>inner</think> still outer</think>visible";
expect(stripReasoningTagsFromText(input)).toBe("still outervisible");
});
it("strips final tag markup but preserves content (by design)", () => {
const input = "A<final>1</final>B<final>2</final>C";
expect(stripReasoningTagsFromText(input)).toBe("A1B2C");
});
it("preserves final tags in inline code (markup only stripped outside)", () => {
const input = "`<final>` in code, <final>visible</final> outside";
expect(stripReasoningTagsFromText(input)).toBe("`<final>` in code, visible outside");
});
it("handles double backtick inline code with tags", () => {
const input = "Use ``code`` with <think>hidden</think> text";
expect(stripReasoningTagsFromText(input)).toBe("Use ``code`` with text");
});
it("handles fenced code blocks with content", () => {
const input = "Before\n```\ncode\n```\nAfter with <think>hidden</think>";
expect(stripReasoningTagsFromText(input)).toBe("Before\n```\ncode\n```\nAfter with");
});
it("does not match mismatched fence types (``` vs ~~~)", () => {
const input = "```\n<think>not protected\n~~~\n</think>text";
const result = stripReasoningTagsFromText(input);
expect(result).toBe(input);
});
it("handles unicode content inside and around tags", () => {
const input = "你好 <think>思考 🤔</think> 世界";
expect(stripReasoningTagsFromText(input)).toBe("你好 世界");
});
it("handles very long content between tags efficiently", () => {
it("handles long content and pathological backtick patterns efficiently", () => {
const longContent = "x".repeat(10000);
const input = `<think>${longContent}</think>visible`;
expect(stripReasoningTagsFromText(input)).toBe("visible");
});
expect(stripReasoningTagsFromText(`<think>${longContent}</think>visible`)).toBe("visible");
it("handles tags with attributes", () => {
const input = "A <think id='test' class=\"foo\">hidden</think> B";
expect(stripReasoningTagsFromText(input)).toBe("A B");
});
it("is case-insensitive for tag names", () => {
const input = "A <THINK>hidden</THINK> <Thinking>also hidden</Thinking> B";
expect(stripReasoningTagsFromText(input)).toBe("A B");
});
it("handles pathological nested backtick patterns without hanging", () => {
const input = "`".repeat(100) + "<think>test</think>" + "`".repeat(100);
const pathological = "`".repeat(100) + "<think>test</think>" + "`".repeat(100);
const start = Date.now();
stripReasoningTagsFromText(input);
stripReasoningTagsFromText(pathological);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(1000);
});
it("handles unclosed inline code gracefully", () => {
const input = "Start `unclosed <think>hidden</think> end";
const result = stripReasoningTagsFromText(input);
expect(result).toBe("Start `unclosed end");
});
});
describe("strict vs preserve mode", () => {
it("strict mode truncates on unclosed tag", () => {
it("applies strict and preserve modes to unclosed tags", () => {
const input = "Before <think>unclosed content after";
expect(stripReasoningTagsFromText(input, { mode: "strict" })).toBe("Before");
});
it("preserve mode keeps content after unclosed tag", () => {
const input = "Before <think>unclosed content after";
expect(stripReasoningTagsFromText(input, { mode: "preserve" })).toBe(
"Before unclosed content after",
);
const cases = [
{ mode: "strict" as const, expected: "Before" },
{ mode: "preserve" as const, expected: "Before unclosed content after" },
];
for (const { mode, expected } of cases) {
expect(stripReasoningTagsFromText(input, { mode })).toBe(expected);
}
});
});
describe("trim options", () => {
it("trims both sides by default", () => {
const input = " <think>x</think> result <think>y</think> ";
expect(stripReasoningTagsFromText(input)).toBe("result");
});
it("trim=none preserves whitespace", () => {
const input = " <think>x</think> result ";
expect(stripReasoningTagsFromText(input, { trim: "none" })).toBe(" result ");
});
it("trim=start only trims start", () => {
const input = " <think>x</think> result ";
expect(stripReasoningTagsFromText(input, { trim: "start" })).toBe("result ");
it("applies configured trim strategies", () => {
const cases = [
{
input: " <think>x</think> result <think>y</think> ",
expected: "result",
opts: undefined,
},
{
input: " <think>x</think> result ",
expected: " result ",
opts: { trim: "none" as const },
},
{
input: " <think>x</think> result ",
expected: "result ",
opts: { trim: "start" as const },
},
] as const;
for (const testCase of cases) {
expect(stripReasoningTagsFromText(testCase.input, testCase.opts)).toBe(testCase.expected);
}
});
});
});