refactor(config): share include scan helper

This commit is contained in:
Peter Steinberger
2026-02-14 13:34:46 +00:00
parent 7fc1026746
commit b3882eccef
3 changed files with 89 additions and 168 deletions

View File

@@ -0,0 +1,87 @@
import JSON5 from "json5";
import * as fs from "node:fs/promises";
import path from "node:path";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "./includes.js";
function listDirectIncludes(parsed: unknown): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
return;
}
if (typeof value !== "object") {
return;
}
const rec = value as Record<string, unknown>;
const includeVal = rec[INCLUDE_KEY];
if (typeof includeVal === "string") {
out.push(includeVal);
} else if (Array.isArray(includeVal)) {
for (const item of includeVal) {
if (typeof item === "string") {
out.push(item);
}
}
}
for (const v of Object.values(rec)) {
visit(v);
}
};
visit(parsed);
return out;
}
function resolveIncludePath(baseConfigPath: string, includePath: string): string {
return path.normalize(
path.isAbsolute(includePath)
? includePath
: path.resolve(path.dirname(baseConfigPath), includePath),
);
}
export async function collectIncludePathsRecursive(params: {
configPath: string;
parsed: unknown;
}): Promise<string[]> {
const visited = new Set<string>();
const result: string[] = [];
const walk = async (basePath: string, parsed: unknown, depth: number): Promise<void> => {
if (depth > MAX_INCLUDE_DEPTH) {
return;
}
for (const raw of listDirectIncludes(parsed)) {
const resolved = resolveIncludePath(basePath, raw);
if (visited.has(resolved)) {
continue;
}
visited.add(resolved);
result.push(resolved);
const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
if (!rawText) {
continue;
}
const nestedParsed = (() => {
try {
return JSON5.parse(rawText);
} catch {
return null;
}
})();
if (nestedParsed) {
// eslint-disable-next-line no-await-in-loop
await walk(resolved, nestedParsed, depth + 1);
}
}
};
await walk(params.configPath, params.parsed, 0);
return result;
}

View File

@@ -3,7 +3,6 @@
*
* These functions perform I/O (filesystem, config reads) to detect security issues.
*/
import JSON5 from "json5";
import fs from "node:fs/promises";
import path from "node:path";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
@@ -22,7 +21,7 @@ import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { resolveNativeSkillsEnabled } from "../config/commands.js";
import { createConfigIO } from "../config/config.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveOAuthDir } from "../config/paths.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -63,88 +62,6 @@ function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
return null;
}
function resolveIncludePath(baseConfigPath: string, includePath: string): string {
return path.normalize(
path.isAbsolute(includePath)
? includePath
: path.resolve(path.dirname(baseConfigPath), includePath),
);
}
function listDirectIncludes(parsed: unknown): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
return;
}
if (typeof value !== "object") {
return;
}
const rec = value as Record<string, unknown>;
const includeVal = rec[INCLUDE_KEY];
if (typeof includeVal === "string") {
out.push(includeVal);
} else if (Array.isArray(includeVal)) {
for (const item of includeVal) {
if (typeof item === "string") {
out.push(item);
}
}
}
for (const v of Object.values(rec)) {
visit(v);
}
};
visit(parsed);
return out;
}
async function collectIncludePathsRecursive(params: {
configPath: string;
parsed: unknown;
}): Promise<string[]> {
const visited = new Set<string>();
const result: string[] = [];
const walk = async (basePath: string, parsed: unknown, depth: number): Promise<void> => {
if (depth > MAX_INCLUDE_DEPTH) {
return;
}
for (const raw of listDirectIncludes(parsed)) {
const resolved = resolveIncludePath(basePath, raw);
if (visited.has(resolved)) {
continue;
}
visited.add(resolved);
result.push(resolved);
const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
if (!rawText) {
continue;
}
const nestedParsed = (() => {
try {
return JSON5.parse(rawText);
} catch {
return null;
}
})();
if (nestedParsed) {
// eslint-disable-next-line no-await-in-loop
await walk(resolved, nestedParsed, depth + 1);
}
}
};
await walk(params.configPath, params.parsed, 0);
return result;
}
function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath);
const candidate = path.resolve(candidatePath);

View File

@@ -1,10 +1,9 @@
import JSON5 from "json5";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { createConfigIO } from "../config/config.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { runExec } from "../process/exec.js";
@@ -303,88 +302,6 @@ function applyConfigFixes(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv
return { cfg: next, changes, policyFlips };
}
function listDirectIncludes(parsed: unknown): string[] {
const out: string[] = [];
const visit = (value: unknown) => {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
return;
}
if (typeof value !== "object") {
return;
}
const rec = value as Record<string, unknown>;
const includeVal = rec[INCLUDE_KEY];
if (typeof includeVal === "string") {
out.push(includeVal);
} else if (Array.isArray(includeVal)) {
for (const item of includeVal) {
if (typeof item === "string") {
out.push(item);
}
}
}
for (const v of Object.values(rec)) {
visit(v);
}
};
visit(parsed);
return out;
}
function resolveIncludePath(baseConfigPath: string, includePath: string): string {
return path.normalize(
path.isAbsolute(includePath)
? includePath
: path.resolve(path.dirname(baseConfigPath), includePath),
);
}
async function collectIncludePathsRecursive(params: {
configPath: string;
parsed: unknown;
}): Promise<string[]> {
const visited = new Set<string>();
const result: string[] = [];
const walk = async (basePath: string, parsed: unknown, depth: number): Promise<void> => {
if (depth > MAX_INCLUDE_DEPTH) {
return;
}
for (const raw of listDirectIncludes(parsed)) {
const resolved = resolveIncludePath(basePath, raw);
if (visited.has(resolved)) {
continue;
}
visited.add(resolved);
result.push(resolved);
const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
if (!rawText) {
continue;
}
const nestedParsed = (() => {
try {
return JSON5.parse(rawText);
} catch {
return null;
}
})();
if (nestedParsed) {
// eslint-disable-next-line no-await-in-loop
await walk(resolved, nestedParsed, depth + 1);
}
}
};
await walk(params.configPath, params.parsed, 0);
return result;
}
async function chmodCredentialsAndAgentState(params: {
env: NodeJS.ProcessEnv;
stateDir: string;