Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1672,9 +1672,16 @@ function createAdmissionRejectionAuditWriter(
return null;
}

const filePath = api.resolvePath(
resolveRejectedAuditFilePath(resolvedDbPath, config.admissionControl),
);
// resolveRejectedAuditFilePath returns:
// - an already-absolute derived path (join of resolvedDbPath + "..") when no
// explicit config is set; these are safe to use directly without re-wrapping
// in api.resolvePath (which returns undefined for already-absolute paths in
// OpenClaw 2026.4.x strict mode).
// - the user-provided explicit path as-is (trimmed). If that path is relative,
// the caller is responsible for resolving it via api.resolvePath.
// Absolute explicit paths pass through unchanged and must NOT be re-resolved.
const rawPath = resolveRejectedAuditFilePath(resolvedDbPath, config.admissionControl);
const filePath = rawPath.startsWith("/") ? rawPath : api.resolvePath(rawPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect absolute audit paths with path.isAbsolute

The new absolute-path guard only checks rawPath.startsWith("/"), which misses Windows absolute forms such as C:\\logs\\rejections.jsonl and \\\\server\\share\\rejections.jsonl. In those cases this branch still calls api.resolvePath(rawPath) on an already-absolute path; under the same strict OpenClaw behavior this patch is addressing, that can return undefined, and the writer will fail at runtime when it reaches dirname(filePath)/appendFile(...), silently dropping rejection-audit writes on Windows setups. Use a platform-aware absolute check (for example path.isAbsolute) before deciding to call api.resolvePath.

Useful? React with 👍 / 👎.


return async (entry: AdmissionRejectionAuditEntry) => {
try {
Expand Down Expand Up @@ -4007,9 +4014,33 @@ const memoryLanceDBProPlugin = {

async function runBackup() {
try {
const backupDir = api.resolvePath(
join(resolvedDbPath, "..", "backups"),
);
// ── Defensive: guard against undefined resolvedDbPath ─────────────────
// api.resolvePath() may return undefined when config.dbPath is an
// empty string or an unhandled edge-case, rather than throwing.
// This guards against:
// TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be
// of type string or an instance of Buffer or URL. Received undefined
// (reported: backup failed at join(resolvedDbPath, "..", "backups"))
if (!resolvedDbPath || typeof resolvedDbPath !== "string") {
api.logger.warn(
`memory-lancedb-pro: backup skipped — resolvedDbPath is "${String(resolvedDbPath)}"`,
);
return;
}

// resolvedDbPath is already absolute (produced by api.resolvePath at
// plugin init); wrapping it again triggered a path-argument `undefined`
// in OpenClaw 2026.4.x's stricter plugin API. Join directly.
const backupDir = join(resolvedDbPath, "..", "backups");

// ── Secondary guard: ensure join() also returned a valid string ──────
if (!backupDir || typeof backupDir !== "string") {
api.logger.warn(
`memory-lancedb-pro: backup skipped — backupDir resolved to "${String(backupDir)}"`,
);
return;
}

await mkdir(backupDir, { recursive: true });

const allMemories = await store.list(undefined, undefined, 10000, 0);
Expand Down
117 changes: 117 additions & 0 deletions test/admission-rejection-audit-path.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import jitiFactory from "jiti";
import { join } from "path";

const jiti = jitiFactory(import.meta.url, { interopDefault: true });

const { resolveRejectedAuditFilePath } = jiti("../src/admission-control.ts");

// ============================================================================
// resolveRejectedAuditFilePath — path construction regression tests
//
// Issue #682 (PR #695): OpenClaw 2026.4.x strict plugin API causes
// api.resolvePath(already-absolute-path) → undefined
//
// This function is the shared path-construction layer used by both:
// - runBackup() in index.ts (fixed by PR #695)
// - admission rejection audit writer in index.ts (fixed alongside PR #695)
//
// When no explicit rejectedAuditFilePath is configured, the default derived
// path is already absolute (join of resolvedDbPath + ".."). The caller must
// NOT wrap it again in api.resolvePath.
//
// When an explicit path IS configured, the caller is responsible for resolving
// it based on whether it is relative or absolute.
// ============================================================================

describe("resolveRejectedAuditFilePath", () => {
const ABSOLUTE_DB_PATH = "/home/user/.openclaw/memory/lancedb-pro";

// --------------------------------------------------------------------------
// Default path — always derived from dbPath, always absolute
// --------------------------------------------------------------------------
it("returns an already-absolute path when no explicit config is set", () => {
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, null);
assert.ok(result.startsWith("/"), `Expected absolute path, got: ${result}`);
});

it("derived path contains admission-audit segment", () => {
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, null);
assert.ok(
result.includes("admission-audit"),
`Expected "admission-audit" in path, got: ${result}`,
);
});

it("derived path ends with rejections.jsonl", () => {
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, null);
assert.ok(
result.endsWith("rejections.jsonl"),
`Expected "rejections.jsonl" suffix, got: ${result}`,
);
});

it("derived path is based on dbPath .. parent", () => {
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, null);
const expectedParent = join(ABSOLUTE_DB_PATH, "..");
assert.ok(
result.startsWith(expectedParent),
`Expected path to start with "${expectedParent}", got: ${result}`,
);
});

// --------------------------------------------------------------------------
// Explicit relative path — returned as-is for caller to resolve
// --------------------------------------------------------------------------
it("returns explicit relative path as-is (caller must resolve)", () => {
const config = { rejectedAuditFilePath: "data/audit/rejections.jsonl" };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.strictEqual(result, "data/audit/rejections.jsonl");
});

it("trims whitespace from explicit relative path", () => {
const config = { rejectedAuditFilePath: " data/audit/rejections.jsonl " };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.strictEqual(result, "data/audit/rejections.jsonl");
});

// --------------------------------------------------------------------------
// Explicit absolute path — returned as-is, must NOT be re-resolved
// --------------------------------------------------------------------------
it("returns explicit absolute path as-is", () => {
const config = { rejectedAuditFilePath: "/var/log/memory/rejections.jsonl" };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.strictEqual(result, "/var/log/memory/rejections.jsonl");
});

it("explicit absolute path starts with / and must not be re-resolved", () => {
const config = { rejectedAuditFilePath: "/custom/path/rejections.jsonl" };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.ok(result.startsWith("/"), `Expected absolute, got: ${result}`);
});

// --------------------------------------------------------------------------
// Empty / whitespace-only explicit path — falls back to default
// --------------------------------------------------------------------------
it("treats empty string explicit path as unset (uses default)", () => {
const config = { rejectedAuditFilePath: "" };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.ok(result.endsWith("rejections.jsonl"), `Expected default, got: ${result}`);
});

it("treats whitespace-only explicit path as unset (uses default)", () => {
const config = { rejectedAuditFilePath: " " };
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.ok(result.endsWith("rejections.jsonl"), `Expected default, got: ${result}`);
});

// --------------------------------------------------------------------------
// Config object with no rejectedAuditFilePath key — uses default
// --------------------------------------------------------------------------
it("uses default when config has no rejectedAuditFilePath key", () => {
const config = {};
const result = resolveRejectedAuditFilePath(ABSOLUTE_DB_PATH, config);
assert.ok(result.endsWith("rejections.jsonl"), `Expected default, got: ${result}`);
});
});
Loading