mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
refactor(memory-host): localize host utilities
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { formatErrorMessage } from "./error-utils.js";
|
||||
|
||||
type BatchOutputErrorLike = {
|
||||
error?: { message?: string };
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../../src/infra/retry.js", () => ({
|
||||
retryAsync: vi.fn(async (run: () => Promise<unknown>) => await run()),
|
||||
}));
|
||||
|
||||
vi.mock("./post-json.js", () => ({
|
||||
postJson: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("postJsonWithRetry", () => {
|
||||
let retryAsyncMock: ReturnType<
|
||||
typeof vi.mocked<typeof import("../../../../src/infra/retry.js").retryAsync>
|
||||
>;
|
||||
let postJsonMock: ReturnType<typeof vi.mocked<typeof import("./post-json.js").postJson>>;
|
||||
let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry;
|
||||
let retryAsyncMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ postJsonWithRetry } = await import("./batch-http.js"));
|
||||
const retryModule = await import("../../../../src/infra/retry.js");
|
||||
const postJsonModule = await import("./post-json.js");
|
||||
retryAsyncMock = vi.mocked(retryModule.retryAsync);
|
||||
postJsonMock = vi.mocked(postJsonModule.postJson);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
retryAsyncMock = vi.fn(async (run: () => Promise<unknown>) => await run());
|
||||
});
|
||||
|
||||
it("posts JSON and returns parsed response payload", async () => {
|
||||
@@ -37,6 +30,7 @@ describe("postJsonWithRetry", () => {
|
||||
headers: { Authorization: "Bearer test" },
|
||||
body: { chunks: ["a", "b"] },
|
||||
errorPrefix: "memory batch failed",
|
||||
retryImpl: retryAsyncMock as typeof import("./retry-utils.js").retryAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, ids: [1, 2] });
|
||||
@@ -77,6 +71,7 @@ describe("postJsonWithRetry", () => {
|
||||
headers: {},
|
||||
body: { chunks: [] },
|
||||
errorPrefix: "memory batch failed",
|
||||
retryImpl: retryAsyncMock as typeof import("./retry-utils.js").retryAsync,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining("memory batch failed: 503 backend down"),
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import { retryAsync } from "../../../../src/infra/retry.js";
|
||||
import { postJson } from "./post-json.js";
|
||||
import { retryAsync } from "./retry-utils.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export async function postJsonWithRetry<T>(params: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
fetchImpl?: typeof fetch;
|
||||
retryImpl?: typeof retryAsync;
|
||||
body: unknown;
|
||||
errorPrefix: string;
|
||||
}): Promise<T> {
|
||||
return await retryAsync(
|
||||
const retry = params.retryImpl ?? retryAsync;
|
||||
return await retry(
|
||||
async () => {
|
||||
return await postJson<T>({
|
||||
url: params.url,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export type BatchHttpClientConfig = {
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { isTruthyEnvValue } from "../../../../src/infra/env.js";
|
||||
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
|
||||
|
||||
const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS);
|
||||
const log = createSubsystemLogger("memory/embeddings");
|
||||
|
||||
export function debugEmbeddingsLog(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!debugEmbeddings) {
|
||||
return;
|
||||
}
|
||||
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
|
||||
log.raw(`${message}${suffix}`);
|
||||
process.stderr.write(`${message}${suffix}\n`);
|
||||
}
|
||||
|
||||
function isTruthyEnvValue(value?: string): boolean {
|
||||
switch (normalizeLowercaseStringOrEmpty(value)) {
|
||||
case "1":
|
||||
case "on":
|
||||
case "true":
|
||||
case "yes":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { requireApiKey, resolveApiKeyForProvider } from "../../../../src/agents/model-auth.js";
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import { normalizeOptionalString } from "../../../../src/shared/string-coerce.js";
|
||||
import type { EmbeddingProviderOptions } from "./embeddings.types.js";
|
||||
import { buildRemoteBaseUrlPolicy } from "./remote-http.js";
|
||||
import { resolveMemorySecretInputString } from "./secret-input.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
import { normalizeOptionalString } from "./string-utils.js";
|
||||
|
||||
export type RemoteEmbeddingProviderId = string;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import { postJson } from "./post-json.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export async function fetchRemoteEmbeddingVectors(params: {
|
||||
url: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import {
|
||||
resolveRemoteEmbeddingBearerClient,
|
||||
type RemoteEmbeddingProviderId,
|
||||
} from "./embeddings-remote-client.js";
|
||||
import { fetchRemoteEmbeddingVectors } from "./embeddings-remote-fetch.js";
|
||||
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export type RemoteEmbeddingClient = {
|
||||
baseUrl: string;
|
||||
|
||||
89
packages/memory-host-sdk/src/host/error-utils.ts
Normal file
89
packages/memory-host-sdk/src/host/error-utils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const SECRET_PATTERNS: RegExp[] = [
|
||||
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g,
|
||||
/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature)=([^&\s"'<>]+)/gi,
|
||||
/"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"/g,
|
||||
/--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1/g,
|
||||
/Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
|
||||
/\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
|
||||
/(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd)=([^\s&#]+)/g,
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
|
||||
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
|
||||
/\b(ghp_[A-Za-z0-9]{20,})\b/g,
|
||||
/\b(github_pat_[A-Za-z0-9_]{20,})\b/g,
|
||||
/\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g,
|
||||
/\b(xapp-[A-Za-z0-9-]{10,})\b/g,
|
||||
/\b(gsk_[A-Za-z0-9_-]{10,})\b/g,
|
||||
/\b(AIza[0-9A-Za-z\-_]{20,})\b/g,
|
||||
/\b(pplx-[A-Za-z0-9_-]{10,})\b/g,
|
||||
/\b(npm_[A-Za-z0-9]{10,})\b/g,
|
||||
/\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
|
||||
/\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
|
||||
];
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length < 18) {
|
||||
return "***";
|
||||
}
|
||||
return `${token.slice(0, 6)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
function redactPemBlock(block: string): string {
|
||||
const lines = block.split(/\r?\n/).filter(Boolean);
|
||||
if (lines.length < 2) {
|
||||
return "***";
|
||||
}
|
||||
return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`;
|
||||
}
|
||||
|
||||
function redactMatch(match: string, groups: string[]): string {
|
||||
if (match.includes("PRIVATE KEY-----")) {
|
||||
return redactPemBlock(match);
|
||||
}
|
||||
const token = groups.findLast((value) => typeof value === "string" && value.length > 0) ?? match;
|
||||
const masked = maskToken(token);
|
||||
return token === match ? masked : match.replace(token, masked);
|
||||
}
|
||||
|
||||
function redactSensitiveText(text: string): string {
|
||||
let next = text;
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
next = next.replace(pattern, (...args: string[]) =>
|
||||
redactMatch(args[0] ?? "", args.slice(1, -2)),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function formatErrorMessage(err: unknown): string {
|
||||
let formatted: string;
|
||||
if (err instanceof Error) {
|
||||
formatted = err.message || err.name || "Error";
|
||||
let cause: unknown = err.cause;
|
||||
const seen = new Set<unknown>([err]);
|
||||
while (cause && !seen.has(cause)) {
|
||||
seen.add(cause);
|
||||
if (cause instanceof Error) {
|
||||
if (cause.message) {
|
||||
formatted += ` | ${cause.message}`;
|
||||
}
|
||||
cause = cause.cause;
|
||||
} else if (typeof cause === "string") {
|
||||
formatted += ` | ${cause}`;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (typeof err === "string") {
|
||||
formatted = err;
|
||||
} else if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
formatted = String(err);
|
||||
} else {
|
||||
try {
|
||||
formatted = JSON.stringify(err);
|
||||
} catch {
|
||||
formatted = Object.prototype.toString.call(err);
|
||||
}
|
||||
}
|
||||
return redactSensitiveText(formatted);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { formatErrorMessage } from "./error-utils.js";
|
||||
|
||||
export function ensureMemoryIndexSchema(params: {
|
||||
db: DatabaseSync;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
|
||||
import { withRemoteHttpResponse } from "./remote-http.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export async function postJson<T>(params: {
|
||||
url: string;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgram,
|
||||
} from "../../../../src/plugin-sdk/windows-spawn.js";
|
||||
import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "./windows-spawn.js";
|
||||
|
||||
export type CliSpawnInvocation = {
|
||||
command: string;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
|
||||
import { formatErrorMessage } from "./error-utils.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
|
||||
|
||||
const log = createSubsystemLogger("memory");
|
||||
|
||||
export type QmdQueryResult = {
|
||||
docid?: string;
|
||||
score?: number;
|
||||
@@ -26,7 +23,7 @@ export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResul
|
||||
if (!trimmedStdout) {
|
||||
const context = trimmedStderr ? ` (stderr: ${summarizeQmdStderr(trimmedStderr)})` : "";
|
||||
const message = `stdout empty${context}`;
|
||||
log.warn(`qmd query returned invalid JSON: ${message}`);
|
||||
warnQmdQueryParseError(message);
|
||||
throw new Error(`qmd query returned invalid JSON: ${message}`);
|
||||
}
|
||||
try {
|
||||
@@ -45,11 +42,18 @@ export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResul
|
||||
throw new Error("qmd query JSON response was not an array");
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
log.warn(`qmd query returned invalid JSON: ${message}`);
|
||||
warnQmdQueryParseError(message);
|
||||
throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
function warnQmdQueryParseError(message: string): void {
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`qmd query returned invalid JSON: ${message}\n`);
|
||||
}
|
||||
|
||||
function isQmdNoResultsOutput(raw: string): boolean {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseAgentSessionKey } from "../../../../src/sessions/session-key-utils.js";
|
||||
import type { ResolvedQmdConfig } from "./backend-config.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -108,3 +107,16 @@ function normalizeQmdSessionKey(key?: string): string | undefined {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseAgentSessionKey(sessionKey: string | undefined | null): { rest: string } | null {
|
||||
const raw = normalizeOptionalLowercaseString(sessionKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3 || parts[0] !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const rest = parts.slice(2).join(":");
|
||||
return rest ? { rest } : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GUARDED_FETCH_MODE } from "../../../../src/infra/net/fetch-guard.js";
|
||||
import { withRemoteHttpResponse } from "./remote-http.js";
|
||||
import { MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE, withRemoteHttpResponse } from "./remote-http.js";
|
||||
|
||||
describe("package withRemoteHttpResponse", () => {
|
||||
function makeFetchDeps({ useEnvProxy = false }: { useEnvProxy?: boolean } = {}) {
|
||||
@@ -31,7 +30,7 @@ describe("package withRemoteHttpResponse", () => {
|
||||
expect(deps.calls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "https://memory.example/v1/embeddings",
|
||||
mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY,
|
||||
mode: MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fetchWithSsrFGuard, GUARDED_FETCH_MODE } from "../../../../src/infra/net/fetch-guard.js";
|
||||
import { fetchWithSsrFGuard } from "../../../../src/infra/net/fetch-guard.js";
|
||||
import { shouldUseEnvHttpProxyForUrl } from "../../../../src/infra/net/proxy-env.js";
|
||||
import {
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
type SsrFPolicy,
|
||||
} from "../../../../src/infra/net/ssrf.js";
|
||||
import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "../../../../src/infra/net/ssrf.js";
|
||||
import type { SsrFPolicy } from "./ssrf-policy.js";
|
||||
|
||||
export const buildRemoteBaseUrlPolicy = ssrfPolicyFromHttpBaseUrlAllowedHostname;
|
||||
export const MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE = "trusted_env_proxy";
|
||||
|
||||
export const buildRemoteBaseUrlPolicy: (baseUrl: string) => SsrFPolicy | undefined =
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname;
|
||||
|
||||
export async function withRemoteHttpResponse<T>(params: {
|
||||
url: string;
|
||||
@@ -25,7 +26,7 @@ export async function withRemoteHttpResponse<T>(params: {
|
||||
init: params.init,
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: params.auditContext ?? "memory-remote",
|
||||
...(shouldUseEnvProxy(params.url) ? { mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY } : {}),
|
||||
...(shouldUseEnvProxy(params.url) ? { mode: MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE } : {}),
|
||||
});
|
||||
try {
|
||||
return await params.onResponse(response);
|
||||
|
||||
141
packages/memory-host-sdk/src/host/retry-utils.ts
Normal file
141
packages/memory-host-sdk/src/host/retry-utils.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export type RetryConfig = {
|
||||
attempts?: number;
|
||||
minDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitter?: number;
|
||||
};
|
||||
|
||||
export type RetryInfo = {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
err: unknown;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type RetryOptions = RetryConfig & {
|
||||
label?: string;
|
||||
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
||||
retryAfterMs?: (err: unknown) => number | undefined;
|
||||
onRetry?: (info: RetryInfo) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_RETRY_CONFIG = {
|
||||
attempts: 3,
|
||||
minDelayMs: 300,
|
||||
maxDelayMs: 30_000,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clampNumber(value: unknown, fallback: number, min?: number, max?: number): number {
|
||||
const next = asFiniteNumber(value);
|
||||
if (next === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const floor = typeof min === "number" ? min : Number.NEGATIVE_INFINITY;
|
||||
const ceiling = typeof max === "number" ? max : Number.POSITIVE_INFINITY;
|
||||
return Math.min(Math.max(next, floor), ceiling);
|
||||
}
|
||||
|
||||
export function resolveRetryConfig(
|
||||
defaults: Required<RetryConfig> = DEFAULT_RETRY_CONFIG,
|
||||
overrides?: RetryConfig,
|
||||
): Required<RetryConfig> {
|
||||
const attempts = Math.max(1, Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)));
|
||||
const minDelayMs = Math.max(
|
||||
0,
|
||||
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
|
||||
);
|
||||
const maxDelayMs = Math.max(
|
||||
minDelayMs,
|
||||
Math.round(clampNumber(overrides?.maxDelayMs, defaults.maxDelayMs, 0)),
|
||||
);
|
||||
const jitter = clampNumber(overrides?.jitter, defaults.jitter, 0, 1);
|
||||
return { attempts, minDelayMs, maxDelayMs, jitter };
|
||||
}
|
||||
|
||||
function applyJitter(delayMs: number, jitter: number): number {
|
||||
if (jitter <= 0) {
|
||||
return delayMs;
|
||||
}
|
||||
const offset = (Math.random() * 2 - 1) * jitter;
|
||||
return Math.max(0, Math.round(delayMs * (1 + offset)));
|
||||
}
|
||||
|
||||
export async function retryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
attemptsOrOptions: number | RetryOptions = 3,
|
||||
initialDelayMs = 300,
|
||||
): Promise<T> {
|
||||
if (typeof attemptsOrOptions === "number") {
|
||||
const attempts = Math.max(1, Math.round(attemptsOrOptions));
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === attempts - 1) {
|
||||
break;
|
||||
}
|
||||
await sleep(initialDelayMs * 2 ** i);
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error("Retry failed");
|
||||
}
|
||||
|
||||
const options = attemptsOrOptions;
|
||||
const resolved = resolveRetryConfig(DEFAULT_RETRY_CONFIG, options);
|
||||
const maxAttempts = resolved.attempts;
|
||||
const minDelayMs = resolved.minDelayMs;
|
||||
const maxDelayMs =
|
||||
Number.isFinite(resolved.maxDelayMs) && resolved.maxDelayMs > 0
|
||||
? resolved.maxDelayMs
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const shouldRetry = options.shouldRetry ?? (() => true);
|
||||
let lastErr: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const retryAfterMs = options.retryAfterMs?.(err);
|
||||
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
|
||||
const baseDelay = hasRetryAfter
|
||||
? Math.max(retryAfterMs, minDelayMs)
|
||||
: minDelayMs * 2 ** (attempt - 1);
|
||||
let delay = Math.min(baseDelay, maxDelayMs);
|
||||
delay = applyJitter(delay, resolved.jitter);
|
||||
delay = Math.min(Math.max(delay, minDelayMs), maxDelayMs);
|
||||
|
||||
options.onRetry?.({
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delayMs: delay,
|
||||
err,
|
||||
label: options.label,
|
||||
});
|
||||
if (delay > 0) {
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastErr ?? new Error("Retry failed");
|
||||
}
|
||||
142
packages/memory-host-sdk/src/host/secret-input-utils.ts
Normal file
142
packages/memory-host-sdk/src/host/secret-input-utils.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export type SecretRefSource = "env" | "file" | "exec";
|
||||
|
||||
export type SecretRef = {
|
||||
source: SecretRefSource;
|
||||
provider: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SECRET_PROVIDER_ALIAS = "default";
|
||||
const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
|
||||
const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:";
|
||||
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeSecretInputString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isSecretRef(value: unknown): value is SecretRef {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(value).length !== 3) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(value.source === "env" || value.source === "file" || value.source === "exec") &&
|
||||
typeof value.provider === "string" &&
|
||||
value.provider.trim().length > 0 &&
|
||||
typeof value.id === "string" &&
|
||||
value.id.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacySecretRefWithoutProvider(
|
||||
value: unknown,
|
||||
): value is { source: SecretRefSource; id: string } {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(value.source === "env" || value.source === "file" || value.source === "exec") &&
|
||||
typeof value.id === "string" &&
|
||||
value.id.trim().length > 0 &&
|
||||
value.provider === undefined
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvTemplateSecretRef(value: unknown): SecretRef | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const match = ENV_SECRET_TEMPLATE_RE.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
source: "env",
|
||||
provider: DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
id: match[1] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacySecretRefEnvMarker(value: unknown): SecretRef | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith(LEGACY_SECRETREF_ENV_MARKER_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const id = trimmed.slice(LEGACY_SECRETREF_ENV_MARKER_PREFIX.length);
|
||||
if (!ENV_SECRET_REF_ID_RE.test(id)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
source: "env",
|
||||
provider: DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceSecretRef(value: unknown): SecretRef | null {
|
||||
if (isSecretRef(value)) {
|
||||
return value;
|
||||
}
|
||||
if (isLegacySecretRefWithoutProvider(value)) {
|
||||
return {
|
||||
source: value.source,
|
||||
provider: DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
id: value.id,
|
||||
};
|
||||
}
|
||||
return parseEnvTemplateSecretRef(value) ?? parseLegacySecretRefEnvMarker(value);
|
||||
}
|
||||
|
||||
export function hasConfiguredSecretInput(value: unknown): boolean {
|
||||
if (normalizeSecretInputString(value)) {
|
||||
return true;
|
||||
}
|
||||
return coerceSecretRef(value) !== null;
|
||||
}
|
||||
|
||||
function formatSecretRefLabel(ref: SecretRef): string {
|
||||
return `${ref.source}:${ref.provider}:${ref.id}`;
|
||||
}
|
||||
|
||||
function createUnresolvedSecretInputError(params: { path: string; ref: SecretRef }): Error {
|
||||
return new Error(
|
||||
`${params.path}: unresolved SecretRef "${formatSecretRefLabel(params.ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSecretInputRef(value: unknown): SecretRef | null {
|
||||
return coerceSecretRef(value);
|
||||
}
|
||||
|
||||
export function normalizeResolvedSecretInputString(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
const normalized = normalizeSecretInputString(params.value);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const ref = resolveSecretInputRef(params.value);
|
||||
if (!ref) {
|
||||
return undefined;
|
||||
}
|
||||
throw createUnresolvedSecretInputError({ path: params.path, ref });
|
||||
}
|
||||
|
||||
export function normalizeEnvSecretInputString(value: unknown): string | undefined {
|
||||
return normalizeSecretInputString(value);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeEnvSecretInputString,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
resolveSecretInputRef,
|
||||
} from "../../../../src/config/types.secrets.js";
|
||||
} from "./secret-input-utils.js";
|
||||
|
||||
export function hasConfiguredMemorySecretInput(value: unknown): boolean {
|
||||
return hasConfiguredSecretInput(value);
|
||||
@@ -13,9 +13,9 @@ export function resolveMemorySecretInputString(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
const { ref } = resolveSecretInputRef({ value: params.value });
|
||||
const ref = resolveSecretInputRef(params.value);
|
||||
if (ref?.source === "env") {
|
||||
const envValue = normalizeSecretInputString(process.env[ref.id]);
|
||||
const envValue = normalizeEnvSecretInputString(process.env[ref.id]);
|
||||
if (envValue) {
|
||||
return envValue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { formatErrorMessage } from "./error-utils.js";
|
||||
import { normalizeOptionalString } from "./string-utils.js";
|
||||
|
||||
type SqliteVecModule = {
|
||||
|
||||
74
packages/memory-host-sdk/src/host/sqlite-wal.ts
Normal file
74
packages/memory-host-sdk/src/host/sqlite-wal.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
|
||||
const DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
type IntervalHandle = ReturnType<typeof setInterval> & {
|
||||
unref?: () => void;
|
||||
};
|
||||
|
||||
type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
|
||||
|
||||
export type SqliteWalMaintenance = {
|
||||
checkpoint: () => boolean;
|
||||
close: () => boolean;
|
||||
};
|
||||
|
||||
export type SqliteWalMaintenanceOptions = {
|
||||
autoCheckpointPages?: number;
|
||||
checkpointIntervalMs?: number;
|
||||
checkpointMode?: SqliteWalCheckpointMode;
|
||||
onCheckpointError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function normalizeNonNegativeInteger(value: number, label: string): number {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function configureSqliteWalMaintenance(
|
||||
db: DatabaseSync,
|
||||
options: SqliteWalMaintenanceOptions = {},
|
||||
): SqliteWalMaintenance {
|
||||
const autoCheckpointPages = normalizeNonNegativeInteger(
|
||||
options.autoCheckpointPages ?? DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES,
|
||||
"autoCheckpointPages",
|
||||
);
|
||||
const checkpointIntervalMs = normalizeNonNegativeInteger(
|
||||
options.checkpointIntervalMs ?? DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS,
|
||||
"checkpointIntervalMs",
|
||||
);
|
||||
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
|
||||
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec(`PRAGMA wal_autocheckpoint = ${autoCheckpointPages};`);
|
||||
|
||||
const checkpoint = (): boolean => {
|
||||
try {
|
||||
db.exec(`PRAGMA wal_checkpoint(${checkpointMode});`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
options.onCheckpointError?.(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let timer: IntervalHandle | null = null;
|
||||
if (checkpointIntervalMs > 0) {
|
||||
timer = setInterval(checkpoint, checkpointIntervalMs) as IntervalHandle;
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
return {
|
||||
checkpoint,
|
||||
close: () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
return checkpoint();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { formatErrorMessage } from "./error-utils.js";
|
||||
import {
|
||||
configureSqliteWalMaintenance,
|
||||
type SqliteWalMaintenance,
|
||||
type SqliteWalMaintenanceOptions,
|
||||
} from "../../../../src/infra/sqlite-wal.js";
|
||||
import { installProcessWarningFilter } from "../../../../src/infra/warning-filter.js";
|
||||
} from "./sqlite-wal.js";
|
||||
import { installProcessWarningFilter } from "./warning-filter.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const sqliteWalMaintenanceByDb = new WeakMap<DatabaseSync, SqliteWalMaintenance>();
|
||||
|
||||
7
packages/memory-host-sdk/src/host/ssrf-policy.ts
Normal file
7
packages/memory-host-sdk/src/host/ssrf-policy.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type SsrFPolicy = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
allowedHostnames?: string[];
|
||||
hostnameAllowlist?: string[];
|
||||
};
|
||||
105
packages/memory-host-sdk/src/host/warning-filter.ts
Normal file
105
packages/memory-host-sdk/src/host/warning-filter.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
const warningFilterKey = Symbol.for("openclaw.warning-filter");
|
||||
|
||||
export type ProcessWarning = {
|
||||
code?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ProcessWarningInstallState = {
|
||||
installed: boolean;
|
||||
};
|
||||
|
||||
function resolveWarningFilterState(): ProcessWarningInstallState {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
if (Object.prototype.hasOwnProperty.call(globalStore, warningFilterKey)) {
|
||||
return globalStore[warningFilterKey] as ProcessWarningInstallState;
|
||||
}
|
||||
const state = { installed: false };
|
||||
globalStore[warningFilterKey] = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function shouldIgnoreWarning(warning: ProcessWarning): boolean {
|
||||
if (warning.code === "DEP0040" && warning.message?.includes("punycode")) {
|
||||
return true;
|
||||
}
|
||||
if (warning.code === "DEP0060" && warning.message?.includes("util._extend")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
warning.name === "ExperimentalWarning" &&
|
||||
warning.message?.includes("SQLite is an experimental feature")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeWarningArgs(args: unknown[]): ProcessWarning {
|
||||
const warningArg = args[0];
|
||||
const secondArg = args[1];
|
||||
const thirdArg = args[2];
|
||||
let name: string | undefined;
|
||||
let code: string | undefined;
|
||||
let message: string | undefined;
|
||||
|
||||
if (warningArg instanceof Error) {
|
||||
name = warningArg.name;
|
||||
message = warningArg.message;
|
||||
code = (warningArg as Error & { code?: string }).code;
|
||||
} else if (typeof warningArg === "string") {
|
||||
message = warningArg;
|
||||
}
|
||||
|
||||
if (secondArg && typeof secondArg === "object" && !Array.isArray(secondArg)) {
|
||||
const options = secondArg as { type?: unknown; code?: unknown };
|
||||
if (typeof options.type === "string") {
|
||||
name = options.type;
|
||||
}
|
||||
if (typeof options.code === "string") {
|
||||
code = options.code;
|
||||
}
|
||||
} else {
|
||||
if (typeof secondArg === "string") {
|
||||
name = secondArg;
|
||||
}
|
||||
if (typeof thirdArg === "string") {
|
||||
code = thirdArg;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, code, message };
|
||||
}
|
||||
|
||||
export function installProcessWarningFilter(): void {
|
||||
const state = resolveWarningFilterState();
|
||||
if (state.installed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalEmitWarning = process.emitWarning.bind(process);
|
||||
const wrappedEmitWarning: typeof process.emitWarning = ((...args: unknown[]) => {
|
||||
if (shouldIgnoreWarning(normalizeWarningArgs(args))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
args[0] instanceof Error &&
|
||||
args[1] &&
|
||||
typeof args[1] === "object" &&
|
||||
!Array.isArray(args[1])
|
||||
) {
|
||||
const warning = args[0];
|
||||
const emitted = Object.assign(new Error(warning.message), {
|
||||
name: warning.name,
|
||||
code: (warning as Error & { code?: string }).code,
|
||||
});
|
||||
process.emit("warning", emitted);
|
||||
return;
|
||||
}
|
||||
Reflect.apply(originalEmitWarning, process, args);
|
||||
}) as typeof process.emitWarning;
|
||||
|
||||
process.emitWarning = wrappedEmitWarning;
|
||||
state.installed = true;
|
||||
}
|
||||
285
packages/memory-host-sdk/src/host/windows-spawn.ts
Normal file
285
packages/memory-host-sdk/src/host/windows-spawn.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-utils.js";
|
||||
|
||||
type WindowsSpawnResolution = "direct" | "node-entrypoint" | "exe-entrypoint" | "shell-fallback";
|
||||
|
||||
type WindowsSpawnCandidateResolution = Exclude<WindowsSpawnResolution, "shell-fallback">;
|
||||
|
||||
type WindowsSpawnProgramCandidate = {
|
||||
command: string;
|
||||
leadingArgv: string[];
|
||||
resolution: WindowsSpawnCandidateResolution | "unresolved-wrapper";
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
export type WindowsSpawnProgram = {
|
||||
command: string;
|
||||
leadingArgv: string[];
|
||||
resolution: WindowsSpawnResolution;
|
||||
shell?: boolean;
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
export type WindowsSpawnInvocation = {
|
||||
command: string;
|
||||
argv: string[];
|
||||
resolution: WindowsSpawnResolution;
|
||||
shell?: boolean;
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
export type ResolveWindowsSpawnProgramParams = {
|
||||
command: string;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
execPath?: string;
|
||||
packageName?: string;
|
||||
allowShellFallback?: boolean;
|
||||
};
|
||||
|
||||
function isFilePath(candidate: string): boolean {
|
||||
try {
|
||||
return statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string {
|
||||
if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
||||
const pathEntries = pathValue
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const hasExtension = path.extname(command).length > 0;
|
||||
const pathExtRaw =
|
||||
env.PATHEXT ??
|
||||
env.Pathext ??
|
||||
process.env.PATHEXT ??
|
||||
process.env.Pathext ??
|
||||
".EXE;.CMD;.BAT;.COM";
|
||||
const pathExt = hasExtension
|
||||
? [""]
|
||||
: pathExtRaw
|
||||
.split(";")
|
||||
.map((ext) => ext.trim())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
|
||||
|
||||
for (const dir of pathEntries) {
|
||||
for (const ext of pathExt) {
|
||||
const normalizedExt = normalizeLowercaseStringOrEmpty(ext);
|
||||
const uppercaseExt = ext.toUpperCase();
|
||||
for (const candidateExt of [ext, normalizedExt, uppercaseExt]) {
|
||||
const candidate = path.join(dir, `${command}${candidateExt}`);
|
||||
if (isFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
function resolveEntrypointFromCmdShim(wrapperPath: string): string | null {
|
||||
if (!isFilePath(wrapperPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(wrapperPath, "utf8");
|
||||
const candidates: string[] = [];
|
||||
for (const match of content.matchAll(/"([^"\r\n]*)"/g)) {
|
||||
const token = match[1] ?? "";
|
||||
const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i);
|
||||
const relative = relMatch?.[1]?.trim();
|
||||
if (!relative) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, "");
|
||||
const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
|
||||
if (isFilePath(candidate)) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
const nonNode = candidates.find((candidate) => {
|
||||
const base = normalizeLowercaseStringOrEmpty(path.basename(candidate));
|
||||
return base !== "node.exe" && base !== "node";
|
||||
});
|
||||
return nonNode ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBinEntry(
|
||||
packageName: string | undefined,
|
||||
binField: string | Record<string, string> | undefined,
|
||||
): string | null {
|
||||
if (typeof binField === "string") {
|
||||
return normalizeOptionalString(binField) ?? null;
|
||||
}
|
||||
if (!binField || typeof binField !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (packageName) {
|
||||
const preferred = binField[packageName];
|
||||
const normalizedPreferred =
|
||||
typeof preferred === "string" ? normalizeOptionalString(preferred) : undefined;
|
||||
if (normalizedPreferred) {
|
||||
return normalizedPreferred;
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(binField)) {
|
||||
const normalizedValue = typeof value === "string" ? normalizeOptionalString(value) : undefined;
|
||||
if (normalizedValue) {
|
||||
return normalizedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEntrypointFromPackageJson(
|
||||
wrapperPath: string,
|
||||
packageName?: string,
|
||||
): string | null {
|
||||
if (!packageName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapperDir = path.dirname(wrapperPath);
|
||||
const packageDirs = [
|
||||
path.resolve(wrapperDir, "..", packageName),
|
||||
path.resolve(wrapperDir, "node_modules", packageName),
|
||||
];
|
||||
|
||||
for (const packageDir of packageDirs) {
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
if (!isFilePath(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const entryRel = resolveBinEntry(packageName, packageJson.bin);
|
||||
if (!entryRel) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.resolve(packageDir, entryRel);
|
||||
if (isFilePath(entryPath)) {
|
||||
return entryPath;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed package metadata.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWindowsSpawnProgramCandidate(
|
||||
params: ResolveWindowsSpawnProgramParams,
|
||||
): WindowsSpawnProgramCandidate {
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = params.env ?? process.env;
|
||||
const execPath = params.execPath ?? process.execPath;
|
||||
|
||||
if (platform !== "win32") {
|
||||
return {
|
||||
command: params.command,
|
||||
leadingArgv: [],
|
||||
resolution: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedCommand = resolveWindowsExecutablePath(params.command, env);
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(resolvedCommand));
|
||||
if (ext === ".js" || ext === ".cjs" || ext === ".mjs") {
|
||||
return {
|
||||
command: execPath,
|
||||
leadingArgv: [resolvedCommand],
|
||||
resolution: "node-entrypoint",
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === ".cmd" || ext === ".bat") {
|
||||
const entrypoint =
|
||||
resolveEntrypointFromCmdShim(resolvedCommand) ??
|
||||
resolveEntrypointFromPackageJson(resolvedCommand, params.packageName);
|
||||
if (entrypoint) {
|
||||
const entryExt = normalizeLowercaseStringOrEmpty(path.extname(entrypoint));
|
||||
if (entryExt === ".exe") {
|
||||
return {
|
||||
command: entrypoint,
|
||||
leadingArgv: [],
|
||||
resolution: "exe-entrypoint",
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: execPath,
|
||||
leadingArgv: [entrypoint],
|
||||
resolution: "node-entrypoint",
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: resolvedCommand,
|
||||
leadingArgv: [],
|
||||
resolution: "unresolved-wrapper",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: resolvedCommand,
|
||||
leadingArgv: [],
|
||||
resolution: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowsSpawnProgram(
|
||||
params: ResolveWindowsSpawnProgramParams,
|
||||
): WindowsSpawnProgram {
|
||||
const candidate = resolveWindowsSpawnProgramCandidate(params);
|
||||
if (candidate.resolution !== "unresolved-wrapper") {
|
||||
return {
|
||||
command: candidate.command,
|
||||
leadingArgv: candidate.leadingArgv,
|
||||
resolution: candidate.resolution,
|
||||
windowsHide: candidate.windowsHide,
|
||||
};
|
||||
}
|
||||
if (params.allowShellFallback === true) {
|
||||
return {
|
||||
command: candidate.command,
|
||||
leadingArgv: [],
|
||||
resolution: "shell-fallback",
|
||||
shell: true,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`${path.basename(candidate.command)} wrapper resolved, but no executable/Node entrypoint could be resolved without shell execution.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function materializeWindowsSpawnProgram(
|
||||
program: WindowsSpawnProgram,
|
||||
argv: string[],
|
||||
): WindowsSpawnInvocation {
|
||||
return {
|
||||
command: program.command,
|
||||
argv: [...program.leadingArgv, ...argv],
|
||||
resolution: program.resolution,
|
||||
shell: program.shell,
|
||||
windowsHide: program.windowsHide,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user