diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 036e1078417b..f824cadad96d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -886,8 +886,6 @@ src/shared/json-schema-defaults.ts src/shared/text/assistant-visible-text.ts src/skills/lifecycle/clawhub.test.ts src/skills/lifecycle/install.ts -src/skills/loading/workspace-load.test.ts -src/skills/loading/workspace.ts src/skills/security/scanner.ts src/skills/workshop/service.test.ts src/snapshot/local-repository.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index d34b0beb8677..ae84a3eca36e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,17 +53,17 @@ {"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"d0cc2d1a92a55bfe73ad51bb32078cf4e8911a2a5a2f1c2f3190ba044922e56b","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} +{"closureHash":"bf2a01a087d508c066401f5df6cc34065b95a11718244ce38f0525140bf11524","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"d759a25775cb15d3f1804e39e98518f83b67b29ba3ab2fd84d4bf16106d9fdb9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"bd666466495cef203461f4484eb439faa6a4fd10756c44a64a45ddef3b65961c","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"bfca309d41b8bed7b9b967ea4c643d6044cbd3dead3c87c6f05ea2d01859c4e9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"d813ac33976a047720de5f7b9612a0c703f516d78615e61a9880de0dff51e191","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"008b6c78d5e44a9d1df1d7e32e6015382ee9ba1cb9b10bd4c2b467619d1c89d4","declaration":"export type AgentToolResultMiddlewareEvent = AgentToolResultMiddlewareEvent;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddlewareEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"7414a2a79c2d78c1d52538250c827305bba1a2bb25010448837c8524a9f95b3c","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"agent-harness","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"7cce5f7355df47a66ed208855889b16b2c40782a75575e8e1a8dce43517b801f","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"b56f77aa308700cc9ea82f50957a860852d0c83c006b5020d97f0d9b2d6c9d6a","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"9257c34a998d535fee6b043e0931dc84d395f7373876dc919c71b4f2d9d71e09","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"a3434c206c28a79c697b64019add9861228634c250df3a17c0a43caa203e9ef9","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"category":"runtime","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","recordType":"module"} {"closureHash":"fab99dfcb01d475a1bfdbe7a8fac9be10a93dac5f12ac78c2dea673c8ab86abf","declaration":"export function abortAgentHarnessRun(sessionId: string): boolean;\nexport function abortAgentHarnessRun(sessionId: undefined, opts: { mode: \"all\" | \"compacting\"; reason?: \"restart\"; }): boolean;","entrypoint":"agent-harness-runtime","exportName":"abortAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -82,8 +82,8 @@ {"closureHash":"f8ad3d762c9753fdc36b2b9af0980167fdc54090c7bcca2f3dbe7c8951e86821","declaration":"export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): AgentRuntimePlan;","entrypoint":"agent-harness-runtime","exportName":"buildAgentRuntimePlan","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"528d11abf061b0b303722737de5a6406e7e55bffa38c45144c77d896ae1b3b0a","declaration":"export function buildBootstrapContextForFiles(bootstrapFiles: WorkspaceBootstrapFile[], params: { config?: OpenClawConfig; agentId?: string | null; warn?: (message: string) => void; }): EmbeddedContextFile[];","entrypoint":"agent-harness-runtime","exportName":"buildBootstrapContextForFiles","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"44d5a4667d2f3dc3a28e5c9d7b46765b1fb3c62820288e40251f5174911c8a6a","declaration":"export function buildEmbeddedAttemptToolRunContext(params: { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; toolsAllow?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; }): { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; runtimeToolAllowlist?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; };","entrypoint":"agent-harness-runtime","exportName":"buildEmbeddedAttemptToolRunContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"273bc789e32d09355911a553f30e7fd13f0ac7fc7d08f76d6c6ea96b8ab4e1d3","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"9cd796a6dc567e647776b5d9af435e8c179796142948b64b088eb9e91c42ba66","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"3af35ed22836860ff7eb66ec82b8f2fa3758fb5ed48bd909ea375657a195b15a","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"626d86d7fae06a3415dd20afa8f907e8c71541a043821dadf8758942b8e873b4","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"33a7cd8d63d4ef045ee3a42f806026a8b621e7dd32d8d6fbf4e7b44452d5e0ce","declaration":"export function buildNativeHookRelayCommand(params: { provider: NativeHookRelayProvider; relayId: string; generation?: string; event: NativeHookRelayEvent; preToolUseUnavailable?: \"noop\"; timeoutMs?: number; executable?: string; nice?: number | false; nodeExecutable?: string; }): string;","entrypoint":"agent-harness-runtime","exportName":"buildNativeHookRelayCommand","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"3846647c3c2fb0242f70591cf95c2e02f2a5a3c44447efb4cf7a31b2dc2122fd","declaration":"export function buildSkillWorkshopPromptSection(): string[];","entrypoint":"agent-harness-runtime","exportName":"buildSkillWorkshopPromptSection","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"32e977ece65144a83ae1b0a3c8d1b9b00a2a2506ad9ef1db3b2b9770ce762dbe","declaration":"export function buildWatchedSessionsHarnessContext(params: { config?: OpenClawConfig; sessionKey?: string; sandboxed?: boolean; toolNames: Iterable; capabilityToolNames?: Iterable; }): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"buildWatchedSessionsHarnessContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -100,7 +100,7 @@ {"closureHash":"51f76e9e37b65b358f0313e10152d950b003bc3e50f4bb26fe29a988237772e2","declaration":"export function consumePreExecutionBlockedToolCall(toolCallId: string, runId?: string): boolean;","entrypoint":"agent-harness-runtime","exportName":"consumePreExecutionBlockedToolCall","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness-runtime","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness-runtime","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"1368f69c7a2b975961e4bec1a8709522669fed0df0eca2d669159c9e65eb1cf3","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"a82ff9b3a834c5c4bf16dfd0b211827dd8395e72079e9aa1549bacbe9d199a61","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"6f436485257f69fa07a18203b91d4341690522f12aff4f25805c7a1e21326ee3","declaration":"export function detectAndLoadAgentHarnessPromptImages(params: { prompt: string; workspaceDir: string; model: { input?: string[]; }; existingImages?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; media?: MediaFact[]; config?: OpenClawConfig; workspaceOnly?: boolean; localRoots?: readonly string[]; sandbox?: { root: string; bridge: SandboxFsBridge; }; }): Promise<{ images: ImageContent[]; detectedRefs: Array<{ raw: string; resolved: string; type: \"path\" | \"media-uri\"; }>; loadedCount: number; skippedCount: number; }>;","entrypoint":"agent-harness-runtime","exportName":"detectAndLoadAgentHarnessPromptImages","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness-runtime","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c38cd390190bb07ddcd247420d6e0b489a0229bd402ddca0440417217a57251a","declaration":"export function emitAgentEvent(event: Omit): void;","entrypoint":"agent-harness-runtime","exportName":"emitAgentEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -162,7 +162,7 @@ {"closureHash":"40154c3b7b2a7179df33f4a3fbef5e56078be2759d5aab29f39eb0f3c92fc631","declaration":"export function projectRuntimeToolInputSchema(schema: unknown, path?: string): RuntimeToolInputSchemaProjection;","entrypoint":"agent-harness-runtime","exportName":"projectRuntimeToolInputSchema","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"e42e3dc63e8545bb9a6056336fbd6c1dc8f356dce4f0f1c6d9765254c974390e","declaration":"export function projectSettledTurnFinalizationAttemptResult(result: AgentHarnessAttemptResult): AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"projectSettledTurnFinalizationAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"28fce991efc7c1c2e4d24a5380acdeca72aea42a3e3b14d053ec352a84ab5211","declaration":"export function queueAgentHarnessMessage(sessionId: string, text: string, options?: EmbeddedAgentQueueMessageOptions): boolean;","entrypoint":"agent-harness-runtime","exportName":"queueAgentHarnessMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"a5942178bfa7de60f6b738d58d5f511a7baa1abf25119454180b81daec365833","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"a415720a81deec67cd76000e5f813229d14f883ed9cef6eec4a1ff637ede552c","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b7cc6c35506dc6955e0359aa5c53c15a4d0db23639da8c1717f8fefda953c119","declaration":"export function requestDeferredPluginToolApproval(params: { deferredApproval: DeferredPluginToolApproval; signal?: AbortSignal; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"requestDeferredPluginToolApproval","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"a2306cba08cb05ff8430c8362c0cc3c821706da5a4e65a06849ad0d0fb007ae3","declaration":"export function resetAgentEventsForTest(options?: { preserveListeners?: boolean; }): void;","entrypoint":"agent-harness-runtime","exportName":"resetAgentEventsForTest","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -197,7 +197,7 @@ {"closureHash":"da8b724c5858691b8a1c71c63294a397631c031270db92fdff9ef4493efa2492","declaration":"export function runAgentHarnessBeforeAgentFinalizeHook(params: { event: PluginHookBeforeAgentFinalizeEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeAgentFinalizeHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"8fec73ee8c7c9be3335bd6dc1bac9278f0be5beef22a5975eb06e56f504f4bd6","declaration":"export function runAgentHarnessBeforeCompactionHook(params: { sessionFile: string; messages?: AgentMessage[]; ctx: AgentHarnessHookContext; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeCompactionHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"021b81820bef2e8d54c50a5fc159d6665b23b50a1f5c40dae76a4833839d89c9","declaration":"export function runAgentHarnessBeforeMessageWriteHook(params: { message: AgentMessage; agentId?: string; sessionKey?: string; }): AgentMessage | null;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeMessageWriteHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"234d68d071e378bce7a3780dd308a39e6f611b722d86203c3366951420d9f1b1","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"45bbc96bc997e28c7af02ae4c96c2d7e8a7fc6b316495eda845d27156c463b4f","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"89aa32c12a44b446777bc4cfbb106a048dd9cbc668260525798a895a433256f4","declaration":"export function runAgentHarnessLlmInputHook(params: { event: PluginHookLlmInputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmInputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"ba63f9cdd34249fba984af87a039494b75b3f93c0c70b925e884f3f98935b984","declaration":"export function runAgentHarnessLlmOutputHook(params: { event: PluginHookLlmOutputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmOutputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c4c5131ab7065decd8d8e935ac3779e3baa2c52fbbbd95a6ccb624377db09ca1","declaration":"export function runBeforeToolCallHook(args: { toolName: string; params: unknown; toolKind?: PluginHookToolKind; toolInputKind?: PluginHookToolInputKind; toolCallId?: string; ctx?: HookContext; signal?: AbortSignal; approvalMode?: \"request\" | \"report\" | \"deny\" | \"defer\"; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runBeforeToolCallHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -219,13 +219,13 @@ {"closureHash":"0701a22057edc3b733da7e05948db94aa98b337b224a5718efdd85212a8602bf","declaration":"export const TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT: string;","entrypoint":"agent-harness-runtime","exportName":"TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"b5845c99df6c2a834a16678c320e0111bad96a3a7ac245d379aa5e6d2555d29b","declaration":"export const agentHarnessAttemptTerminal: { merge: (current: AgentRunAttemptTerminal, incoming: AgentRunAttemptTerminal) => AgentRunAttemptTerminal; normalize: (input: { aborted?: boolean; externalAbort?: boolean; idleTimedOut?: boolean; promptError?: unknown; promptErrorSource?: AgentRunAttemptFailureSource | null; timedOut?: boolean; timedOutByRunBudget?: boolean; timedOutDuringCompaction?: boolean; timedOutDuringToolExecution?: boolean; }) => AgentRunAttemptTerminal; project: (terminal: AgentRunAttemptTerminal) => { aborted: boolean; cleanupYieldAborted: boolean; externalAbort: boolean; failed: boolean; idleTimedOut: boolean; interrupted: boolean; promptError: unknown; promptErrorSource: AgentRunAttemptFailureSource | null; timedOut: boolean; timedOutByRunBudget: boolean; timedOutDuringCompaction: boolean; timedOutDuringToolExecution: boolean; }; setFailure: (terminal: AgentRunAttemptTerminal, failure: { source: AgentRunAttemptFailureSource; error: unknown; } | null) => AgentRunAttemptTerminal;};","entrypoint":"agent-harness-runtime","exportName":"agentHarnessAttemptTerminal","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"22bcf962aa76c76074c4ed4ad7d6558edbc3588e239263e09af74c1000bbe012","declaration":"export const embeddedAgentLog: SubsystemLogger;","entrypoint":"agent-harness-runtime","exportName":"embeddedAgentLog","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} -{"closureHash":"c0f6b29e20cd3f18f1f123bc1e5d458f391013e2812541fd58698155bd72fe96","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} +{"closureHash":"ca8e36c9027d9d5cdee1398e5ede7f0e4b0ffadffaca98dc88ff11ff522b064e","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"f237bbc11e1d389679cd4e2ad7aa9055951b3029348e3374b81f769786a51d55","declaration":"export type AbortAndDrainAgentHarnessRunResult = AbortAndDrainEmbeddedAgentRunResult;","entrypoint":"agent-harness-runtime","exportName":"AbortAndDrainAgentHarnessRunResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f802f46717d28d260bbcb2cf4790dfed4e9ab28c4fe822d259228189cca4665","declaration":"export type AgentApprovalEventData = AgentApprovalEventData;","entrypoint":"agent-harness-runtime","exportName":"AgentApprovalEventData","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"12aac3a57ce200b0f082002be237625729f84ed7c337f1bac90bcd0688a53319","declaration":"export type AgentEventPayload = AgentEventPayload;","entrypoint":"agent-harness-runtime","exportName":"AgentEventPayload","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"d759a25775cb15d3f1804e39e98518f83b67b29ba3ab2fd84d4bf16106d9fdb9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"d759a25775cb15d3f1804e39e98518f83b67b29ba3ab2fd84d4bf16106d9fdb9","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"acb796810881f352798f8026ebebc9ba1ce3faf3286785e4b9a964367ebd24d9","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"bfca309d41b8bed7b9b967ea4c643d6044cbd3dead3c87c6f05ea2d01859c4e9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"bfca309d41b8bed7b9b967ea4c643d6044cbd3dead3c87c6f05ea2d01859c4e9","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"9d6ae26d110c34587071b268243778e6dc8d6d490550c1d810a437b31657889e","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"2dc2a62deea63b316142cc28d40afb88fb0d1e5ce3e048f141420726cda38248","declaration":"export type AgentHarnessAttemptResult = AgentHarnessAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"c7e642234081160830fd9e18a301900f8ee1ea8c88382e5a286f0102de65b714","declaration":"export type AgentHarnessAuthBindingFingerprintParams = AgentHarnessAuthBindingFingerprintParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAuthBindingFingerprintParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"8f496ce75f294f2aa440a49c3c5b065ddff3a4714df775116af5e53f7fd14fe5","declaration":"export type AgentHarnessCompactParams = CompactEmbeddedAgentSessionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessCompactParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -239,8 +239,8 @@ {"closureHash":"95741b872a194687696eba4a96d6e887ec6e81922695fa0282cb2f0a46ff9f97","declaration":"export type AgentHarnessSessionForkParams = AgentHarnessSessionForkParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f3c46fef2a6c3393b0b6ad09915abcd6f57bb58ddddde1d60ebd113c7a1c5fa6","declaration":"export type AgentHarnessSessionForkResult = AgentHarnessSessionForkResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4b5a59730309e446f59a8ec1c554c3193aca1959916594cc657347098bd67a35","declaration":"export type AgentHarnessSettledTurnFinalizationResult = AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSettledTurnFinalizationResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"e2c320165e7f072dd5d053d82be3fa5db3b3648112d39a3b40e393d92b07033a","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"1311982296ef9a93957c4559b85e87fc6f3e414476b5c77418df522d2312f795","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"2ac87a91220b9b712e35d4da89186417bb0daf1306ec159b417a35a7d54bd200","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"39674e80bbf1656c7fad97fb9e2289be09603df97747e1f5026a0a69b4b1dc3d","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"e84173055503c40dd0ef1cb67c6d333e9257fcf995252b27c1d58f5c904e8de9","declaration":"export type AgentHarnessSideQuestionResult = AgentHarnessSideQuestionResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50214d08dac98e3c1031ac7bc733b85cb2247d2eef3b6b2045758ae403af7647","declaration":"export type AgentHarnessSupport = AgentHarnessSupport;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupport","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50ce26ea22bc225e6064d2e5f37356b6f4ce4a57f8b9fa42baa4c421da48aad5","declaration":"export type AgentHarnessSupportContext = AgentHarnessSupportContext;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupportContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -250,7 +250,7 @@ {"closureHash":"20a8a7c9d5ebf425c30d04eea3856c6c889a210f1fa53c99299df24882afbf92","declaration":"export type AgentHarnessUserInputOption = AgentHarnessUserInputOption;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputOption","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6224f51b4189f10a642ad7a8f50a902ec9c6a6a4f8dac0c0de5f7bc0271a27e5","declaration":"export type AgentHarnessUserInputPromptOptions = AgentHarnessUserInputPromptOptions;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputPromptOptions","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"d8f1d0d118e1536d1f442d11aeae5334a1ed7949a6c61824f547e12931278e1d","declaration":"export type AgentHarnessUserInputQuestion = AgentHarnessUserInputQuestion;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"bd666466495cef203461f4484eb439faa6a4fd10756c44a64a45ddef3b65961c","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"d813ac33976a047720de5f7b9612a0c703f516d78615e61a9880de0dff51e191","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f2af683e6a31cde5a473c77e4f03b8f0ec0351a43ea6c0af979d5b394a33f95","declaration":"export type AgentMessage = AgentMessage;","entrypoint":"agent-harness-runtime","exportName":"AgentMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"185abd2870d4df65e9f94623061a30167d17ef1279a395201fe429398bf23500","declaration":"export type AgentToolResultMiddlewareContext = AgentToolResultMiddlewareContext;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddlewareContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -276,8 +276,8 @@ {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedAgentCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedAgentCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"1bbefad0193436f7340f9d0dec50be5d482973325c3c65170b1053dd05566f0a","declaration":"export type EmbeddedContextFile = EmbeddedContextFile;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedContextFile","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedPiCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedPiCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"7cce5f7355df47a66ed208855889b16b2c40782a75575e8e1a8dce43517b801f","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"b56f77aa308700cc9ea82f50957a860852d0c83c006b5020d97f0d9b2d6c9d6a","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"9257c34a998d535fee6b043e0931dc84d395f7373876dc919c71b4f2d9d71e09","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"a3434c206c28a79c697b64019add9861228634c250df3a17c0a43caa203e9ef9","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"a6cd2eebe0d06e6578ec8438a207c5acda10ddf01041102d84e4c576a0b6a42f","declaration":"export type EmbeddedRunAttemptResult = EmbeddedRunAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f6cb2d6411db22cb04220777720d97bcca977351de1cb344ce2ff6ab126a3a08","declaration":"export type ExecApprovalDecision = ExecApprovalDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecApprovalDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"7acb0dffc7bcda6c727e4afc49c955fa8e14f4f8b7b45d197ed794dfa0d73b8e","declaration":"export type ExecAutoReviewDecision = ExecAutoReviewDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecAutoReviewDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -293,7 +293,7 @@ {"closureHash":"d8a720bf250da8daf4dc6a52087aa8728ec33b650b3ad3906fcb40e184de66a0","declaration":"export type NativeHookRelayEvent = \"pre_tool_use\" | \"post_tool_use\" | \"permission_request\" | \"before_agent_finalize\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cb72ca18e056e52e779d0bf5b67606e1dc160f225225b55937304541105027f4","declaration":"export type NativeHookRelayProcessResponse = NativeHookRelayProcessResponse;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProcessResponse","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6ee390ae4784b987e22255c16884fb4171033bbfaefaef655e3dd2a2513814a5","declaration":"export type NativeHookRelayProvider = \"codex\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProvider","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"389e3f59bfd6a95d6edeeb1afc2dbd51e6b26828fda9bbcdef480d7314962976","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"5d17b841c07853cedf3b9c6025e4a4bba812cb911ba5d7188f0e10783a7d268f","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"90fea3155b5d32c22da90460fdaea94e4fd6895d1ca18613d5036ba9835153f9","declaration":"export type NodeListNode = NodeListNode;","entrypoint":"agent-harness-runtime","exportName":"NodeListNode","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cefe1325c866f0cf7edbec5dcca2fe420237eedc0da172e54094e365894d205d","declaration":"export type NormalizedUsage = NormalizedUsage;","entrypoint":"agent-harness-runtime","exportName":"NormalizedUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness-runtime","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -315,7 +315,7 @@ {"closureHash":"666634000ee5bf317dabcbd70524833961fad58edc2b3cd66fe5fa0172e89602","declaration":"export function getAgentScopedMediaLocalRoots(cfg: OpenClawConfig, agentId?: string): readonly string[];","entrypoint":"agent-media-payload","exportName":"getAgentScopedMediaLocalRoots","importSpecifier":"openclaw/plugin-sdk/agent-media-payload","kind":"function","recordType":"export"} {"closureHash":"160c685c3f4e7ed80fb24d3d76655a182116efe6a7b985bb3bc57b89fe436d2a","declaration":"export type AgentMediaPayload = AgentMediaPayload;","entrypoint":"agent-media-payload","exportName":"AgentMediaPayload","importSpecifier":"openclaw/plugin-sdk/agent-media-payload","kind":"type","recordType":"export"} {"category":"runtime","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime","recordType":"module"} -{"closureHash":"d676259fd3157772725733326c64420d7b5d750225551ac6501664954281135a","declaration":"export function agentCommandFromIngress(opts: AgentCommandIngressOpts, runtime?: RuntimeEnv, deps?: CliDeps): Promise<{ payloads: ReturnType; meta: EmbeddedAgentRunMeta; didSendViaMessagingTool?: boolean; messagingToolSentTexts?: string[]; messagingToolSentMediaUrls?: string[]; messagingToolSentTargets?: MessagingToolSend[]; deliverySucceeded?: boolean; deliveryStatus?: { requested: true; attempted: boolean; status: \"sent\" | \"suppressed\" | \"partial_failed\" | \"failed\"; succeeded: true | false | \"partial\"; error?: true; errorMessage?: string; reason?: string; resultCount?: number; sentBeforeError?: true; payloadOutcomes?: SerializedDurableMessagePayloadOutcome[]; }; }>;","entrypoint":"agent-runtime","exportName":"agentCommandFromIngress","importSpecifier":"openclaw/plugin-sdk/agent-runtime","kind":"function","recordType":"export"} +{"closureHash":"2e52ff09ecfb43b22fdd7d03446baab43c24fd7e864a1e6e567591c1d0dd829f","declaration":"export function agentCommandFromIngress(opts: AgentCommandIngressOpts, runtime?: RuntimeEnv, deps?: CliDeps): Promise<{ payloads: ReturnType; meta: EmbeddedAgentRunMeta; didSendViaMessagingTool?: boolean; messagingToolSentTexts?: string[]; messagingToolSentMediaUrls?: string[]; messagingToolSentTargets?: MessagingToolSend[]; deliverySucceeded?: boolean; deliveryStatus?: { requested: true; attempted: boolean; status: \"sent\" | \"suppressed\" | \"partial_failed\" | \"failed\"; succeeded: true | false | \"partial\"; error?: true; errorMessage?: string; reason?: string; resultCount?: number; sentBeforeError?: true; payloadOutcomes?: SerializedDurableMessagePayloadOutcome[]; }; }>;","entrypoint":"agent-runtime","exportName":"agentCommandFromIngress","importSpecifier":"openclaw/plugin-sdk/agent-runtime","kind":"function","recordType":"export"} {"closureHash":"cb95256473d5489ef80ec3e1d93c6a612b2cc733c26ab535f945f8797995b5cb","declaration":"export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig; workspaceDir?: string; manifestPlugins?: ModelManifestPlugins; }): ModelCatalogEntry[];","entrypoint":"agent-runtime","exportName":"buildConfiguredModelCatalog","importSpecifier":"openclaw/plugin-sdk/agent-runtime","kind":"function","recordType":"export"} {"closureHash":"4a71b73b60d846e9d99fa3881cb72eb7eaaebe7b3386b6bcbaa6cfa46eb3a073","declaration":"export function buildModelAliasIndex(params: BuildModelAliasIndexParams): ModelAliasIndex;","entrypoint":"agent-runtime","exportName":"buildModelAliasIndex","importSpecifier":"openclaw/plugin-sdk/agent-runtime","kind":"function","recordType":"export"} {"closureHash":"ae0f930ec291a0345c4e99e247711465a5d29a8bbdd63968900fa69f382b3a78","declaration":"export function clearExpiredCooldowns(store: AuthProfileStore, now?: number): boolean;","entrypoint":"agent-runtime","exportName":"clearExpiredCooldowns","importSpecifier":"openclaw/plugin-sdk/agent-runtime","kind":"function","recordType":"export"} @@ -762,7 +762,7 @@ {"closureHash":"9fc7e45343d7bfbc6ed56d05c9f824e2f9cf6dafc3ca4b25437d1f65c2200727","declaration":"export function clearAccountEntryFields(params: { accounts?: Record; accountId: string; fields: string[]; isValueSet?: (value: unknown) => boolean; markClearedOnFieldPresence?: boolean; }): { nextAccounts?: Record; changed: boolean; cleared: boolean; };","entrypoint":"channel-core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"f4e084b64b5376268885a73fe507aa5d72bee17a88ec2d7ae1d19b36ea5f1ccb","declaration":"export function createChannelConfigUiHints(params: { channelLabel: string; dmPolicy?: { channelKey: string; includeLegacyNestedPolicy?: boolean; legacyNestedPolicyOrder?: \"before\" | \"after\"; }; configWrites?: boolean; mentionPatterns?: { targetDescription: string; policyTargetDescription?: string; policyNote?: string; denyNote?: string; }; nativeCommands?: boolean; implicitMentions?: boolean; progress?: { includeCommentary?: boolean; commentaryOrder?: \"before-command\" | \"after-command\"; labels?: \"openclaw\"; titleWording?: boolean; }; streaming?: Partial>; retry?: boolean; }): HintMap;","entrypoint":"channel-core","exportName":"createChannelConfigUiHints","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"9e93bcdfc6299e65ef2a3d267047a9e8c856f0b2f8e9b79d570fb2a4cd238c74","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"channel-core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} -{"closureHash":"ad9a5a09235e9a426da9b310cd466830ad103dda2e5ce4891ef4131fdf50deda","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} +{"closureHash":"ac583dafb57787174ec7563fabd453a375a7a1e1155a33f42323f68d9cea9c5d","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"channel-core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"8895f7af11b994dcd383ebc45d8cfb3c3b1e6d88a64a001b04f604a83f77e517","declaration":"export function parseOptionalDelimitedEntries(value?: string): string[] | undefined;","entrypoint":"channel-core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"3f97534b10d08eda6a556044eaba623b3744b200a2033877e48d2f2ca924d298","declaration":"export function recoverCurrentThreadSessionId(params: { route: ChannelOutboundSessionRoute; currentSessionKey?: string | null; canRecover?: (context: ThreadAwareOutboundSessionRouteRecoveryContext) => boolean; }): string | undefined;","entrypoint":"channel-core","exportName":"recoverCurrentThreadSessionId","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} @@ -774,24 +774,24 @@ {"closureHash":"8126b651a679d5435eaa5d346916792c3600e5f0f75d8492b077dd361854f7dd","declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; currentSessionKey?: string; resolvedTarget?: { to: string; kind: ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"channel-core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"d02e5c7a995847180696d2a05d8ea092b364ef32b36753e4091fc97ffdaf44ab","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"channel-core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"d9d24565b06f891a687f49672129beac9d38ccb817526ed1a6579efcb5825eb0","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"b94637421f524b48f7e497e449491475a2cc35f3d6dc43bdf5274b401b9424b0","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"category":"channel","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","recordType":"module"} {"closureHash":"821fe6ccd0d9323322bf70c4d1ca5697071a0e32032f44c1e14157162c2c729d","declaration":"export function createChannelDmPolicy(params: CreateChannelDmPolicyParams): ChannelSetupDmPolicy & { promptAllowFrom: NonNullable; };","entrypoint":"channel-dm-policy","exportName":"createChannelDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","kind":"function","recordType":"export"} {"category":null,"entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","recordType":"module"} -{"closureHash":"5885bf21e94629126fa96a1a953d2c913eeeaac3fc603e9d23846e0700b53835","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} -{"closureHash":"237c32c30b7f6979b8e955acfd6520db3ac2d98a3c46539c3b083d5d1af48247","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"eb415824d16db17a50d7c90bd990684048255e5bf9ddc3099c86f624d6165cbd","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"b06dd84646003c9e493e47c7a7249bc5f7ed63cec17586110c0274b7dc501b79","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"cd150287d7e286e7a0d7d6caeb05d0a1efa63b5f351f75f69ba932510a108afd","declaration":"export function loadBundledEntryExportSync(importMetaUrl: string, reference: BundledEntryModuleRef, options?: BundledEntryModuleLoadOptions): T;","entrypoint":"channel-entry-contract","exportName":"loadBundledEntryExportSync","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"7781a3d570d08929af5b3b6cffbb9ef4b9c8e8f1bed55234e0015a681868c812","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"channel-entry-contract","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"4b7913e3376b2de7370a5970d8330e81f65574e173a313aec21d5dc5428007dd","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"2fdb74bb36a7fd0b531c47e3fed55be12d9093046ed6d2a09ea98b6777c8fa88","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"f4b82dfd995bd6f08b232984743389d1c854866cb845a8dbb3605acb28818688","declaration":"export type BundledChannelEntryFeatures = BundledChannelEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"960693591b9bdc974bf3f0097076d90490078db3dceb35349a4f851736cde4fc","declaration":"export type BundledChannelLegacySessionSurface = BundledChannelLegacySessionSurface;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacySessionSurface","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"edf79e6a736e5c4977e3b86ef21e7fdf0c0b2bc7deacd8edbee2ca76cb8cef43","declaration":"export type BundledChannelLegacyStateMigrationDetector = BundledChannelLegacyStateMigrationDetector;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacyStateMigrationDetector","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"882a849a3b180ad11b9622cd5d38b932012672f8a7b7b67c229874bb1862ac48","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"edf3dd5d9961a117ee2da538eb8741695dc57aeb0871bd9258a1bdaad5291be2","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"24679ecdf2ff34157b6345a37b58ec1363ec8cb1969b522599d0d92bb7a113b7","declaration":"export type BundledChannelSetupEntryFeatures = BundledChannelSetupEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"367219c821915e5be4d1ba3a86754475a5f8a4194512f0401de3be11cc649c60","declaration":"export type BundledEntryModuleLoadOptions = BundledEntryModuleLoadOptions;","entrypoint":"channel-entry-contract","exportName":"BundledEntryModuleLoadOptions","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"d092260da21786feb5266e642269dc90807ac6a33131fffa66641d95b7544145","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"64ea70860eab8f956b0edf1624f2994eda772010247baf37139fb0e0d006e9c8","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"2a50f49bf524a67b6554ad9e3afc32487403828d09f966191930f7bb701032a1","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"627f955848f6970b3fce5e72f1ccccc136636d67355114ce38aedd886be38db8","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-entry-contract","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback","recordType":"module"} @@ -862,7 +862,7 @@ {"closureHash":"6b537112d5d84ed07c0b9fd24787eabc2f893f7bfdce1ed5915f88574a7e101c","declaration":"export function normalizeMentionText(text: string): string;","entrypoint":"channel-inbound","exportName":"normalizeMentionText","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} {"closureHash":"92900098c3cc5b73fba7622a3aa5a1a8c48462ff7ec0cfae8ee0a3aa6f8277f0","declaration":"export function normalizeOutboundLocation(value: unknown, label?: string): OutboundLocation | undefined;","entrypoint":"channel-inbound","exportName":"normalizeOutboundLocation","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} {"closureHash":"65f5f2725c1124096e437bcf5894983eee1023f7033be886f1a506044b77c58e","declaration":"export function recordChannelBotPairLoopAndCheckSuppression(params: ChannelBotLoopProtectionFacts): PairLoopGuardResult;","entrypoint":"channel-inbound","exportName":"recordChannelBotPairLoopAndCheckSuppression","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} -{"closureHash":"b8271620147b917162fda83897092229d5691a4e418288a79912ae41775e83da","declaration":"export function recordChannelFeedbackEvent(params: { cfg: OpenClawConfig; agentId: string; sessionKey: string; event: Parameters[1]; }): Promise;","entrypoint":"channel-inbound","exportName":"recordChannelFeedbackEvent","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} +{"closureHash":"d4c2f90ba1b19748480106cea43d965d7c3505b4c6325ee87deb7bd5e8885abe","declaration":"export function recordChannelFeedbackEvent(params: { cfg: OpenClawConfig; agentId: string; sessionKey: string; event: Parameters[1]; }): Promise;","entrypoint":"channel-inbound","exportName":"recordChannelFeedbackEvent","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} {"closureHash":"33b4280a69de6a4a87acf915c6d694b9ee41fc4f0262a1bb9836c46d53fe337c","declaration":"export function recordDroppedChannelInboundHistory(params: { input: NormalizedTurnInput; preflight: PreflightFacts; admission?: ChannelTurnAdmission; }): Promise;","entrypoint":"channel-inbound","exportName":"recordDroppedChannelInboundHistory","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} {"closureHash":"33b4280a69de6a4a87acf915c6d694b9ee41fc4f0262a1bb9836c46d53fe337c","declaration":"export function recordDroppedChannelTurnHistory(params: { input: NormalizedTurnInput; preflight: PreflightFacts; admission?: ChannelTurnAdmission; }): Promise;","entrypoint":"channel-inbound","exportName":"recordDroppedChannelTurnHistory","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} {"closureHash":"54b920fd80ac659a03000356b64e34d4a44ec22ae39a77accdd2ac97dc5a094e","declaration":"export function resolveChannelInboundRouteEnvelope(params: ResolveAgentRouteInput): { route: ResolvedAgentRoute; buildEnvelope: (input: ChannelInboundEnvelopeInput) => string; };","entrypoint":"channel-inbound","exportName":"resolveChannelInboundRouteEnvelope","importSpecifier":"openclaw/plugin-sdk/channel-inbound","kind":"function","recordType":"export"} @@ -1034,7 +1034,7 @@ {"closureHash":"acd64a0d6ce6dbd9db22f9e4a4497cef2110a9e962f240c6f5e90cef31505459","declaration":"export function createFinalizableDraftStreamControlsForState(params: { throttleMs: number; state: FinalizableDraftStreamState; sendOrEditStreamMessage: (value: T) => Promise; emptyValue?: T; isEmpty?: (value: T) => boolean; }): { loop: DraftStreamLoop & { takePending: () => T; }; update: (value: T) => void; stop: () => Promise; seal: () => Promise; discardPending: () => Promise; stopForClear: () => Promise; };","entrypoint":"channel-message","exportName":"createFinalizableDraftStreamControlsForState","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"f2bb1868ec1cf7d97e6b8eb0a9a8a37122ec53c9cd4e2bbfc8448c1555f3d382","declaration":"export function createMessageReceiptFromOutboundResults(params: { results: readonly MessageReceiptInputResult[]; kind?: MessageReceiptPartKind; threadId?: string; replyToId?: string; sentAt?: number; }): MessageReceipt;","entrypoint":"channel-message","exportName":"createMessageReceiptFromOutboundResults","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"3c150fb027a4e4a1685b572d26aa1f70b3838641bd7629434c4265bfda549d0a","declaration":"export function createMessageReceiveContext(params: { id: string; channel: string; accountId?: string; message: TMessage; ackPolicy?: MessageAckPolicy; receivedAt?: number; signal?: AbortSignal; onAck?: () => Promise | void; onNack?: (error: unknown) => Promise | void; }): MessageReceiveContext;","entrypoint":"channel-message","exportName":"createMessageReceiveContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} -{"closureHash":"93906d2c7105382007072ea3e79787732a62828361034e59306d3cd0f5b05a25","declaration":"export function createOutboundPayloadPlan(payloads: readonly ReplyPayload[], context?: OutboundPayloadPlanContext): OutboundPayloadPlan[];","entrypoint":"channel-message","exportName":"createOutboundPayloadPlan","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} +{"closureHash":"541c4ed521f12c5e2ef9ea702c67923b559c5fdd109450c193239c9247a4f77b","declaration":"export function createOutboundPayloadPlan(payloads: readonly ReplyPayload[], context?: OutboundPayloadPlanContext): OutboundPayloadPlan[];","entrypoint":"channel-message","exportName":"createOutboundPayloadPlan","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"7abc004852a9162c3f4241d6c80b9962c9732dd03d2d38a56e4080a4e92758a1","declaration":"export function createPreviewMessageReceipt(params: { id: unknown; threadId?: string; replyToId?: string; sentAt?: number; raw?: unknown; }): MessageReceipt;","entrypoint":"channel-message","exportName":"createPreviewMessageReceipt","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"449a8d46d726b3de6fdf7aabf60939d37565ef5701ef58dd91b817040712ab87","declaration":"export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; channel?: string; accountId?: string; }): ReplyPrefixContextBundle;","entrypoint":"channel-message","exportName":"createReplyPrefixContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"acb4b6a4562c6a6ac30594fcd9431a9aabb031135fff7faa41a74067139ab572","declaration":"export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; channel?: string; accountId?: string; }): ReplyPrefixOptions;","entrypoint":"channel-message","exportName":"createReplyPrefixOptions","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} @@ -1063,7 +1063,7 @@ {"closureHash":"844a3251dcba84a9735c855d7b6c930570cc486f3a1c5fc49b541572d52bb7d7","declaration":"export function normalizeAgentPlanSteps(value: unknown): AgentPlanStep[] | undefined;","entrypoint":"channel-message","exportName":"normalizeAgentPlanSteps","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"b19d38e15e2054e826fd132198c606165fc369fb5a96f4e6bbbf7b89bee35ac2","declaration":"export function normalizeChannelProgressDraftLineIdentity(line: string | ChannelProgressDraftLine | undefined): string;","entrypoint":"channel-message","exportName":"normalizeChannelProgressDraftLineIdentity","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"ba2389600eeea10867e3891e411bd84e8fd07dcd410249c29ca92ba59ef27bad","declaration":"export function processPidFromOwnerId(ownerId: string): number;","entrypoint":"channel-message","exportName":"processPidFromOwnerId","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} -{"closureHash":"70bfbeeee4a0a2290cbf9620933c2bf7d00fbcaf3c20958c2064f5e2ce5a0bb8","declaration":"export function projectOutboundPayloadPlanForDelivery(plan: readonly OutboundPayloadPlan[]): ReplyPayload[];","entrypoint":"channel-message","exportName":"projectOutboundPayloadPlanForDelivery","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} +{"closureHash":"ec60c4ceadf16fb4e54b875058278a3f5a1e7a5b7954b95bd621998ae05a2978","declaration":"export function projectOutboundPayloadPlanForDelivery(plan: readonly OutboundPayloadPlan[]): ReplyPayload[];","entrypoint":"channel-message","exportName":"projectOutboundPayloadPlanForDelivery","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"bc16377d8f9c3467da5115ff2d6b3ef0423980794a0f5af287b1f1e8ee9533ef","declaration":"export function recordOutboundMessageIdentity(identity: OutboundMessageIdentity): void;","entrypoint":"channel-message","exportName":"recordOutboundMessageIdentity","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"3090faaaca1e2c970a0eadbb7792c531a053746df6df83a082d8564a8313b401","declaration":"export function resolveAgentOutboundIdentity(cfg: OpenClawConfig, agentId: string): OutboundIdentity | undefined;","entrypoint":"channel-message","exportName":"resolveAgentOutboundIdentity","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} {"closureHash":"ba763bf7ff1dee852fb9b58f5995f9df744028d47ca29f687b215ba316c66723","declaration":"export function resolveChannelDraftStreamingChunking(cfg: OpenClawConfig | undefined, channelId: ChannelId, accountId: string | null | undefined, opts: { fallbackLimit: number; }): ChannelDraftStreamingChunking;","entrypoint":"channel-message","exportName":"resolveChannelDraftStreamingChunking","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"function","recordType":"export"} @@ -1103,7 +1103,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} -{"closureHash":"64792a0c33912731a154f8e8e1b3f598b3396d2e0b93ed35fb4cc67a150bf9e2","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} +{"closureHash":"2e9cda856e1cfdb150d56efbdcb1225469b0fed11c67b3d80b1baa744a7f0cf3","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} @@ -1166,7 +1166,7 @@ {"closureHash":"acd64a0d6ce6dbd9db22f9e4a4497cef2110a9e962f240c6f5e90cef31505459","declaration":"export function createFinalizableDraftStreamControlsForState(params: { throttleMs: number; state: FinalizableDraftStreamState; sendOrEditStreamMessage: (value: T) => Promise; emptyValue?: T; isEmpty?: (value: T) => boolean; }): { loop: DraftStreamLoop & { takePending: () => T; }; update: (value: T) => void; stop: () => Promise; seal: () => Promise; discardPending: () => Promise; stopForClear: () => Promise; };","entrypoint":"channel-outbound","exportName":"createFinalizableDraftStreamControlsForState","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"f2bb1868ec1cf7d97e6b8eb0a9a8a37122ec53c9cd4e2bbfc8448c1555f3d382","declaration":"export function createMessageReceiptFromOutboundResults(params: { results: readonly MessageReceiptInputResult[]; kind?: MessageReceiptPartKind; threadId?: string; replyToId?: string; sentAt?: number; }): MessageReceipt;","entrypoint":"channel-outbound","exportName":"createMessageReceiptFromOutboundResults","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"3c150fb027a4e4a1685b572d26aa1f70b3838641bd7629434c4265bfda549d0a","declaration":"export function createMessageReceiveContext(params: { id: string; channel: string; accountId?: string; message: TMessage; ackPolicy?: MessageAckPolicy; receivedAt?: number; signal?: AbortSignal; onAck?: () => Promise | void; onNack?: (error: unknown) => Promise | void; }): MessageReceiveContext;","entrypoint":"channel-outbound","exportName":"createMessageReceiveContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} -{"closureHash":"93906d2c7105382007072ea3e79787732a62828361034e59306d3cd0f5b05a25","declaration":"export function createOutboundPayloadPlan(payloads: readonly ReplyPayload[], context?: OutboundPayloadPlanContext): OutboundPayloadPlan[];","entrypoint":"channel-outbound","exportName":"createOutboundPayloadPlan","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} +{"closureHash":"541c4ed521f12c5e2ef9ea702c67923b559c5fdd109450c193239c9247a4f77b","declaration":"export function createOutboundPayloadPlan(payloads: readonly ReplyPayload[], context?: OutboundPayloadPlanContext): OutboundPayloadPlan[];","entrypoint":"channel-outbound","exportName":"createOutboundPayloadPlan","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"7abc004852a9162c3f4241d6c80b9962c9732dd03d2d38a56e4080a4e92758a1","declaration":"export function createPreviewMessageReceipt(params: { id: unknown; threadId?: string; replyToId?: string; sentAt?: number; raw?: unknown; }): MessageReceipt;","entrypoint":"channel-outbound","exportName":"createPreviewMessageReceipt","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"449a8d46d726b3de6fdf7aabf60939d37565ef5701ef58dd91b817040712ab87","declaration":"export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; channel?: string; accountId?: string; }): ReplyPrefixContextBundle;","entrypoint":"channel-outbound","exportName":"createReplyPrefixContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"acb4b6a4562c6a6ac30594fcd9431a9aabb031135fff7faa41a74067139ab572","declaration":"export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; channel?: string; accountId?: string; }): ReplyPrefixOptions;","entrypoint":"channel-outbound","exportName":"createReplyPrefixOptions","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} @@ -1193,7 +1193,7 @@ {"closureHash":"844a3251dcba84a9735c855d7b6c930570cc486f3a1c5fc49b541572d52bb7d7","declaration":"export function normalizeAgentPlanSteps(value: unknown): AgentPlanStep[] | undefined;","entrypoint":"channel-outbound","exportName":"normalizeAgentPlanSteps","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"b19d38e15e2054e826fd132198c606165fc369fb5a96f4e6bbbf7b89bee35ac2","declaration":"export function normalizeChannelProgressDraftLineIdentity(line: string | ChannelProgressDraftLine | undefined): string;","entrypoint":"channel-outbound","exportName":"normalizeChannelProgressDraftLineIdentity","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"ba2389600eeea10867e3891e411bd84e8fd07dcd410249c29ca92ba59ef27bad","declaration":"export function processPidFromOwnerId(ownerId: string): number;","entrypoint":"channel-outbound","exportName":"processPidFromOwnerId","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} -{"closureHash":"70bfbeeee4a0a2290cbf9620933c2bf7d00fbcaf3c20958c2064f5e2ce5a0bb8","declaration":"export function projectOutboundPayloadPlanForDelivery(plan: readonly OutboundPayloadPlan[]): ReplyPayload[];","entrypoint":"channel-outbound","exportName":"projectOutboundPayloadPlanForDelivery","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} +{"closureHash":"ec60c4ceadf16fb4e54b875058278a3f5a1e7a5b7954b95bd621998ae05a2978","declaration":"export function projectOutboundPayloadPlanForDelivery(plan: readonly OutboundPayloadPlan[]): ReplyPayload[];","entrypoint":"channel-outbound","exportName":"projectOutboundPayloadPlanForDelivery","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"bc16377d8f9c3467da5115ff2d6b3ef0423980794a0f5af287b1f1e8ee9533ef","declaration":"export function recordOutboundMessageIdentity(identity: OutboundMessageIdentity): void;","entrypoint":"channel-outbound","exportName":"recordOutboundMessageIdentity","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"3090faaaca1e2c970a0eadbb7792c531a053746df6df83a082d8564a8313b401","declaration":"export function resolveAgentOutboundIdentity(cfg: OpenClawConfig, agentId: string): OutboundIdentity | undefined;","entrypoint":"channel-outbound","exportName":"resolveAgentOutboundIdentity","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} {"closureHash":"ba763bf7ff1dee852fb9b58f5995f9df744028d47ca29f687b215ba316c66723","declaration":"export function resolveChannelDraftStreamingChunking(cfg: OpenClawConfig | undefined, channelId: ChannelId, accountId: string | null | undefined, opts: { fallbackLimit: number; }): ChannelDraftStreamingChunking;","entrypoint":"channel-outbound","exportName":"resolveChannelDraftStreamingChunking","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"function","recordType":"export"} @@ -1232,7 +1232,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} -{"closureHash":"64792a0c33912731a154f8e8e1b3f598b3396d2e0b93ed35fb4cc67a150bf9e2","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} +{"closureHash":"2e9cda856e1cfdb150d56efbdcb1225469b0fed11c67b3d80b1baa744a7f0cf3","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} @@ -1298,7 +1298,7 @@ {"closureHash":"cd6b0d3f962c8cb40d405e92bb06bb8e9285b386200897f550d21036022262c1","declaration":"export const PAIRING_APPROVED_MESSAGE: \"✅ OpenClaw access approved. Send a message to start chatting.\";","entrypoint":"channel-plugin-common","exportName":"PAIRING_APPROVED_MESSAGE","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"const","recordType":"export"} {"closureHash":"712435252d2fe5135542ca6b5a4375a6edea24e83829338011dc6ae7e9802fbb","declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-plugin-common","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"d02e5c7a995847180696d2a05d8ea092b364ef32b36753e4091fc97ffdaf44ab","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-plugin-common","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"b94637421f524b48f7e497e449491475a2cc35f3d6dc43bdf5274b401b9424b0","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-plugin-common","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy","recordType":"module"} {"closureHash":"017ab29d16d0f3d02d60f3513e65a373868d26496bc93d020d8fc8c17fd875f4","declaration":"export function buildAccountScopedDmSecurityPolicy(params: { cfg: OpenClawConfig; channelKey: string; accountId?: string | null; fallbackAccountId?: string | null; policy?: string | null; allowFrom?: Array | null; defaultPolicy?: string; allowFromPathSuffix?: string; policyPathSuffix?: string; approveChannelId?: string; approveHint?: string; normalizeEntry?: (raw: string) => string; inheritSharedDefaultsFromDefaultAccount?: boolean; }): ChannelSecurityDmPolicy;","entrypoint":"channel-policy","exportName":"buildAccountScopedDmSecurityPolicy","importSpecifier":"openclaw/plugin-sdk/channel-policy","kind":"function","recordType":"export"} @@ -1897,8 +1897,8 @@ {"closureHash":"9e93bcdfc6299e65ef2a3d267047a9e8c856f0b2f8e9b79d570fb2a4cd238c74","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"785b0f750c5bd20f9f3ca081886a5af79ee0e4d2c38f78f769bf994b234f5076","declaration":"export function createDedupeCache(options: DedupeCacheOptions): DedupeCache;","entrypoint":"core","exportName":"createDedupeCache","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"core","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"ad9a5a09235e9a426da9b310cd466830ad103dda2e5ce4891ef4131fdf50deda","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"fd766c74118002291fa28dcd19a9677cdf9f615fe09994862f95b989dd36479a","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"ac583dafb57787174ec7563fabd453a375a7a1e1155a33f42323f68d9cea9c5d","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"ed949ec84859c0ebca72e1a10d757b1063cf05a83898c18ffe0e62eb5a72f80c","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"3c5ce0c3733cbb038aeb914af1df5d701f362f0521921b8abd0c9214db9cc4b8","declaration":"export function delegateCompactionToRuntime(params: Parameters[0]): Promise;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"2c282e0e308f9442bca7cdeb8e89a097531b1d54a692c9d86e5f39a8db1c2537","declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[]; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} @@ -1945,7 +1945,7 @@ {"closureHash":"2e6ce1f4c1692270a00dc0cf08e2752a26739c3143f8091cb270063c6034605d","declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options: CredentialFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: FsSafeSecretFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): ConfiguredCredentialResult;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): CredentialResult;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"b40933a11984425d14a7a479bda0d2cd14cbfa9371164774f39bebbf12861df7","declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} {"closureHash":null,"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} -{"closureHash":"d759a25775cb15d3f1804e39e98518f83b67b29ba3ab2fd84d4bf16106d9fdb9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"bfca309d41b8bed7b9b967ea4c643d6044cbd3dead3c87c6f05ea2d01859c4e9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"core","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"core","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"core","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -1977,10 +1977,10 @@ {"closureHash":"54ea426dde51859b572b4c1079d2b70b412096f73da2b8c265f2749d9f01cbb5","declaration":"export type NormalizedLocation = NormalizedLocation;","entrypoint":"core","exportName":"NormalizedLocation","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"5189698d102b8b064eefe1442b008312973c3994fd6b203d2205725d7ee9d118","declaration":"export type OpenClawPluginActiveModelContext = OpenClawPluginActiveModelContext;","entrypoint":"core","exportName":"OpenClawPluginActiveModelContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"647007b989ba3d08c520ec68873f65f22f231b0274daf10f5d219074fd2fcba6","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"ccae64ce42b52f34fc0f2adffe6202985c2c8464e3e2df19f43ae1eb0005e808","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"09482263ed4b15cc73a7d59079368b3c646b6482764650d08827d946d85895e7","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"core","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"core","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"ddc2738dd0ece94ddde9a7c655f92c15ec6416645ecf932d4aa404c7b7761159","declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -2215,7 +2215,7 @@ {"closureHash":"0db968dad99d84ecef5202167daadd092e3b4f456270283d3fd61585ac2bbd5a","declaration":"export type DiscordComponentSendResult = DiscordComponentSendResult;","entrypoint":"discord","exportName":"DiscordComponentSendResult","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"084b46d7dd510eb182a4705559251e824220b57dbfa6dc54392507b72ba1815e","declaration":"export type DiscordOutboundTargetResolution = DiscordOutboundTargetResolution;","entrypoint":"discord","exportName":"DiscordOutboundTargetResolution","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"discord","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"b94637421f524b48f7e497e449491475a2cc35f3d6dc43bdf5274b401b9424b0","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"discord","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6b9967e919c1cd420c8356b4f9d40dbc81932b4b15a733eecdcfb2ad84f8e5d8","declaration":"export type ResolvedDiscordAccount = ResolvedDiscordAccount;","entrypoint":"discord","exportName":"ResolvedDiscordAccount","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6bed5ca896b7034117e4757e2c6b4111dd5293dbad9416e3828dd6afa4e858ce","declaration":"export type ThreadBindingRecord = ThreadBindingRecord;","entrypoint":"discord","exportName":"ThreadBindingRecord","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} @@ -2360,7 +2360,7 @@ {"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"871d3ff30c14af1a605fdb47c50923a910a7886f7cc886ba1cd9c2eff1ea8334","declaration":"export function runChannelInboundEvent(params: RunChannelTurnParams): Promise>;\nexport function runChannelInboundEvent(params: ChannelInboundEventRunnerParams): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply(params: PreparedChannelTurn): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} -{"closureHash":"64792a0c33912731a154f8e8e1b3f598b3396d2e0b93ed35fb4cc67a150bf9e2","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} +{"closureHash":"2e9cda856e1cfdb150d56efbdcb1225469b0fed11c67b3d80b1baa744a7f0cf3","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} {"closureHash":"10d47ee68a65fa2df62012bf98988026560dd0bc30d2be56783ed4132864a149","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} @@ -3195,19 +3195,19 @@ {"closureHash":"66f0964725c2b9640e8ab8cfb803a421a9e58a2238ea4fbf7ffa97d0c4d33df0","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"c7e8cad42fa48c1be4ae1b1f7f87339c849cacf81a53ad53df76e188f4dd4629","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"21edc4c0432bbcf579b3e35e390dbb217f09649c1fea5aadff78773f306da866","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"eaf5cff3027412d66a1e6488a71c1fb21ae1efb16d5c7415afed7370a1641dbf","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"2ce663d0c867849f055335c2e964429a31516c653e0628173149d4f991067808","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"3c09a8c46d56d7eb2489dcdad4386365ce7d464f41d6826fe682d43e8c0d8472","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin; createdAt: string; }): MeetingSessionRecord;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"abfbbc6b5eca3947bed22ea782cf5ad582b6a364f554d8851c92c2d3cbc3c6f7","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"364dbe49fce073de6aff2b666e2e80b9d188f04458f179f0569506702b2e5075","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"ed383aeec67155486ebb84de62db0c0ba2832f4c70fcccf47e7ed8de92d98ecb","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"c571be67deff656fc2e53aecb61d02bd58b468d4b548860b37b61eafa18f8d6b","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"e80aa4d0d11d08e96fc7b42afbf3ee38aca718a342b3d12c09f47d55d3bbdd43","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} @@ -3218,8 +3218,8 @@ {"closureHash":"120055d3915ab89e6960d454ba75088f5d000e2abe1715901b791f86ab0f2ba5","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"c64d9bc7f2e478a5affc01a910f49882aef707b077d8ebc0da889985cfaf9595","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"20dff0ae02d5d945273bedcb54a853efed1f43cf1f03754a52e0eb3e584c7b88","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"63f2ba1711d2c7892ecb4d133ed5f2d4acca4d72680e8efe0c16ef721d5b6673","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"21744f5ed9b90d14f80ef303087d5bcec0b6e83ca6248ac57d7bbdff1d6897f3","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3251,7 +3251,7 @@ {"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles = MeetingSessionRuntimeHandles;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages = MeetingSessionRuntimeMessages;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"8d3f6abdbf39603c6f28a69b9bb8f0b214d978dc10dd83ca3f6a692f565a7cb0","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} +{"closureHash":"672ace60a1e69878afa3cf2382a07f478128158f9229a678fcc1864565df6f96","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3265,9 +3265,9 @@ {"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"376e9dae283ed91fb2a5e5769c33f09bed27db7a0d42afe7d305bb1f6d58167e","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} +{"closureHash":"38cc3441d19861bc1bc1de8eedf0649465eb0f38512de81f39658ef590c6e212","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} {"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise;\n writeOutput(audio: Buffer): Promise;\n clearOutput(): Promise;\n dispose(): Promise;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} -{"closureHash":"2bf79ddb49656aafb6210e91b35334500af758fbe90bc6da026a8660fd3ed5b4","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} +{"closureHash":"0ffca719e5af0cc3a4165180bd9bcb416056635b07683191a26aa27aa9168add","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} {"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} @@ -3403,9 +3403,9 @@ {"category":"core","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","recordType":"module"} {"closureHash":"3d27406644141af2733cfca6e8c1240fc62327910109f585ce9073cafd7fd89c","declaration":"export function buildJsonPluginConfigSchema(schema: JsonSchemaObject, options?: BuildJsonPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildJsonPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"aff66c792d532642afb9c6e5a03c8d220f219250c3a8b49733f32058f22f98ca","declaration":"export function buildPluginConfigSchema(schema: ZodTypeAny, options?: BuildPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"fd766c74118002291fa28dcd19a9677cdf9f615fe09994862f95b989dd36479a","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} +{"closureHash":"ed949ec84859c0ebca72e1a10d757b1063cf05a83898c18ffe0e62eb5a72f80c","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"0ef1dfa3b5deeb456480e292c0e835c3cd6d13e7bb7059fc735546507da51594","declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"d759a25775cb15d3f1804e39e98518f83b67b29ba3ab2fd84d4bf16106d9fdb9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"bfca309d41b8bed7b9b967ea4c643d6044cbd3dead3c87c6f05ea2d01859c4e9","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"plugin-entry","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3421,10 +3421,10 @@ {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2f6e368fff73e942044cdf95c7c751b46c741e22ac0d2d4d97f45a3158f758ae","declaration":"export type OpenClawGatewayDiscoveryAdvertiseContext = OpenClawGatewayDiscoveryAdvertiseContext;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryAdvertiseContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"b198058e0065e5c60b729a5bdf568f7e2436a006d8c4a7c63de764c7283f96ba","declaration":"export type OpenClawGatewayDiscoveryService = OpenClawGatewayDiscoveryService;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"647007b989ba3d08c520ec68873f65f22f231b0274daf10f5d219074fd2fcba6","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"ccae64ce42b52f34fc0f2adffe6202985c2c8464e3e2df19f43ae1eb0005e808","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"09482263ed4b15cc73a7d59079368b3c646b6482764650d08827d946d85895e7","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"7470b211ac4d3449cd0fc353ee2eb48e9fbc2dbc9259d21c680d0203577aa94f","declaration":"export type OpenClawPluginHttpRouteHandler = OpenClawPluginHttpRouteHandler;","entrypoint":"plugin-entry","exportName":"OpenClawPluginHttpRouteHandler","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3574,14 +3574,14 @@ {"closureHash":"983131a9d5c2971ecd7f2fb04f9d664d6854535d1abe2099baf2292014c30b23","declaration":"export function executePluginCommand(params: { command: RegisteredPluginCommand; args?: string; senderId?: string; channel: string; channelId?: PluginCommandContext[\"channelId\"]; isAuthorizedSender: boolean; senderIsOwner?: boolean; gatewayClientScopes?: PluginCommandContext[\"gatewayClientScopes\"]; agentId?: string; sessionKey?: PluginCommandContext[\"sessionKey\"]; sessionId?: PluginCommandContext[\"sessionId\"]; sessionTarget?: PluginCommandContext[\"sessionTarget\"]; sessionFile?: PluginCommandContext[\"sessionFile\"]; authProfileId?: string; commandBody: string; config: OpenClawConfig; from?: PluginCommandContext[\"from\"]; to?: PluginCommandContext[\"to\"]; originatingTo?: string; accountId?: PluginCommandContext[\"accountId\"]; messageThreadId?: PluginCommandContext[\"messageThreadId\"]; threadParentId?: PluginCommandContext[\"threadParentId\"]; diagnosticsSessions?: PluginCommandContext[\"diagnosticsSessions\"]; diagnosticsUploadApproved?: PluginCommandContext[\"diagnosticsUploadApproved\"]; diagnosticsPreviewOnly?: PluginCommandContext[\"diagnosticsPreviewOnly\"]; diagnosticsPrivateRouted?: PluginCommandContext[\"diagnosticsPrivateRouted\"]; }): Promise;","entrypoint":"plugin-runtime","exportName":"executePluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"dfc8fc733f281bfbe0cc270964f0f657f17c2b97f21bc1b316aee6e9de8ff6ba","declaration":"export function getGlobalHookRunner(): HookRunner | null;","entrypoint":"plugin-runtime","exportName":"getGlobalHookRunner","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"db6583607237e4081814e2c5d4f8fb6f216ad3bbfe623bf16fc1a9c09c15f64b","declaration":"export function getPluginCommandSpecs(provider?: string, options?: PluginCommandSpecOptions): Array<{ name: string; description: string; descriptionLocalizations?: Record; acceptsArgs: boolean; }>;","entrypoint":"plugin-runtime","exportName":"getPluginCommandSpecs","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} -{"closureHash":"8240e99e361a089d6dacdaecb1d367e31bdce8be4d3b137a5b0065904bac11c6","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} +{"closureHash":"5773d2030bc8b30003ebf283763f478f27e4cd9b0f2205e9c2b1f3a872bd8487","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"cfe277ce6de0c6ed50f1f54b66c889f6c95e91b058e8bcdc27aa37317a93a447","declaration":"export function listRegisteredPluginAgentPromptGuidance(params?: { surface?: AgentPromptSurfaceKind; includeLegacyGlobalGuidance?: boolean; }): string[];","entrypoint":"plugin-runtime","exportName":"listRegisteredPluginAgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"c53214cb523257e979580c6683f378f2f8e85c154e18c8e30ad84ae0eba28c28","declaration":"export function matchPluginCommand(commandBody: string, options?: { channel?: string; }): { command: RegisteredPluginCommand; args?: string; } | null;","entrypoint":"plugin-runtime","exportName":"matchPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"94e8ab88f5f42afc3ba7300ea0da6364722360be9083d89aade693b82c3e162f","declaration":"export function registerPluginCommand(pluginId: string, command: OpenClawPluginCommandDefinition, opts?: { pluginName?: string; pluginRoot?: string; allowReservedCommandNames?: boolean; allowOwnerStatusExposure?: boolean; }): CommandRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"16986e5d431f584c2d5053289eca3d2225bbe47a92cdfbbc2af91b264a165846","declaration":"export function registerPluginInteractiveHandler(pluginId: string, registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string; }): InteractiveRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginInteractiveHandler","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"f20c07a48e2c949fbc889e7a32760f4e52339d9f6d3eab49e00ff249266e2396","declaration":"export function startLazyPluginServiceModule(params: { skipEnvVar?: string; overrideEnvVar?: string; validateOverrideSpecifier?: (specifier: string) => string; loadDefaultModule: () => Promise; loadOverrideModule?: (specifier: string) => Promise; startExportNames: string[]; stopExportNames?: string[]; }): Promise;","entrypoint":"plugin-runtime","exportName":"startLazyPluginServiceModule","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"bad43ffe69d1d9d6c92c79df4c388b3dda685013a92392daef456f1622ade659","declaration":"export type LazyPluginServiceHandle = LazyPluginServiceHandle;","entrypoint":"plugin-runtime","exportName":"LazyPluginServiceHandle","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} -{"closureHash":"2ddf03be75f0d46c8a7e1d7a3d3e70122001b190dcb361132cef2f1d23d8ddec","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} +{"closureHash":"a7692c42bc8dd93b27887836381fa24095aa6776f3210b5155cf6e404dc17b38","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"f06379ada10ce0eca4e66bdf978db2c0035e054a135dc044c6d831ac7797c08f","declaration":"export type PluginConversationBinding = PluginConversationBinding;","entrypoint":"plugin-runtime","exportName":"PluginConversationBinding","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"a1223d674b2b66918a576ae05b04318d7b8cbe9b1d3bc6dd7a08d1a4fa79b291","declaration":"export type PluginConversationBindingRequestParams = PluginConversationBindingRequestParams;","entrypoint":"plugin-runtime","exportName":"PluginConversationBindingRequestParams","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} @@ -3671,10 +3671,10 @@ {"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"} {"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"} {"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"720c33e7d2fb1f65d8fd6c7949e9498fda0a1b4eb0ccf6a7635b2dce58d68d05","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"228e8e3d4bd122168da916b923639accece60b5706df52fb437fff2b2a46f18f","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"b066e2fa15256bc32e1fa098c5302922f0db5ccbca4a59c3331860e822c779fc","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"b7e37ed1adcc928a9a65520f5c288051f01e873a99bcb559488de03869bb6cf7","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"d9a3d65ab4200e4f9857b0459e8cfaf898bfebde8a94a6c8d82bf9f3ce81eb64","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"b03d523d4b368c8f4fca0caf78165bbfbfaa8be4dec5390201e12aeb66134001","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"15f34788b0e40c2febc8b538ad49f3b297ffcac589e32065d9664794d70a1e20","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"fb56eedf17994958a2c0cf52398cf79191fdcfbeeceba87ae3412466de7b4ef5","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} {"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"} {"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} {"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} @@ -4639,16 +4639,16 @@ {"closureHash":"e365d7ad911453b5139ba700931b061e560cff80b50d4cbe5e9615b5d7e13a52","declaration":"export type ScopedExpiringIdCache = ScopedExpiringIdCache;","entrypoint":"text-runtime","exportName":"ScopedExpiringIdCache","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"type","recordType":"export"} {"closureHash":"af5502c92594c2e9049a4b4994e17c00d3adee3f6d1e68edd818a539c74c8787","declaration":"export interface CodeRegion {\n start: number;\n end: number;\n}","entrypoint":"text-runtime","exportName":"CodeRegion","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"interface","recordType":"export"} {"category":null,"entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","recordType":"module"} -{"closureHash":"296a332a70c3af6ed244dc40f665226c3fec4c68ee1e6f836ce62ff9504cbabc","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} +{"closureHash":"f034787dce706f74e5ad08ed9a85af4cd5a6f3eeda279af7b6fb31d4861a6dde","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"36ef907ce20f885b5f3068cbf0475dc442a68da9652579bb5bf8fb83ad34c38b","declaration":"export function getToolPluginMetadata(entry: unknown): ToolPluginMetadata | undefined;","entrypoint":"tool-plugin","exportName":"getToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"bdc58154e36b2625fb3daccca3eab248d981077d04d747f0ad507cb2ff565198","declaration":"export const toolPluginMetadataSymbol: typeof toolPluginMetadataSymbol;","entrypoint":"tool-plugin","exportName":"toolPluginMetadataSymbol","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"const","recordType":"export"} -{"closureHash":"6c3a1859eedd030e8f47fd172946ab37de66367752388c459eac4c3261e929e2","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"dbb9e65a5f0c657542d4f2ee7a9ecc287476375dc2100984c162c2c9c00447db","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"724409d65bf50cf7dcf9c9031c780f03824af1a7e82c7b7024844c57260f5c34","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"53cee5e8cd018ab1ae19f13012dcaed6e1837f7bd01098e63ceae66ce61237fa","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"ab56234c79293b6b206c7975789e8f4cfb6fb481fb227c5464051a0b682db828","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"a3043ea977a394b53d157073bc5f373ad28f61997fc1752bb01265be62bc9b01","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"7b0e2a7c96dd726816480b62b466f34df5efba467d77da173bf9b08704311225","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"1814b014e6e25490835292c58c786b9d7945b0f3179a9698029f55f53df75448","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"f774bd092f51a31fbb34847213f9583234f585f66918d0af55e81bbbae40463b","declaration":"export type ToolPluginMetadata = ToolPluginMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"414a89d0c80eaaf682dc25d306e7223c981c6fd25366adb973a106f4268a6a54","declaration":"export type ToolPluginStaticToolMetadata = ToolPluginStaticToolMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginStaticToolMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"5cb770b85f7a568f9563c0d6d746ab8f10bb551e61c5ee07b28683ec50a28d00","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"f851db4de9ce7588165a8fa0d3cf5a572596bc27135639cd24004e147fe925fe","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"category":null,"entrypoint":"tool-results","importSpecifier":"openclaw/plugin-sdk/tool-results","recordType":"module"} {"closureHash":"c5b0951c3629834dff78f92317cf1421a6bbc0faf9307be1cabb915055665280","declaration":"export function jsonResult(payload: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"jsonResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} {"closureHash":"b9aefc637a694c74e028e92006004bef18185ed569af48ac43d36b646b0af2f3","declaration":"export function textResult(text: string, details: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"textResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} @@ -4682,9 +4682,9 @@ {"closureHash":"9f9174f1023a8a451232f5d97d1e922b7faa88b76b58cffbfbe246a1488e0ddd","declaration":"export function readJsonWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; emptyObjectOnEmpty?: boolean; invalidJsonMessage?: string; }): Promise<{ ok: true; value: unknown; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readJsonWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"1bbddd5d1c8297e315275db4d5e1c8748ff09c0be7c37b0cf456db4fc69f8909","declaration":"export function readRequestBodyWithLimit(req: IncomingMessage, options: ReadRequestBodyOptions): Promise;","entrypoint":"webhook-ingress","exportName":"readRequestBodyWithLimit","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"e8a1174cac83ce346a231fd25e82bb5f7618ffb57cdd9af6eaa208411f70646d","declaration":"export function readWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; invalidBodyMessage?: string; }): Promise<{ ok: true; value: string; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"137a9d7b92bee027864e6b3ed9dab727b82cec93e88d72099ea796f3cba761cf","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"32467eac87696d9fe01b7f3fb4586c0e10ae4cd8131c353faa0f9663ff46d313","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"05835f2f66f906f736b82b332a84a9714bc3a6b211890a62d8fc4dbf9258a7d4","declaration":"export function registerWebhookTarget(targetsByPath: Map, target: T, opts?: RegisterWebhookTargetOptions): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"081f16d31a0ae6a890fa372cde3100a3544fe95d584bbaef196009b56a60d275","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"c36279d85866fd9e2672d3acc910ac71b73886fe118dfe64d976090e7552c0de","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"ec2f47e9bd7a075a3dd4302ac9e4c0bfe20630445db6735cf803f2d354275841","declaration":"export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string;","entrypoint":"webhook-ingress","exportName":"requestBodyErrorToText","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"5e1f3b468d56eb0f82104184077fdf4b6bd0c1444aa10b71a65fcaa2ec0298a2","declaration":"export function resolveRequestClientIp(req?: IncomingMessage, trustedProxies?: string[], allowRealIpFallback?: boolean): string | undefined;","entrypoint":"webhook-ingress","exportName":"resolveRequestClientIp","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"56c3e9868dfa6ee2a7a128eb02039a16b52696759adadbd3fe0a3bd7a6a07eeb","declaration":"export function resolveSingleWebhookTarget(targets: readonly T[], isMatch: (target: T) => boolean): WebhookTargetMatchResult;","entrypoint":"webhook-ingress","exportName":"resolveSingleWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} @@ -4702,7 +4702,7 @@ {"closureHash":"efb74e84339c15d6f607b38d1d477b0504d47d04ae78e77083a1893c756a0b54","declaration":"export const WEBHOOK_RATE_LIMIT_DEFAULTS: Readonly<{ windowMs: 60000; maxRequests: 120; maxTrackedKeys: 4096;}>;","entrypoint":"webhook-ingress","exportName":"WEBHOOK_RATE_LIMIT_DEFAULTS","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"const","recordType":"export"} {"closureHash":"13b28ec9acae43c3bdcd683b9831154f88118dee8142e42b7eebfd59f08c602c","declaration":"export type BoundedCounter = BoundedCounter;","entrypoint":"webhook-ingress","exportName":"BoundedCounter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"9e737ae8c17038ef7becbd2bf1b756cfeaed571e2ded368c0fe3a446ebd1f666","declaration":"export type FixedWindowRateLimiter = FixedWindowRateLimiter;","entrypoint":"webhook-ingress","exportName":"FixedWindowRateLimiter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} -{"closureHash":"9357b6c5e08176f9f4416d77f22dfce793961da76bf7b88ea1b3a756be25d924","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} +{"closureHash":"49675c44ab8b6607d9a56e0e2b9458ca15394f86798ada8e0e9b90051708ad92","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"b3e98619854462acfac6538ac9a2d0cefb88704a0eac712563f0996b07364152","declaration":"export type RegisterWebhookTargetOptions = RegisterWebhookTargetOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookTargetOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"964c2bfd8f268c411ee7b982c9de02c207f4bec402dd62c9f998a59752bb9d1f","declaration":"export type RegisteredWebhookTarget = RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"RegisteredWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"aa552e5bdaefc9eb94d3b5a5cdf8c413fad555d9f452c6a2a6af5a3ce695c9d1","declaration":"export type WebhookAnomalyTracker = WebhookAnomalyTracker;","entrypoint":"webhook-ingress","exportName":"WebhookAnomalyTracker","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 82c38cd23ffc..b79fbd0ad03c 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -45,7 +45,7 @@ import { parseAgentSessionKey, } from "../../routing/session-key.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; -import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -259,7 +259,7 @@ async function resolveCliSkillsPrompt(params: { workspaceDir: params.workspaceDir, }); if (!sandboxWorkspace) { - return resolveSkillsPromptForRun({ + return resolveSkillsPrompt({ skillsSnapshot: params.skillsSnapshot, workspaceDir: params.workspaceDir, config: params.config, @@ -305,7 +305,7 @@ async function resolveCliSkillsPrompt(params: { skillsWorkspaceDir, skillsPromptWorkspaceDir, }); - return resolveSkillsPromptForRun({ + return resolveSkillsPrompt({ skillsSnapshot: skillsSnapshotForRun, entries: promptSkillEntries, workspaceDir: skillsPromptWorkspaceDir, diff --git a/src/agents/code-mode.skills.test.ts b/src/agents/code-mode.skills.test.ts index a0b80e3016fd..fd619c8f171b 100644 --- a/src/agents/code-mode.skills.test.ts +++ b/src/agents/code-mode.skills.test.ts @@ -3,7 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Skill } from "../skills/loading/skill-contract.js"; -import { resolveSkillsPromptForRun } from "../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../skills/loading/workspace-skill-prompt.js"; import { createFixtureSkillEntry } from "../skills/test-support/test-helpers.js"; import { createOpenClawReadTool } from "./agent-tools.read.js"; import { resolveCodeModeSkills } from "./code-mode-skills.js"; @@ -48,7 +48,7 @@ describe("Code Mode skills and read tools", () => { it("keeps Code Mode skill parsing aligned with the production prompt renderer", () => { const entries = [createFixtureSkillEntry("alpha"), createFixtureSkillEntry("beta")]; - const skillsPrompt = resolveSkillsPromptForRun({ + const skillsPrompt = resolveSkillsPrompt({ entries, workspaceDir: "/workspace", }); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index ad23143803fa..c3c49cdd9903 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -44,7 +44,7 @@ import { type UserTurnInput, type UserTurnTranscriptRecorder, } from "../../sessions/user-turn-transcript.js"; -import { buildWorkspaceSkillSnapshot } from "../../skills/loading/workspace.js"; +import type { SkillSnapshot } from "../../skills/types.js"; import { getGeneratedMediaTaskIdsForSessionKey, hasNewGeneratedMediaTaskForSessionKey, @@ -535,7 +535,7 @@ export function runAgentAttempt(params: { runContext: ReturnType; spawnedBy: string | undefined; messageChannel: ReturnType; - skillsSnapshot: ReturnType | undefined; + skillsSnapshot: SkillSnapshot | undefined; resolvedVerboseLevel: VerboseLevel | undefined; agentDir: string; onAgentEvent: (evt: { diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 95d0ab4f6c7e..b5dae285ec6c 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -964,9 +964,12 @@ export async function loadCompactHooksHarness(): Promise<{ applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), })); - vi.doMock("../../skills/loading/workspace.js", () => ({ - loadWorkspaceSkillEntries: vi.fn(() => []), - resolveSkillsPromptForRun: vi.fn(() => undefined), + vi.doMock("../../skills/loading/workspace-skill-loader.js", () => ({ + loadWorkspaceSkills: vi.fn(() => []), + })); + + vi.doMock("../../skills/loading/workspace-skill-prompt.js", () => ({ + resolveSkillsPrompt: vi.fn(() => undefined), })); vi.doMock("../agent-scope.js", () => ({ diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index 2ace8cf863dd..40b29f75b085 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -16,7 +16,7 @@ import { extractModelCompat } from "../../plugins/provider-model-compat.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { transformProviderSystemPrompt } from "../../plugins/provider-runtime.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; -import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import { applySkillEnvOverrides, @@ -170,7 +170,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP skillsWorkspaceDir: effectiveSkillsWorkspace, skillsPromptWorkspaceDir: effectiveSkillsPromptWorkspace, }); - const skillsPrompt = resolveSkillsPromptForRun({ + const skillsPrompt = resolveSkillsPrompt({ skillsSnapshot: skillsSnapshotForRun, entries: promptSkillEntries, config: params.config, diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.ts b/src/agents/embedded-agent-runner/run/attempt-setup.ts index 467e1098375b..e59e1eddffb9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.ts @@ -28,7 +28,7 @@ import { resolveProviderRuntimePluginHandle, type ProviderRuntimePluginHandle, } from "../../../plugins/provider-hook-runtime.js"; -import { resolveSkillsPromptForRun } from "../../../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../../../skills/loading/workspace-skill-prompt.js"; import { resolveEmbeddedRunSkillEntries } from "../../../skills/runtime/embedded-run-entries.js"; import { applySkillEnvOverrides, @@ -524,7 +524,7 @@ export function prepareEmbeddedAttemptSkills(params: { skillsWorkspaceDir, skillsPromptWorkspaceDir, }); - const skillsPrompt = resolveSkillsPromptForRun({ + const skillsPrompt = resolveSkillsPrompt({ skillsSnapshot, entries: promptSkillEntries, config: params.attempt.config, diff --git a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts index b90afec89fb4..c334c2e731bd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts @@ -491,8 +491,8 @@ vi.mock("../../../skills/runtime/env-overrides.js", () => ({ applySkillEnvOverridesFromSnapshot: () => () => {}, })); -vi.mock("../../../skills/loading/workspace.js", () => ({ - resolveSkillsPromptForRun: (...args: unknown[]) => hoisted.resolveSkillsPromptForRunMock(...args), +vi.mock("../../../skills/loading/workspace-skill-prompt.js", () => ({ + resolveSkillsPrompt: (...args: unknown[]) => hoisted.resolveSkillsPromptForRunMock(...args), })); vi.mock("../../../skills/runtime/embedded-run-entries.js", () => ({ diff --git a/src/agents/embedded-agent-runner/run/attempt-startup.test.ts b/src/agents/embedded-agent-runner/run/attempt-startup.test.ts index cf7a259ce24b..9bdd934e6a10 100644 --- a/src/agents/embedded-agent-runner/run/attempt-startup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-startup.test.ts @@ -18,8 +18,8 @@ vi.mock("../../../skills/runtime/embedded-run-entries.js", () => ({ })), })); -vi.mock("../../../skills/loading/workspace.js", () => ({ - resolveSkillsPromptForRun: vi.fn(() => "skills prompt"), +vi.mock("../../../skills/loading/workspace-skill-prompt.js", () => ({ + resolveSkillsPrompt: vi.fn(() => "skills prompt"), })); vi.mock("../sandbox-skills.js", () => ({ diff --git a/src/agents/embedded-agent-runner/sandbox-skills.test.ts b/src/agents/embedded-agent-runner/sandbox-skills.test.ts index a613f4c9fc6e..84b1ae58d325 100644 --- a/src/agents/embedded-agent-runner/sandbox-skills.test.ts +++ b/src/agents/embedded-agent-runner/sandbox-skills.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createSyntheticSourceInfo } from "../../skills/loading/skill-contract.js"; -import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import type { SkillSnapshot } from "../../skills/types.js"; import { @@ -177,7 +177,7 @@ describe("resolveSandboxSkillRuntimeInputs", () => { skillsWorkspaceDir, skillsPromptWorkspaceDir, }); - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: skillsSnapshotForRun, entries: promptSkillEntries, workspaceDir: skillsPromptWorkspaceDir, @@ -228,7 +228,7 @@ describe("resolveSandboxSkillRuntimeInputs", () => { eligibility: skillsEligibility, workspaceOnly: true, }); - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ entries: shouldLoadSkillEntries ? skillEntries : undefined, workspaceDir: root, eligibility: skillsEligibility, diff --git a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts index fb2e3e350bd4..ae7fdee5d0e7 100644 --- a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts +++ b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts @@ -34,8 +34,8 @@ vi.mock("../process/exec.js", async (importOriginal) => ({ spawnCommand: spawnDockerProcess, })); -vi.mock("../skills/loading/workspace.js", () => ({ - syncSkillsToWorkspace: vi.fn(async () => undefined), +vi.mock("../skills/loading/workspace-skill-sync.runtime.js", () => ({ + syncWorkspaceSkills: vi.fn(async () => undefined), })); let resolveSandboxContext: typeof import("./sandbox/context.js").resolveSandboxContext; diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 679da27e2707..1d5dfa43e9e9 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -60,8 +60,8 @@ vi.mock("../skills/runtime/remote.js", () => ({ getRemoteSkillEligibility: vi.fn(() => ({ note: "test-remote" })), })); -vi.mock("../skills/loading/workspace.js", () => ({ - syncSkillsToWorkspace: syncSkillsToWorkspaceMock, +vi.mock("../skills/loading/workspace-skill-sync.runtime.js", () => ({ + syncWorkspaceSkills: syncSkillsToWorkspaceMock, })); let sandboxFixtureRoot = ""; diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 4194f653546c..cab3a87fb4d5 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -14,6 +14,7 @@ import { resolveBrowserConfig, } from "../../plugin-sdk/browser-profiles.js"; import { defaultRuntime } from "../../runtime.js"; +import { createLazyRuntimeNamedExport } from "../../shared/lazy-runtime.js"; import type { SkillEligibilityContext, SkillSnapshot, SkillUsagePath } from "../../skills/types.js"; import type { ExecPolicyOverrides } from "../exec-defaults.js"; import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from "./backend.js"; @@ -29,6 +30,11 @@ import { resolveSandboxWorkspaceLayoutPaths } from "./shared.js"; import type { SandboxContext, SandboxWorkspaceInfo } from "./types.js"; import { ensureSandboxWorkspace } from "./workspace.js"; +const loadSyncWorkspaceSkills = createLazyRuntimeNamedExport( + () => import("../../skills/loading/workspace-skill-sync.runtime.js"), + "syncWorkspaceSkills", +); + async function syncSandboxSkillsToWorkspace(params: { sourceWorkspaceDir: string; targetWorkspaceDir: string; @@ -39,15 +45,12 @@ async function syncSandboxSkillsToWorkspace(params: { skillsSnapshot?: SkillSnapshot; }): Promise<{ eligibility?: SkillEligibilityContext; skillUsagePaths?: SkillUsagePath[] }> { try { - const [ - { syncSkillsToWorkspace }, - { getRemoteSkillEligibility }, - { resolveNodeExecEligibility }, - ] = await Promise.all([ - import("../../skills/loading/workspace.js"), - import("../../skills/runtime/remote.js"), - import("../exec-defaults.js"), - ]); + const [syncWorkspaceSkills, { getRemoteSkillEligibility }, { resolveNodeExecEligibility }] = + await Promise.all([ + loadSyncWorkspaceSkills(), + import("../../skills/runtime/remote.js"), + import("../exec-defaults.js"), + ]); const nodeSkills = resolveNodeExecEligibility({ cfg: params.config, sessionKey: params.rawSessionKey, @@ -60,7 +63,7 @@ async function syncSandboxSkillsToWorkspace(params: { advertiseExecNode: nodeSkills.canExec, }), }; - const skillUsagePaths = await syncSkillsToWorkspace({ + const skillUsagePaths = await syncWorkspaceSkills({ sourceWorkspaceDir: params.sourceWorkspaceDir, targetWorkspaceDir: params.targetWorkspaceDir, config: params.config, diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 60645d0ea20d..be2dab872e29 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -21,7 +21,7 @@ import { buildConfiguredAgentSystemPrompt } from "../../agents/system-prompt-con import { buildSystemPromptParams } from "../../agents/system-prompt-params.js"; import type { WorkspaceBootstrapFile } from "../../agents/workspace.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js"; -import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js"; +import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import { getRemoteSkillEligibility } from "../../skills/runtime/remote.js"; import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js"; @@ -127,7 +127,7 @@ async function resolveCommandSkillsPrompt(params: { skillsWorkspaceDir, skillsPromptWorkspaceDir, }); - return resolveSkillsPromptForRun({ + return resolveSkillsPrompt({ skillsSnapshot: skillsSnapshotForRun, entries: promptSkillEntries, config: params.config, diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index 8ac772c15117..37b7c1285ea3 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -50,8 +50,8 @@ vi.mock("../../skills/runtime/remote.js", () => ({ getRemoteSkillEligibility: getRemoteSkillEligibilityMock, })); -vi.mock("../../skills/loading/workspace.js", () => ({ - buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, +vi.mock("../../skills/loading/workspace-skill-prompt.js", () => ({ + buildSkillSnapshot: buildWorkspaceSkillSnapshotMock, })); vi.mock("../../skills/runtime/refresh.js", () => ({ diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index bd1b62f0cd8a..d88be3256604 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -297,9 +297,12 @@ vi.mock("../agents/workspace.js", () => ({ ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })), })); -vi.mock("../skills/loading/workspace.js", () => ({ - buildWorkspaceSkillSnapshot: vi.fn(() => undefined), - loadWorkspaceSkillEntries: vi.fn(() => []), +vi.mock("../skills/loading/workspace-skill-prompt.js", () => ({ + buildSkillSnapshot: vi.fn(() => undefined), +})); + +vi.mock("../skills/loading/workspace-skill-loader.js", () => ({ + loadWorkspaceSkills: vi.fn(() => []), })); vi.mock("../skills/runtime/remote.js", () => ({ diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 5c45165c5532..9c5b15f53777 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -42,7 +42,7 @@ import { } from "../../skills/lifecycle/clawhub.js"; import { installSkill } from "../../skills/lifecycle/install.js"; import { installUploadedSkillArchive } from "../../skills/lifecycle/upload-install.js"; -import { loadWorkspaceSkillEntries } from "../../skills/loading/workspace.js"; +import { loadWorkspaceSkills } from "../../skills/loading/workspace-skill-loader.js"; import { getRemoteSkillEligibility } from "../../skills/runtime/remote.js"; import { collectClawHubVerdictTargets, @@ -272,7 +272,7 @@ export const skillsHandlers: GatewayRequestHandlers = { const workspaceDirs = listAgentWorkspaceDirs(cfg); const bins = new Set(); for (const workspaceDir of workspaceDirs) { - const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg }); + const entries = loadWorkspaceSkills(workspaceDir, { config: cfg }); for (const bin of collectSkillBins(entries)) { bins.add(bin); } diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index 2c1b81b01384..18144a426ede 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -11,8 +11,8 @@ import { collectStateDeepFilesystemFindings, } from "./audit-extra.async.js"; -vi.mock("../skills/loading/workspace.js", () => ({ - loadWorkspaceSkillEntries: (workspaceDir: string) => { +vi.mock("../skills/loading/workspace-skill-loader.js", () => ({ + loadWorkspaceSkills: (workspaceDir: string) => { const sep = workspaceDir.includes("\\") ? "\\" : "/"; const baseDir = `${workspaceDir}${sep}skills${sep}evil-skill`; return [ diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index a060e90dd252..5657e13fddc0 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -24,6 +24,7 @@ import { readRegularFile, statRegularFile } from "../infra/fs-safe.js"; import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js"; +import { loadWorkspaceSkills } from "../skills/loading/workspace-skill-loader.js"; import type { SkillScanFinding } from "../skills/security/scanner.js"; import { listInstalledPluginDirs } from "./installed-plugin-dirs.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; @@ -48,7 +49,6 @@ type ExecDockerRawFn = ( const DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS = 5000; type CodeSafetySummaryCache = Map>; -const loadSkillsModule = createLazyRuntimeModule(() => import("../skills/loading/workspace.js")); const loadConfigModule = createLazyRuntimeModule(() => import("../config/config.js")); @@ -919,10 +919,8 @@ export async function collectInstalledSkillsCodeSafetyFindings(params: { workspaceDirs.add(workspaceDir); } } - const { loadWorkspaceSkillEntries } = await loadSkillsModule(); - for (const workspaceDir of workspaceDirs) { - const entries = loadWorkspaceSkillEntries(workspaceDir, { + const entries = loadWorkspaceSkills(workspaceDir, { config: params.cfg, includeArchived: true, }); diff --git a/src/skills/discovery/command-specs.test.ts b/src/skills/discovery/command-specs.test.ts index 6241529092b1..6f7790a7895d 100644 --- a/src/skills/discovery/command-specs.test.ts +++ b/src/skills/discovery/command-specs.test.ts @@ -26,9 +26,9 @@ vi.mock("../../plugins/bundle-commands.js", () => ({ loadEnabledClaudeBundleCommands: () => bundleCommandState.entries, })); -vi.mock("../loading/workspace.js", () => ({ - filterWorkspaceSkillEntriesWithOptions: (entries: SkillEntry[]) => entries, - loadVisibleWorkspaceSkillEntries: () => [], +vi.mock("../loading/workspace-skill-loader.js", () => ({ + filterWorkspaceSkills: (entries: SkillEntry[]) => entries, + loadVisibleSkills: () => [], })); beforeEach(() => { diff --git a/src/skills/discovery/command-specs.ts b/src/skills/discovery/command-specs.ts index 8ac915560223..78d9f6c26b34 100644 --- a/src/skills/discovery/command-specs.ts +++ b/src/skills/discovery/command-specs.ts @@ -9,10 +9,7 @@ import { createDedupeCache } from "../../infra/dedupe.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js"; import { resolveSkillTelemetrySource } from "../loading/source.js"; -import { - filterWorkspaceSkillEntriesWithOptions, - loadVisibleWorkspaceSkillEntries, -} from "../loading/workspace.js"; +import { filterWorkspaceSkills, loadVisibleSkills } from "../loading/workspace-skill-loader.js"; import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "../types.js"; import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; import { filterUserInvocableSkillEntries, isSkillPromptVisible } from "./skill-index.js"; @@ -89,12 +86,12 @@ export function buildWorkspaceSkillCommandSpecs( const effectiveSkillFilter = opts?.skillFilter ?? resolveEffectiveAgentSkillFilter(opts?.config, opts?.agentId); const eligible = opts?.entries - ? filterWorkspaceSkillEntriesWithOptions(opts.entries, { + ? filterWorkspaceSkills(opts.entries, { config: opts?.config, skillFilter: effectiveSkillFilter, eligibility: opts?.eligibility, }) - : loadVisibleWorkspaceSkillEntries(workspaceDir, { + : loadVisibleSkills(workspaceDir, { config: opts?.config, managedSkillsDir: opts?.managedSkillsDir, bundledSkillsDir: opts?.bundledSkillsDir, diff --git a/src/skills/discovery/status-workspace.test.ts b/src/skills/discovery/status-workspace.test.ts index 36dd12734629..267a7cdf312b 100644 --- a/src/skills/discovery/status-workspace.test.ts +++ b/src/skills/discovery/status-workspace.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { withEnv, withEnvAsync } from "../../test-utils/env.js"; -import { loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import { writeSkill } from "../test-support/e2e-test-helpers.js"; import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; import type { SkillEntry } from "../types.js"; @@ -194,7 +194,7 @@ describe("buildWorkspaceSkillStatus", () => { it("requires explicit enablement before exposing bundled coding-agent", async () => { const workspaceDir = await createTempWorkspaceDir(); const bundledSkillsDir = path.resolve("skills"); - const entries = loadWorkspaceSkillEntries(workspaceDir, { + const entries = loadWorkspaceSkills(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), bundledSkillsDir, config: { diff --git a/src/skills/discovery/status.ts b/src/skills/discovery/status.ts index 174513553981..6a99a7bdc607 100644 --- a/src/skills/discovery/status.ts +++ b/src/skills/discovery/status.ts @@ -22,7 +22,7 @@ import { resolveSkillConfig, resolveSkillsInstallPreferences, } from "../loading/config.js"; -import { loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import { mergeRemoteNodeSkillEntries } from "../runtime/remote-skills.js"; import type { SkillEntry, @@ -361,7 +361,7 @@ export function buildWorkspaceSkillStatus( // the loader must stay unfiltered; node-hosted skills merge in separately. const skillEntries = mergeRemoteNodeSkillEntries( opts?.entries ?? - loadWorkspaceSkillEntries(workspaceDir, { + loadWorkspaceSkills(workspaceDir, { config: opts?.config, managedSkillsDir, bundledSkillsDir: bundledContext.dir, diff --git a/src/skills/lifecycle/install-fallback.test.ts b/src/skills/lifecycle/install-fallback.test.ts index 187f6428bb8f..9e8f7a9d3ac5 100644 --- a/src/skills/lifecycle/install-fallback.test.ts +++ b/src/skills/lifecycle/install-fallback.test.ts @@ -8,7 +8,7 @@ import { hasBinaryMock, runCommandWithTimeoutMock } from "../test-support/instal import type { SkillEntry, SkillInstallSpec } from "../types.js"; const skillsMocks = vi.hoisted(() => ({ - loadWorkspaceSkillEntries: vi.fn(), + loadWorkspaceSkills: vi.fn(), })); vi.mock("../../process/exec.js", () => ({ @@ -19,8 +19,8 @@ vi.mock("../../plugins/install-security-scan.js", () => ({ evaluateSkillInstallPolicy: vi.fn(async () => undefined), })); -vi.mock("../loading/workspace.js", () => ({ - loadWorkspaceSkillEntries: skillsMocks.loadWorkspaceSkillEntries, +vi.mock("../loading/workspace-skill-loader.js", () => ({ + loadWorkspaceSkills: skillsMocks.loadWorkspaceSkills, })); let installSkill: typeof import("./install.js").installSkill; @@ -132,7 +132,7 @@ describe("skills-install fallback edge cases", () => { beforeAll(async () => { workspaceDir = await suiteTempDirs.setup(); - skillsMocks.loadWorkspaceSkillEntries.mockReturnValue([ + skillsMocks.loadWorkspaceSkills.mockReturnValue([ makeSkillEntry(workspaceDir, "go-tool-single", { kind: "go", module: "example.com/tool@latest", @@ -282,7 +282,7 @@ describe("skills-install fallback edge cases", () => { }); mockAvailableBinaries([]); try { - skillsMocks.loadWorkspaceSkillEntries.mockReturnValueOnce([ + skillsMocks.loadWorkspaceSkills.mockReturnValueOnce([ makeSkillEntry(workspaceDir, "brew-tool-container", { kind: "brew", formula: "openai-whisper", diff --git a/src/skills/lifecycle/install.test-support.ts b/src/skills/lifecycle/install.test-support.ts index 844eab0c3517..8f79d3363805 100644 --- a/src/skills/lifecycle/install.test-support.ts +++ b/src/skills/lifecycle/install.test-support.ts @@ -1,10 +1,10 @@ import type { resolveSkillsInstallPreferences } from "../loading/config.js"; -import type { loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import type { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import "./install.js"; type SkillsInstallDeps = { hasBinary(bin: string): boolean; - loadWorkspaceSkillEntries: typeof loadWorkspaceSkillEntries; + loadWorkspaceSkills: typeof loadWorkspaceSkills; resolveNodeInstallStateDir(): string; resolveBrewExecutable(): string | undefined; isContainerEnvironment(): boolean; diff --git a/src/skills/lifecycle/install.test.ts b/src/skills/lifecycle/install.test.ts index f548d048a527..ce8d023d3e65 100644 --- a/src/skills/lifecycle/install.test.ts +++ b/src/skills/lifecycle/install.test.ts @@ -115,7 +115,7 @@ describe("installSkill before_install hooks", () => { resetGlobalHookRunner(); runCommandWithTimeoutMock.mockClear(); skillsInstallTesting.setDepsForTest({ - loadWorkspaceSkillEntries: loadTestWorkspaceSkillEntries, + loadWorkspaceSkills: loadTestWorkspaceSkillEntries, resolveNodeInstallStateDir: () => { const stateDir = process.env.OPENCLAW_STATE_DIR; if (!stateDir) { diff --git a/src/skills/lifecycle/install.ts b/src/skills/lifecycle/install.ts index 5cb27bbba0c8..fa8069470f58 100644 --- a/src/skills/lifecycle/install.ts +++ b/src/skills/lifecycle/install.ts @@ -17,7 +17,7 @@ import { resolveSkillsInstallPreferences as defaultResolveSkillsInstallPreferences, } from "../loading/config.js"; import { resolveSkillSource } from "../loading/source.js"; -import { loadWorkspaceSkillEntries as defaultLoadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills as defaultLoadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import type { SkillEntry, SkillInstallSpec, SkillsInstallPreferences } from "../types.js"; import { installDownloadSpec } from "./install-download.js"; import { formatInstallFailureMessage } from "./install-output.js"; @@ -34,7 +34,7 @@ export type { SkillInstallSkipReason } from "./install-types.js"; type SkillsInstallDeps = { hasBinary: (bin: string) => boolean; - loadWorkspaceSkillEntries: typeof defaultLoadWorkspaceSkillEntries; + loadWorkspaceSkills: typeof defaultLoadWorkspaceSkills; resolveNodeInstallStateDir: () => string; resolveBrewExecutable: () => string | undefined; isContainerEnvironment: () => boolean; @@ -43,7 +43,7 @@ type SkillsInstallDeps = { const defaultSkillsInstallDeps: SkillsInstallDeps = { hasBinary: defaultHasBinary, - loadWorkspaceSkillEntries: defaultLoadWorkspaceSkillEntries, + loadWorkspaceSkills: defaultLoadWorkspaceSkills, resolveNodeInstallStateDir: resolveDefaultNodeInstallStateDir, resolveBrewExecutable: defaultResolveBrewExecutable, isContainerEnvironment: defaultIsContainerEnvironment, @@ -680,7 +680,7 @@ export async function installSkill(params: SkillInstallRequest): Promise item.skill.name === params.skillName); if (!entry) { return { diff --git a/src/skills/loading/agents-directory.test.ts b/src/skills/loading/agents-directory.test.ts index bf6e27fc2bf7..28854d40c3f1 100644 --- a/src/skills/loading/agents-directory.test.ts +++ b/src/skills/loading/agents-directory.test.ts @@ -9,7 +9,7 @@ import { type SkillsHomeEnvSnapshot, } from "../test-support/home-env.test-support.js"; import { writeSkill } from "../test-support/test-helpers.js"; -import { buildWorkspaceSkillsPrompt } from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; vi.mock("./plugin-skills.js", () => ({ resolvePluginSkillDirs: () => [], @@ -18,10 +18,10 @@ vi.mock("./plugin-skills.js", () => ({ const tempDirs = createTempDirTracker(); function buildSkillsPrompt(workspaceDir: string, managedDir: string, bundledDir: string): string { - return buildWorkspaceSkillsPrompt(workspaceDir, { + return buildSkillSnapshot(workspaceDir, { managedSkillsDir: managedDir, bundledSkillsDir: bundledDir, - }); + }).prompt; } async function createWorkspaceSkillDirs() { diff --git a/src/skills/loading/compact-skill-paths.test.ts b/src/skills/loading/compact-skill-paths.test.ts deleted file mode 100644 index b701496a1306..000000000000 --- a/src/skills/loading/compact-skill-paths.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Compact skill path tests cover short path formatting for skill prompt payloads. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withEnv } from "../../test-utils/env.js"; -import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; -import { testing as workspaceSkillsTesting, buildWorkspaceSkillsPrompt } from "./workspace.js"; - -describe("compactSkillPaths", () => { - function buildPromptForFixtureSkill(params: { - workspaceRoot: string; - skillDir: string; - name: string; - description: string; - }) { - return buildWorkspaceSkillsPrompt(params.workspaceRoot, { - entries: [ - { - skill: createCanonicalFixtureSkill({ - name: params.name, - description: params.description, - filePath: path.join(params.skillDir, "SKILL.md"), - baseDir: params.skillDir, - source: "test", - }), - frontmatter: {}, - metadata: undefined, - invocation: { disableModelInvocation: false, userInvocable: true }, - exposure: { - includeInRuntimeRegistry: true, - includeInAvailableSkillsPrompt: true, - userInvocable: true, - }, - }, - ], - }); - } - - it("replaces home directory prefix with ~ in skill locations", () => { - const home = os.homedir(); - const skillDir = path.join(home, ".openclaw-test-skills", "test-skill"); - - const prompt = buildPromptForFixtureSkill({ - workspaceRoot: home, - skillDir, - name: "test-skill", - description: "A test skill for path compaction", - }); - - expect(prompt).not.toContain(home + path.sep); - expect(prompt).toContain("~/"); - expect(prompt).toContain("test-skill"); - expect(prompt).toContain("A test skill for path compaction"); - }); - - it("does not compact explicit state-root managed skill paths to OS-home tilde paths", () => { - const root = path.parse(os.homedir()).root; - const osHome = path.join(root, "data"); - const stateDir = path.join(osHome, ".openclaw"); - const skillDir = path.join(stateDir, "skills", "world-cup-soccer-openclaw-skill"); - const skillFile = path.join(skillDir, "SKILL.md"); - - const prompt = withEnv( - { - HOME: osHome, - OPENCLAW_HOME: osHome, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), - }, - () => - buildPromptForFixtureSkill({ - workspaceRoot: path.join(root, "workspace"), - skillDir, - name: "world-cup-soccer-openclaw-skill", - description: "World Cup standings lookup", - }), - ); - - expect(prompt).toContain(`${skillFile}`); - expect(prompt).not.toContain("~/.openclaw/skills/world-cup-soccer-openclaw-skill/SKILL.md"); - }); - - it("does not compact explicit state-root plugin skill paths to OS-home tilde paths", () => { - const root = path.parse(os.homedir()).root; - const osHome = path.join(root, "data"); - const stateDir = path.join(osHome, ".openclaw"); - const skillDir = path.join(stateDir, "plugin-skills", "calendar-plugin-skill"); - const skillFile = path.join(skillDir, "SKILL.md"); - - const prompt = withEnv( - { - HOME: osHome, - OPENCLAW_HOME: osHome, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), - }, - () => - buildPromptForFixtureSkill({ - workspaceRoot: path.join(root, "workspace"), - skillDir, - name: "calendar-plugin-skill", - description: "Calendar plugin skill", - }), - ); - - expect(prompt).toContain(`${skillFile}`); - expect(prompt).not.toContain("~/.openclaw/plugin-skills/calendar-plugin-skill/SKILL.md"); - }); - - it("compacts managed skill paths when OS-home tilde reaches the same path", () => { - const home = os.homedir(); - const stateDir = path.join(home, ".openclaw"); - const skillDir = path.join(stateDir, "skills", "home-managed-skill"); - - const prompt = withEnv( - { - HOME: home, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_HOME: undefined, - }, - () => - buildPromptForFixtureSkill({ - workspaceRoot: path.join(home, "workspace"), - skillDir, - name: "home-managed-skill", - description: "Home managed skill", - }), - ); - - expect(prompt).toContain("~/.openclaw/skills/home-managed-skill/SKILL.md"); - expect(prompt).not.toContain(`${path.join(skillDir, "SKILL.md")}`); - }); - - it("normalizes compacted Windows skill locations to forward slashes", () => { - const home = "C:\\Users\\alice"; - const skillPath = path.win32.join(home, ".openclaw-test-skills", "win-skill", "SKILL.md"); - - const compactedPath = workspaceSkillsTesting.compactHomePath(skillPath, [home]); - - expect(compactedPath).toBe("~/.openclaw-test-skills/win-skill/SKILL.md"); - }); - - it("preserves POSIX literal backslashes after home compaction", () => { - const home = os.homedir(); - const skillDir = path.join(home, ".openclaw-test-skills\\literal-skill"); - - const prompt = buildPromptForFixtureSkill({ - workspaceRoot: home, - skillDir, - name: "literal-skill", - description: "POSIX literal backslash skill", - }); - - const locationMatch = prompt.match(/([^<]+)<\/location>/); - if (!locationMatch) { - throw new Error("expected prompt location tag"); - } - expect(locationMatch[1]).toContain("~/"); - expect(locationMatch[1]).toContain("\\literal-skill"); - }); - - it("preserves paths outside home directory", () => { - const outsideHome = path.join(path.parse(os.homedir()).root, "openclaw-external-skills"); - const skillDir = path.join(outsideHome, "skills", "ext-skill"); - - const prompt = buildPromptForFixtureSkill({ - workspaceRoot: outsideHome, - skillDir, - name: "ext-skill", - description: "External skill", - }); - - expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); - expect(prompt).toContain(path.join(skillDir, "SKILL.md")); - }); - - it("loads skills when the shared state database is unavailable", () => { - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-load-"))); - const blockedParent = path.join(root, "state-blocker"); - fs.writeFileSync(blockedParent, "not a directory", "utf8"); - const skillDir = path.join(root, "workspace", "skills", "available-skill"); - - try { - const prompt = withEnv({ OPENCLAW_STATE_DIR: path.join(blockedParent, "state") }, () => - buildPromptForFixtureSkill({ - workspaceRoot: path.join(root, "workspace"), - skillDir, - name: "available-skill", - description: "Available despite missing lifecycle state", - }), - ); - - expect(prompt).toContain("available-skill"); - expect(prompt).toContain("Available despite missing lifecycle state"); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/src/skills/loading/plugin-sandbox-sync.test.ts b/src/skills/loading/plugin-sandbox-sync.test.ts deleted file mode 100644 index 4fa56628d9a7..000000000000 --- a/src/skills/loading/plugin-sandbox-sync.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Plugin sandbox sync tests cover syncing plugin-provided skills into sandbox state. -import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { writeSkill } from "../test-support/e2e-test-helpers.js"; -import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./workspace.js"; - -// Mock resolvePluginSkillDirs to return our test plugin skill directories -const mockResolvePluginSkillDirs = vi.hoisted(() => vi.fn(() => [] as string[])); - -vi.mock("./plugin-skills.js", () => ({ - resolvePluginSkillDirs: mockResolvePluginSkillDirs, -})); - -let fixtureRoot = ""; -let fixtureCount = 0; - -async function createCaseDir(prefix: string): Promise { - const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); - await fsPromises.mkdir(dir, { recursive: true }); - return dir; -} - -async function pathExists(filePath: string): Promise { - try { - await fsPromises.access(filePath); - return true; - } catch { - return false; - } -} - -beforeAll(async () => { - fixtureRoot = await fsPromises.realpath( - await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-skills-sync-")), - ); -}); - -afterAll(async () => { - await fsPromises.rm(fixtureRoot, { recursive: true, force: true }); -}); - -describe("syncSkillsToWorkspace for plugin skills", () => { - it("syncs plugin skills from symlinked directories to sandbox workspace", async () => { - const sourceWorkspace = await createCaseDir("source"); - const targetWorkspace = await createCaseDir("target"); - - const realPluginSkillDir = await createCaseDir("real-plugin-skill"); - await writeSkill({ - dir: realPluginSkillDir, - name: "wiki-maintainer", - description: "Wiki maintenance skill for sandboxed agents", - }); - - const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); - await fsPromises.mkdir(pluginSkillsDir, { recursive: true }); - const symlinkPath = path.join(pluginSkillsDir, "wiki-maintainer"); - - fs.symlinkSync( - realPluginSkillDir, - symlinkPath, - process.platform === "win32" ? "junction" : "dir", - ); - - mockResolvePluginSkillDirs.mockReturnValueOnce([realPluginSkillDir]); - - const skillUsagePaths = await syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - pluginSkillsDir, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }); - - const syncedSkillDir = path.join(targetWorkspace, "skills", "wiki-maintainer"); - const syncedSkillMd = path.join(syncedSkillDir, "SKILL.md"); - const syncedStat = await fsPromises.lstat(syncedSkillDir); - const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, { - bundledSkillsDir: path.join(targetWorkspace, ".bundled"), - managedSkillsDir: path.join(targetWorkspace, ".managed"), - }).replaceAll("\\", "/"); - - expect(await pathExists(syncedSkillMd)).toBe(true); - expect(syncedStat.isSymbolicLink()).toBe(false); - expect(prompt).toContain("Wiki maintenance skill for sandboxed agents"); - expect(prompt).toContain("skills/wiki-maintainer/SKILL.md"); - expect(prompt).not.toContain(realPluginSkillDir.replaceAll("\\", "/")); - expect(prompt).not.toContain(pluginSkillsDir.replaceAll("\\", "/")); - expect(prompt).not.toContain(symlinkPath.replaceAll("\\", "/")); - expect(skillUsagePaths).toEqual([ - { - readPath: syncedSkillMd, - skillFile: path.join(realPluginSkillDir, "SKILL.md"), - skillName: "wiki-maintainer", - skillSource: "workspace", - }, - ]); - }); - - it("syncs multiple plugin skills directories to sandbox workspace", async () => { - const sourceWorkspace = await createCaseDir("source-multi"); - const targetWorkspace = await createCaseDir("target-multi"); - - // Create multiple real plugin skill directories - const realSkillA = await createCaseDir("skill-a"); - await writeSkill({ - dir: realSkillA, - name: "browser-automation", - description: "Browser automation skill", - }); - - const realSkillB = await createCaseDir("skill-b"); - await writeSkill({ - dir: realSkillB, - name: "obsidian-vault", - description: "Obsidian vault maintenance skill", - }); - - // Create plugin-skills directory with symlinks - const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); - await fsPromises.mkdir(pluginSkillsDir, { recursive: true }); - - fs.symlinkSync( - realSkillA, - path.join(pluginSkillsDir, "browser-automation"), - process.platform === "win32" ? "junction" : "dir", - ); - fs.symlinkSync( - realSkillB, - path.join(pluginSkillsDir, "obsidian-vault"), - process.platform === "win32" ? "junction" : "dir", - ); - - mockResolvePluginSkillDirs.mockReturnValueOnce([realSkillA, realSkillB]); - - await syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - pluginSkillsDir, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }); - - // Both skills should be synced - expect( - await pathExists(path.join(targetWorkspace, "skills", "browser-automation", "SKILL.md")), - ).toBe(true); - expect( - await pathExists(path.join(targetWorkspace, "skills", "obsidian-vault", "SKILL.md")), - ).toBe(true); - }); - - it("does not sync plugin skills that escape allowed root", async () => { - const sourceWorkspace = await createCaseDir("source-escape"); - const targetWorkspace = await createCaseDir("target-escape"); - - // Create a skill outside any allowed root - const outsideRoot = await createCaseDir("outside-root"); - const escapedSkillDir = path.join(outsideRoot, "escaped-skill"); - await writeSkill({ - dir: escapedSkillDir, - name: "escaped-skill", - description: "Should not be synced", - }); - - // Create plugin-skills with symlink to escaped skill - const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); - await fsPromises.mkdir(pluginSkillsDir, { recursive: true }); - fs.symlinkSync( - escapedSkillDir, - path.join(pluginSkillsDir, "escaped-skill"), - process.platform === "win32" ? "junction" : "dir", - ); - - // Mock returns an allowed root that doesn't include the escaped skill - const allowedRoot = await createCaseDir("allowed-root"); - mockResolvePluginSkillDirs.mockReturnValueOnce([allowedRoot]); - - await syncSkillsToWorkspace({ - sourceWorkspaceDir: sourceWorkspace, - targetWorkspaceDir: targetWorkspace, - pluginSkillsDir, - bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), - managedSkillsDir: path.join(sourceWorkspace, ".managed"), - }); - - // Escaped skill should NOT be synced - expect( - await pathExists(path.join(targetWorkspace, "skills", "escaped-skill", "SKILL.md")), - ).toBe(false); - }); -}); diff --git a/src/skills/loading/skill-contract.test.ts b/src/skills/loading/skill-contract.test.ts new file mode 100644 index 000000000000..8d33c05cff1e --- /dev/null +++ b/src/skills/loading/skill-contract.test.ts @@ -0,0 +1,96 @@ +// Skill contract tests cover full and compact catalog serialization. +import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "openclaw/plugin-sdk/agent-sessions"; +import { describe, expect, it } from "vitest"; +import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; +import { formatSkillsForPrompt, type Skill } from "./skill-contract.js"; +import { formatSkillsCompactForPrompt as formatSkillsCompact } from "./skill-contract.js"; + +function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill { + return createCanonicalFixtureSkill({ + name, + description: desc, + filePath, + baseDir: `/skills/${name}`, + source: "workspace", + }); +} + +describe("formatSkillsCompact", () => { + it("keeps the full-format XML output aligned with the upstream formatter for visible skills", () => { + const skills = [ + { ...makeSkill("weather", "Get weather & forecasts"), promptVersion: "sha256:abc123" }, + makeSkill("notes", "Summarize notes", "/tmp/notes/SKILL.md"), + ]; + expect(formatSkillsForPrompt(skills)).toBe(upstreamFormatSkillsForPrompt(skills)); + }); + + it("renders all passed skills in the full formatter without reapplying visibility policy", () => { + const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true }; + const out = formatSkillsForPrompt([makeSkill("visible"), hidden]); + expect(out).toContain("visible"); + expect(out).toContain("hidden"); + }); + + it("returns empty string for no skills", () => { + expect(formatSkillsCompact([])).toBe(""); + }); + + it("keeps compact descriptions with name, location, and version", () => { + const out = formatSkillsCompact([ + { ...makeSkill("weather", "Get weather data"), promptVersion: "sha256:abc123" }, + ]); + expect(out).toContain("weather"); + expect(out).toContain("Get weather data"); + expect(out).toContain("/skills/weather/SKILL.md"); + expect(out).toContain("sha256:abc123"); + }); + + it("omits descriptions when their compact budget is zero", () => { + const out = formatSkillsCompact([makeSkill("weather", "Get weather data")], { + descriptionMaxChars: 0, + }); + expect(out).toContain("weather"); + expect(out).not.toContain(""); + }); + + it("preserves location notes when compact descriptions are omitted", () => { + const out = formatSkillsCompact( + [ + { + ...makeSkill("remote", "Remote skill"), + locationNote: "Load with exec host=node node=node-1.", + }, + ], + { descriptionMaxChars: 0 }, + ); + + expect(out).toContain("Load with exec host=node node=node-1."); + }); + + it("truncates descriptions without splitting emoji surrogate pairs", () => { + const out = formatSkillsCompact([makeSkill("emoji", `${"A".repeat(16)}😀 trailing`)], { + descriptionMaxChars: 20, + }); + + expect(out).toContain(`${"A".repeat(16)}...`); + expect(out).not.toMatch(/[\uD800-\uDFFF]/u); + }); + + it("renders all passed skills without reapplying visibility policy", () => { + const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true }; + const out = formatSkillsCompact([makeSkill("visible"), hidden]); + expect(out).toContain("visible"); + expect(out).toContain("hidden"); + }); + + it("escapes XML special characters", () => { + const out = formatSkillsCompact([makeSkill("a { + const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800))); + const compact = formatSkillsCompact(skills); + expect(compact.length).toBeLessThan(formatSkillsForPrompt(skills).length / 2); + }); +}); diff --git a/src/skills/loading/skill-contract.ts b/src/skills/loading/skill-contract.ts index 5fcd3aceba09..3ab5e89bedf1 100644 --- a/src/skills/loading/skill-contract.ts +++ b/src/skills/loading/skill-contract.ts @@ -1,3 +1,4 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; // Skill contract types describe loaded skill metadata, sources, and prompt surfaces. import type { SourceInfo } from "../../agents/sessions/source-info.js"; @@ -20,7 +21,7 @@ export interface Skill { export { createSyntheticSourceInfo } from "../../agents/sessions/source-info.js"; -function escapeXml(str: string): string { +export function escapeSkillXml(str: string): string { return str .replace(/&/g, "&") .replace(/"); - lines.push(` ${escapeXml(skill.name)}`); - lines.push(` ${escapeXml(skill.description)}`); - lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(` ${escapeSkillXml(skill.name)}`); + lines.push(` ${escapeSkillXml(skill.description)}`); + lines.push(` ${escapeSkillXml(skill.filePath)}`); if (skill.locationNote) { - lines.push(` ${escapeXml(skill.locationNote)}`); + lines.push(` ${escapeSkillXml(skill.locationNote)}`); } if (skill.promptVersion) { - lines.push(` ${escapeXml(skill.promptVersion)}`); + lines.push(` ${escapeSkillXml(skill.promptVersion)}`); + } + lines.push(" "); + } + lines.push(""); + return lines.join("\n"); +} + +/** Compact prompt catalog with descriptions bounded independently from identities. */ +export function formatSkillsCompactForPrompt( + skills: Skill[], + opts?: { descriptionMaxChars?: number }, +): string { + if (skills.length === 0) { + return ""; + } + const descriptionMaxChars = Math.max( + 0, + Math.floor(opts?.descriptionMaxChars ?? COMPACT_DESCRIPTION_MAX_CHARS), + ); + const lines = [ + "\n\nThe following skills provide specialized instructions for specific tasks.", + descriptionMaxChars > 0 + ? "Use the read tool to load a skill's file when the task matches its name or description." + : "Use the read tool to load a skill's file when the task matches its name.", + "If a skill's differs from a previous turn, re-read its SKILL.md before using it.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + for (const skill of skills) { + lines.push(" "); + lines.push(` ${escapeSkillXml(skill.name)}`); + if (descriptionMaxChars > 0) { + const description = truncateSkillDescription(skill.description, descriptionMaxChars); + if (description) { + lines.push(` ${escapeSkillXml(description)}`); + } + } + lines.push(` ${escapeSkillXml(skill.filePath)}`); + if (skill.locationNote) { + lines.push(` ${escapeSkillXml(skill.locationNote)}`); + } + if (skill.promptVersion) { + lines.push(` ${escapeSkillXml(skill.promptVersion)}`); } lines.push(" "); } diff --git a/src/skills/loading/skill-path-containment.test.ts b/src/skills/loading/skill-path-containment.test.ts new file mode 100644 index 000000000000..535cdc8d38cc --- /dev/null +++ b/src/skills/loading/skill-path-containment.test.ts @@ -0,0 +1,376 @@ +// Skill path containment tests cover root escapes, allowed symlinks, and diagnostics. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; +import { loggingState } from "../../logging/state.js"; +import { writeSkill } from "../test-support/e2e-test-helpers.js"; +import { + restoreMockSkillsHomeEnv, + setMockSkillsHomeEnv, + type SkillsHomeEnvSnapshot, +} from "../test-support/home-env.test-support.js"; +import { readSkillFrontmatterSafe } from "./local-loader.js"; +import { loadWorkspaceSkills } from "./workspace-skill-loader.js"; + +vi.mock("./plugin-skills.js", () => ({ + resolvePluginSkillDirs: () => [], +})); + +let fakeHome = ""; +let envSnapshot: SkillsHomeEnvSnapshot; +let tempRoot = ""; +let workspaceCaseIndex = 0; + +async function createTempWorkspaceDir() { + const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); + await fs.mkdir(workspaceDir, { recursive: true }); + return workspaceDir; +} + +function captureWarningLogger() { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + return warn; +} + +function firstWarningLine(warn: ReturnType): string { + const [line] = warn.mock.calls[0] ?? []; + return String(line); +} + +function loadTestWorkspaceSkills( + workspaceDir: string, + opts?: Parameters[1], +) { + return loadWorkspaceSkills(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: "", + pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"), + ...opts, + }); +} + +async function createEscapedBundledSkillFixture(params?: { + workspaceDir?: string; + outsideDir?: string; +}) { + const workspaceDir = params?.workspaceDir ?? (await createTempWorkspaceDir()); + const outsideDir = params?.outsideDir ?? (await createTempWorkspaceDir()); + const bundledDir = path.join(workspaceDir, ".bundled"); + const escapedSkillDir = path.join(outsideDir, "outside-bundled-skill"); + await writeSkill({ + dir: escapedSkillDir, + name: "outside-bundled-skill", + description: "Outside bundled", + }); + await fs.mkdir(bundledDir, { recursive: true }); + const requestedPath = path.join(bundledDir, "escaped-bundled-skill"); + await fs.symlink(escapedSkillDir, requestedPath, "dir"); + return { workspaceDir, outsideDir, bundledDir, escapedSkillDir, requestedPath }; +} + +beforeAll(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-containment-")); + fakeHome = path.join(tempRoot, "home"); + await fs.mkdir(fakeHome, { recursive: true }); + envSnapshot = setMockSkillsHomeEnv(fakeHome); +}); + +afterEach(() => { + setLoggerOverride(null); + loggingState.rawConsole = null; + resetLogger(); +}); + +afterAll(async () => { + await restoreMockSkillsHomeEnv(envSnapshot, async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); +}); + +describe("skill path containment", () => { + it.runIf(process.platform !== "win32")( + "skips workspace skill paths that resolve outside the workspace root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const outsideDir = await createTempWorkspaceDir(); + const escapedSkillDir = path.join(outsideDir, "outside-skill"); + await writeSkill({ + dir: escapedSkillDir, + name: "outside-skill", + description: "Outside", + }); + await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true }); + const requestedPath = path.join(workspaceDir, "skills", "escaped-skill"); + await fs.symlink(escapedSkillDir, requestedPath, "dir"); + const fileLinkSkillDir = path.join(workspaceDir, "skills", "escaped-file"); + await fs.mkdir(fileLinkSkillDir, { recursive: true }); + await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(fileLinkSkillDir, "SKILL.md")); + const targetDir = path.join(workspaceDir, "safe-target"); + await writeSkill({ + dir: targetDir, + name: "symlink-target", + description: "Target skill", + }); + const symlinkedSkillDir = path.join(workspaceDir, "skills", "symlinked"); + await fs.mkdir(symlinkedSkillDir, { recursive: true }); + await fs.symlink(path.join(targetDir, "SKILL.md"), path.join(symlinkedSkillDir, "SKILL.md")); + const warn = captureWarningLogger(); + + const entries = loadTestWorkspaceSkills(workspaceDir); + + expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill"); + expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill"); + expect(entries.map((entry) => entry.skill.name)).not.toContain("symlink-target"); + const warningLine = firstWarningLine(warn); + expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); + expect(warningLine).toContain("reason=symlink-escape"); + expect(warningLine).toContain("source=openclaw-workspace"); + expect(warningLine).toContain(`root=${path.join(workspaceDir, "skills")}`); + expect(warningLine).toContain(`requested=${requestedPath}`); + expect(warningLine).toContain("resolved="); + }, + ); + + it.runIf(process.platform !== "win32")( + "allows configured skill symlink targets outside their source root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillName = `manager-${++workspaceCaseIndex}`; + const targetRoot = path.join(tempRoot, `${skillName}-skills`); + const targetSkillDir = path.join(targetRoot, skillName); + await writeSkill({ + dir: targetSkillDir, + name: skillName, + description: "Manager skill", + }); + const workspaceSkillsDir = path.join(workspaceDir, "skills"); + await fs.mkdir(workspaceSkillsDir, { recursive: true }); + const symlinkPath = path.join(workspaceSkillsDir, skillName); + await fs.symlink(targetSkillDir, symlinkPath, "dir"); + const warn = captureWarningLogger(); + + try { + const entries = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { + allowSymlinkTargets: [targetRoot], + }, + }, + }, + }); + + expect(entries.map((entry) => entry.skill.name)).toContain(skillName); + expect(warn).not.toHaveBeenCalled(); + } finally { + await fs.unlink(symlinkPath).catch(() => undefined); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "loads managed skill directory symlinks outside the managed root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const managedDir = path.join(workspaceDir, ".managed"); + const skillName = `managed-${++workspaceCaseIndex}`; + const targetSkillDir = path.join(tempRoot, `${skillName}-target`, skillName); + await writeSkill({ + dir: targetSkillDir, + name: skillName, + description: "Managed symlink target", + }); + await fs.mkdir(managedDir, { recursive: true }); + const symlinkPath = path.join(managedDir, skillName); + await fs.symlink(targetSkillDir, symlinkPath, "dir"); + const warn = captureWarningLogger(); + + try { + const entries = loadTestWorkspaceSkills(workspaceDir, { + managedSkillsDir: managedDir, + }); + + expect(entries.map((entry) => entry.skill.name)).toContain(skillName); + expect(warn).not.toHaveBeenCalled(); + } finally { + await fs.unlink(symlinkPath).catch(() => undefined); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "keeps SKILL.md containment for managed symlinked skill directories", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const managedDir = path.join(workspaceDir, ".managed"); + const skillName = `managed-file-link-${++workspaceCaseIndex}`; + const targetSkillDir = path.join(tempRoot, `${skillName}-target`, skillName); + const outsideDir = path.join(tempRoot, `${skillName}-outside`); + await fs.mkdir(targetSkillDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await writeSkill({ + dir: outsideDir, + name: skillName, + description: "Escaped metadata", + }); + await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(targetSkillDir, "SKILL.md")); + await fs.mkdir(managedDir, { recursive: true }); + const symlinkPath = path.join(managedDir, skillName); + await fs.symlink(targetSkillDir, symlinkPath, "dir"); + const warn = captureWarningLogger(); + + try { + const entries = loadTestWorkspaceSkills(workspaceDir, { + managedSkillsDir: managedDir, + }); + + expect(entries.map((entry) => entry.skill.name)).not.toContain(skillName); + const warningLine = firstWarningLine(warn); + expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); + expect(warningLine).toContain("source=openclaw-managed"); + expect(warningLine).toContain("reason=symlink-escape"); + } finally { + await fs.unlink(symlinkPath).catch(() => undefined); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "calls out bundled symlink escapes with compact home-relative paths", + async () => { + const { workspaceDir, bundledDir, requestedPath } = await createEscapedBundledSkillFixture(); + const warn = captureWarningLogger(); + + const entries = loadTestWorkspaceSkills(workspaceDir, { + bundledSkillsDir: bundledDir, + }); + + expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-bundled-skill"); + const warningLine = firstWarningLine(warn); + expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); + expect(warningLine).toContain("source=openclaw-bundled"); + expect(warningLine).toContain("reason=bundled-symlink-escape"); + expect(warningLine).toContain("hint=likely-stray-local-symlink-or-checkout-mutation"); + expect(warningLine).toContain(`requested=${requestedPath}`); + expect(warningLine).toContain("resolved="); + }, + ); + + it.runIf(process.platform !== "win32")( + "uses compact home-relative paths in escaped skill console warnings", + async () => { + const { workspaceDir, bundledDir } = await createEscapedBundledSkillFixture({ + workspaceDir: path.join(fakeHome, "workspace"), + outsideDir: path.join(fakeHome, "outside"), + }); + const warn = captureWarningLogger(); + + loadTestWorkspaceSkills(workspaceDir, { + bundledSkillsDir: bundledDir, + }); + + const warningLine = firstWarningLine(warn); + expect(warningLine).toContain("root=~/workspace/.bundled"); + expect(warningLine).toContain("requested=~/workspace/.bundled/escaped-bundled-skill"); + expect(warningLine).toContain("resolved=~/outside/outside-bundled-skill"); + }, + ); + + it.runIf(process.platform !== "win32")( + "reads skill frontmatter when the allowed root is the filesystem root", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillDir = path.join(workspaceDir, "skills", "root-allowed"); + await writeSkill({ + dir: skillDir, + name: "root-allowed", + description: "Readable from filesystem root", + }); + + const frontmatter = readSkillFrontmatterSafe({ + rootDir: path.parse(skillDir).root, + filePath: path.join(skillDir, "SKILL.md"), + }); + + expect(frontmatter?.name).toBe("root-allowed"); + expect(frontmatter?.description).toBe("Readable from filesystem root"); + }, + ); + + it.runIf(process.platform !== "win32")( + "does not follow outside symlink dirs during repo-root detection", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + const outsideDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(outsideDir, "linked"), + name: "outside-linked-skill", + description: "Outside linked skill", + }); + await fs.mkdir(path.join(repoDir, "examples"), { recursive: true }); + await fs.symlink(outsideDir, path.join(repoDir, "examples", "linked"), "dir"); + await writeSkill({ + dir: path.join(repoDir, "skills", "group", "valid"), + name: "repo-nested-skill", + description: "Valid nested repo skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("repo-nested-skill"); + expect(names).not.toContain("outside-linked-skill"); + }, + ); + + it.runIf(process.platform !== "win32")( + "keeps configured roots with possible symlink skills outside nested skills", + async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + const targetRoot = path.join(tempRoot, `linked-root-${workspaceCaseIndex++}`); + const targetSkillDir = path.join(targetRoot, "linked-skill"); + await writeSkill({ + dir: targetSkillDir, + name: "linked-skill", + description: "Allowed linked skill", + }); + await fs.mkdir(path.join(repoDir, "group"), { recursive: true }); + await fs.symlink(targetSkillDir, path.join(repoDir, "group", "linked-skill"), "dir"); + await writeSkill({ + dir: path.join(repoDir, "skills", "group", "valid"), + name: "repo-nested-skill", + description: "Valid nested repo skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { + allowSymlinkTargets: [targetRoot], + extraDirs: [repoDir], + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("linked-skill"); + expect(names).toContain("repo-nested-skill"); + }, + ); +}); diff --git a/src/skills/loading/skill-paths.ts b/src/skills/loading/skill-paths.ts new file mode 100644 index 000000000000..e359152c893a --- /dev/null +++ b/src/skills/loading/skill-paths.ts @@ -0,0 +1,127 @@ +// Skill path helpers keep prompt and diagnostic paths compact without changing their meaning. +import os from "node:os"; +import path from "node:path"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { resolveOsHomeDir } from "../../infra/home-dir.js"; +import { isPathInside } from "../../infra/path-guards.js"; +import { resolveConfigDir } from "../../utils.js"; +import type { Skill } from "./skill-contract.js"; +import { tryRealpath } from "./symlink-targets.js"; + +/** Resolve the effective user home used by skill discovery. */ +export function resolveSkillsUserHomeDir(): string | undefined { + return resolveOsHomeDir(process.env, os.homedir); +} + +function resolveNativeUserHomeDir(): string | undefined { + try { + return path.resolve(os.homedir()); + } catch { + return undefined; + } +} + +function resolveCompactHomePrefixes(): string[] { + const homes = [resolveSkillsUserHomeDir(), resolveNativeUserHomeDir()].filter( + (home): home is string => Boolean(home), + ); + const resolvedHomes = homes.map((home) => path.resolve(home)); + const realHomes = resolvedHomes + .map((home) => tryRealpath(home)) + .filter((home): home is string => Boolean(home)); + return uniqueStrings([...resolvedHomes, ...realHomes]).toSorted((a, b) => b.length - a.length); +} + +/** Compact prompt-facing skill paths while preserving managed paths that `~` cannot reach. */ +export function compactPromptSkills(skills: Skill[]): Skill[] { + const homes = resolveCompactHomePrefixes(); + if (homes.length === 0) { + return skills; + } + const preservedRoots = resolvePreservedPromptSkillPathRoots(); + const tildeRoots = resolvePromptTildeRoots(); + return skills.map((skill) => ({ + ...skill, + filePath: shouldPreservePromptSkillPath(skill.filePath, preservedRoots, tildeRoots) + ? skill.filePath + : compactHomePath(skill.filePath, homes), + })); +} + +function resolvePreservedPromptSkillPathRoots(): string[] { + const configDir = resolveConfigDir(); + const promptSkillDirs = [ + path.resolve(configDir, "skills"), + path.resolve(configDir, "plugin-skills"), + ]; + const realPromptSkillDirs = promptSkillDirs + .map((dir) => tryRealpath(dir)) + .filter((dir): dir is string => Boolean(dir)); + return uniqueStrings([...promptSkillDirs, ...realPromptSkillDirs]); +} + +function resolvePromptTildeRoots(): string[] { + const nativeHome = resolveNativeUserHomeDir(); + if (!nativeHome) { + return []; + } + const resolvedNativeHome = path.resolve(nativeHome); + if (isContainerStateHomeWherePromptTildeEscapes(resolvedNativeHome)) { + return []; + } + const realNativeHome = tryRealpath(resolvedNativeHome); + return uniqueStrings([resolvedNativeHome, ...(realNativeHome ? [realNativeHome] : [])]); +} + +function isContainerStateHomeWherePromptTildeEscapes(home: string): boolean { + const configDir = path.resolve(resolveConfigDir()); + return ( + home === "/data" && + (configDir === "/data/.openclaw" || isPathInside("/data/.openclaw", configDir)) + ); +} + +function shouldPreservePromptSkillPath( + filePath: string, + roots: readonly string[], + tildeRoots: readonly string[], +): boolean { + const resolvedFilePath = path.resolve(filePath); + const isManagedPromptSkillPath = roots.some( + (root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath), + ); + if (!isManagedPromptSkillPath) { + return false; + } + return !tildeRoots.some( + (root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath), + ); +} + +function compactHomePath(filePath: string, homes: readonly string[]): string { + for (const home of homes) { + for (const prefix of compactHomePrefixesForHome(home)) { + if (filePath.startsWith(prefix)) { + return "~/" + normalizeCompactedSkillPath(filePath.slice(prefix.length), prefix); + } + } + } + return filePath; +} + +function compactHomePrefixesForHome(home: string): string[] { + const prefixes = [home.endsWith(path.sep) ? home : home + path.sep]; + if (home.includes("\\") && !home.endsWith("\\")) { + prefixes.push(home + "\\"); + } + return prefixes; +} + +function normalizeCompactedSkillPath(filePath: string, matchedHomePrefix: string): string { + return matchedHomePrefix.includes("\\") ? filePath.replace(/\\/g, "/") : filePath; +} + +/** Compact a skill path for console diagnostics. */ +export function compactSkillPath(filePath: string): string { + return compactHomePath(filePath, resolveCompactHomePrefixes()); +} diff --git a/src/skills/loading/skill-root-discovery.test.ts b/src/skills/loading/skill-root-discovery.test.ts new file mode 100644 index 000000000000..b6fdd4d29dde --- /dev/null +++ b/src/skills/loading/skill-root-discovery.test.ts @@ -0,0 +1,744 @@ +// Skill root discovery tests cover bounded recursive scanning and nested repo-style roots. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; +import { loggingState } from "../../logging/state.js"; +import { writeSkill } from "../test-support/e2e-test-helpers.js"; +import { loadWorkspaceSkills } from "./workspace-skill-loader.js"; + +vi.mock("./plugin-skills.js", () => ({ + resolvePluginSkillDirs: () => [], +})); + +let tempRoot = ""; +let workspaceCaseIndex = 0; + +async function createTempWorkspaceDir() { + const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); + await fs.mkdir(workspaceDir, { recursive: true }); + return workspaceDir; +} + +function captureWarningLogger() { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + return warn; +} + +function collectMatching(items: readonly T[], predicate: (item: T) => boolean): T[] { + const matches: T[] = []; + for (const item of items) { + if (predicate(item)) { + matches.push(item); + } + } + return matches; +} + +function loadTestWorkspaceSkills( + workspaceDir: string, + opts?: Parameters[1], +) { + return loadWorkspaceSkills(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: "", + pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"), + ...opts, + }); +} + +beforeAll(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-discovery-")); +}); + +afterEach(() => { + setLoggerOverride(null); + loggingState.rawConsole = null; + resetLogger(); +}); + +afterAll(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +describe("discoverSkillCandidates", () => { + it("discovers SKILL.md two levels deep under a grouping subfolder", async () => { + const workspaceDir = await createTempWorkspaceDir(); + // Grouped layout: skills/group/skill/SKILL.md (no SKILL.md at skills/group/). + await writeSkill({ + dir: path.join(workspaceDir, "skills", "group", "nested-skill"), + name: "nested-skill", + description: "Nested under a group folder", + }); + + const entries = loadTestWorkspaceSkills(workspaceDir); + const names = entries.map((entry) => entry.skill.name); + expect(names).toContain("nested-skill"); + }); + + it("keeps loading direct skills (skills/skill/SKILL.md) unchanged", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "direct-skill"), + name: "direct-skill", + description: "Direct skill at first level", + }); + // Sibling group with a deeper skill. + await writeSkill({ + dir: path.join(workspaceDir, "skills", "group", "grouped-skill"), + name: "grouped-skill", + description: "Skill nested under a group", + }); + + const names = loadTestWorkspaceSkills(workspaceDir).map((entry) => entry.skill.name); + expect(names).toContain("direct-skill"); + expect(names).toContain("grouped-skill"); + }); + + it("does not count invalid grouped candidates against the loaded skill cap", async () => { + const workspaceDir = await createTempWorkspaceDir(); + for (const nestedName of ["a", "b"]) { + const invalidDir = path.join(workspaceDir, "skills", "00-group", nestedName); + await fs.mkdir(invalidDir, { recursive: true }); + await fs.writeFile( + path.join(invalidDir, "SKILL.md"), + `---\nname: ${nestedName}\n---\n\n# Invalid\n`, + "utf-8", + ); + } + await writeSkill({ + dir: path.join(workspaceDir, "skills", "01-valid"), + name: "valid-skill", + description: "Valid sibling after invalid grouped candidates", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + limits: { + maxCandidatesPerRoot: 10, + maxSkillsLoadedPerSource: 1, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toEqual(["valid-skill"]); + }); + + it("loads earlier grouped skills before later direct siblings hit the source cap", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "00-group", "grouped"), + name: "grouped-skill", + description: "Grouped skill before direct siblings", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "01-direct"), + name: "direct-skill", + description: "Direct sibling after grouped skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + limits: { + maxCandidatesPerRoot: 10, + maxSkillsLoadedPerSource: 1, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toEqual(["grouped-skill"]); + }); + + it("keeps later grouped siblings discoverable when an earlier group is noisy", async () => { + const workspaceDir = await createTempWorkspaceDir(); + async function createNoisyTree(dir: string, depth: number): Promise { + if (depth === 0) { + return; + } + for (const name of ["00-a", "01-b"]) { + const childDir = path.join(dir, name); + await fs.mkdir(childDir, { recursive: true }); + await createNoisyTree(childDir, depth - 1); + } + } + await createNoisyTree(path.join(workspaceDir, "skills", "00-noisy"), 6); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "01-later", "later-skill"), + name: "later-skill", + description: "Grouped sibling after a noisy tree", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + limits: { + maxCandidatesPerRoot: 2, + maxSkillsLoadedPerSource: 10, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("later-skill"); + }); + + it("discovers deeply nested SKILL.md files within the Codex-compatible depth", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "a", "b", "c"), + name: "deep-skill", + description: "Discovered through grouped folders", + }); + + const names = loadTestWorkspaceSkills(workspaceDir).map((entry) => entry.skill.name); + expect(names).toContain("deep-skill"); + }); + + it("discovers deeply nested skills in configured roots named skills", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const parentDir = await createTempWorkspaceDir(); + const skillsDir = path.join(parentDir, "skills"); + await writeSkill({ + dir: path.join(skillsDir, "d0", "d1", "d2", "d3", "d4", "d5"), + name: "configured-deep-skill", + description: "Depth 6 from configured skills root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillsDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("configured-deep-skill"); + }); + + it("uses the nested skills folder as the depth root for repo-style extra dirs", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(repoDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), + name: "repo-depth-skill", + description: "Depth 6 from nested skills root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("repo-depth-skill"); + }); + + it("ignores invalid outside candidates when resolving repo-style extra dirs", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + await fs.mkdir(path.join(repoDir, "examples", "bad"), { recursive: true }); + await fs.writeFile(path.join(repoDir, "examples", "bad", "SKILL.md"), "---\nname: bad\n---\n"); + await writeSkill({ + dir: path.join(repoDir, "skills", "group", "valid"), + name: "repo-nested-skill", + description: "Valid nested repo skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("repo-nested-skill"); + expect(names).not.toContain("bad"); + }); + + it("ignores invalid root SKILL.md files when resolving repo-style extra dirs", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + await fs.writeFile(path.join(repoDir, "SKILL.md"), "---\nname: bad\n---\n"); + await writeSkill({ + dir: path.join(repoDir, "examples", "valid"), + name: "outside-valid-skill", + description: "Valid outside repo skill", + }); + await writeSkill({ + dir: path.join(repoDir, "skills", "group", "valid"), + name: "repo-nested-skill", + description: "Valid nested repo skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("repo-nested-skill"); + expect(names).not.toContain("outside-valid-skill"); + expect(names).not.toContain("bad"); + }); + + it("treats invalid outside SKILL.md files as terminal during repo-root detection", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + await fs.mkdir(path.join(repoDir, "examples", "bad", "child"), { recursive: true }); + await fs.writeFile(path.join(repoDir, "examples", "bad", "SKILL.md"), "---\nname: bad\n---\n"); + await writeSkill({ + dir: path.join(repoDir, "examples", "bad", "child"), + name: "outside-child", + description: "Valid child hidden behind invalid terminal parent", + }); + await writeSkill({ + dir: path.join(repoDir, "skills", "group", "valid"), + name: "repo-nested-skill", + description: "Valid nested repo skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("repo-nested-skill"); + expect(names).not.toContain("outside-child"); + }); + + it("keeps a configured direct skill root even when it has nested skill fixtures", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: skillDir, + name: "direct-root", + description: "Configured direct skill root", + }); + await writeSkill({ + dir: path.join(skillDir, "skills", "examples", "fixture"), + name: "fixture-skill", + description: "Nested fixture skill should not replace the root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("direct-root"); + expect(names).not.toContain("fixture-skill"); + }); + + it("does not re-root extra dirs from ignored nested skill files", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(repoDir, "valid"), + name: "valid-root-skill", + description: "Direct child skill under configured root", + }); + await writeSkill({ + dir: path.join(repoDir, "skills", "node_modules", "pkg"), + name: "ignored-package-skill", + description: "Ignored nested dependency fixture", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("valid-root-skill"); + expect(names).not.toContain("ignored-package-skill"); + }); + + it("keeps direct child skills when a configured root also has a skills child", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillRootDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(skillRootDir, "valid"), + name: "valid-root-skill", + description: "Direct child skill under configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "skills", "examples", "fixture"), + name: "fixture-skill", + description: "Nested fixture should not replace the configured root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillRootDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("valid-root-skill"); + expect(names).toContain("fixture-skill"); + }); + + it("keeps nested skills when top-level candidate cap is filled by direct skills", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillRootDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(skillRootDir, "00-valid"), + name: "valid-root-skill", + description: "Direct child skill under configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "skills", "examples", "fixture"), + name: "fixture-skill", + description: "Nested fixture should still be scanned", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillRootDir] }, + limits: { + maxCandidatesPerRoot: 1, + maxSkillsLoadedPerSource: 10, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("valid-root-skill"); + expect(names).toContain("fixture-skill"); + }); + + it("keeps nested skills depth when a configured root also has direct skills", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillRootDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(skillRootDir, "valid"), + name: "valid-root-skill", + description: "Direct child skill under configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), + name: "deep-nested-skill", + description: "Depth 6 from nested skills root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillRootDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("valid-root-skill"); + expect(names).toContain("deep-nested-skill"); + }); + + it("keeps configured root grouping outside skills within watcher depth", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillRootDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(skillRootDir, "group", "within-depth"), + name: "within-depth", + description: "Depth 2 from configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "group", "d1", "too-deep"), + name: "too-deep", + description: "Depth 3 from configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), + name: "deep-nested-skill", + description: "Depth 6 from nested skills root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillRootDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("within-depth"); + expect(names).toContain("deep-nested-skill"); + expect(names).not.toContain("too-deep"); + }); + + it("keeps grouped child skills when a configured root also has a skills child", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillRootDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(skillRootDir, "group", "valid"), + name: "valid-grouped-skill", + description: "Grouped child skill under configured root", + }); + await writeSkill({ + dir: path.join(skillRootDir, "skills", "examples", "fixture"), + name: "fixture-skill", + description: "Nested fixture should not replace the configured root", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [skillRootDir] }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("valid-grouped-skill"); + expect(names).toContain("fixture-skill"); + }); + + it("does not descend beyond the bounded grouped skill depth", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), + name: "within-depth", + description: "Depth 6 loads", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "e0", "e1", "e2", "e3", "e4", "e5", "e6"), + name: "too-deep", + description: "Depth 7 does not load", + }); + + const names = loadTestWorkspaceSkills(workspaceDir).map((entry) => entry.skill.name); + expect(names).toContain("within-depth"); + expect(names).not.toContain("too-deep"); + }); + + it("does not fall through to child skills when an immediate SKILL.md is invalid", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const parentDir = path.join(workspaceDir, "skills", "group", "parent"); + await fs.mkdir(parentDir, { recursive: true }); + await fs.writeFile(path.join(parentDir, "SKILL.md"), "---\nname: parent\n---\n", "utf-8"); + await writeSkill({ + dir: path.join(parentDir, "child"), + name: "too-deep", + description: "Should not be discovered through invalid parent fallback", + }); + + const names = loadTestWorkspaceSkills(workspaceDir).map((entry) => entry.skill.name); + expect(names).not.toContain("too-deep"); + }); + + it("treats an immediate SKILL.md as terminal and does not descend", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "group"), + name: "group", + description: "Direct skill at the group level", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "group", "inner"), + name: "inner", + description: "Should be ignored when parent is itself a skill", + }); + + const names = loadTestWorkspaceSkills(workspaceDir).map((entry) => entry.skill.name); + expect(names).toContain("group"); + expect(names).not.toContain("inner"); + }); + + it("warns and caps discovery in large grouping subfolders", async () => { + const workspaceDir = await createTempWorkspaceDir(); + for (let i = 0; i < 3; i += 1) { + const name = `nested-skill-${i}`; + await writeSkill({ + dir: path.join(workspaceDir, "skills", "group", name), + name, + description: `Nested skill ${i}`, + }); + } + const warn = captureWarningLogger(); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + limits: { + maxCandidatesPerRoot: 2, + maxSkillsLoadedPerSource: 10, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect( + names.reduce((count, name) => count + (name.startsWith("nested-skill-") ? 1 : 0), 0), + ).toBe(2); + expect( + warn.mock.calls + .map(([line]) => String(line)) + .some((line) => + line.includes("Nested skills directory has many entries, truncating discovery."), + ), + ).toBe(true); + }); + + it("does not spend nested candidate budget on ignored raw entries", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const groupDir = path.join(workspaceDir, "skills", "group"); + await fs.mkdir(groupDir, { recursive: true }); + for (let i = 0; i < 50; i += 1) { + await fs.writeFile(path.join(groupDir, `ignored-${String(i).padStart(2, "0")}.txt`), ""); + } + for (const name of ["valid-a", "valid-b", "valid-c"]) { + await writeSkill({ + dir: path.join(groupDir, name), + name, + description: `${name} nested under a group`, + }); + } + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + limits: { + maxCandidatesPerRoot: 2, + maxSkillsLoadedPerSource: 10, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(collectMatching(names, (name) => name.startsWith("valid-"))).toEqual([ + "valid-a", + "valid-b", + ]); + }); + + it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + for (let i = 0; i < 8; i += 1) { + const name = `repo-skill-${String(i).padStart(2, "0")}`; + await writeSkill({ + dir: path.join(repoDir, "skills", name), + name, + description: `Desc ${i}`, + }); + } + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + limits: { + maxCandidatesPerRoot: 5, + maxSkillsLoadedPerSource: 5, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toStrictEqual([ + "repo-skill-00", + "repo-skill-01", + "repo-skill-02", + "repo-skill-03", + "repo-skill-04", + ]); + }); + + it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "small-skill"), + name: "small-skill", + description: "Small", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "big-skill"), + name: "big-skill", + description: "Big", + body: "x".repeat(5_000), + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { skills: { limits: { maxSkillFileBytes: 1000 } } }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("small-skill"); + expect(names).not.toContain("big-skill"); + }); + + it("detects nested skills roots beyond the first 25 entries", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const repoDir = await createTempWorkspaceDir(); + for (let i = 0; i < 30; i += 1) { + await fs.mkdir(path.join(repoDir, "skills", `entry-${String(i).padStart(2, "0")}`), { + recursive: true, + }); + } + await writeSkill({ + dir: path.join(repoDir, "skills", "entry-29"), + name: "late-skill", + description: "Nested skill discovered late", + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [repoDir] }, + limits: { + maxCandidatesPerRoot: 30, + maxSkillsLoadedPerSource: 30, + }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).toContain("late-skill"); + }); + + it("enforces maxSkillFileBytes for root-level SKILL.md", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const rootSkillDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: rootSkillDir, + name: "root-big-skill", + description: "Big", + body: "x".repeat(5_000), + }); + + const names = loadTestWorkspaceSkills(workspaceDir, { + config: { + skills: { + load: { extraDirs: [rootSkillDir] }, + limits: { maxSkillFileBytes: 1000 }, + }, + }, + }).map((entry) => entry.skill.name); + + expect(names).not.toContain("root-big-skill"); + }); +}); diff --git a/src/skills/loading/skill-root-discovery.ts b/src/skills/loading/skill-root-discovery.ts new file mode 100644 index 000000000000..23e41648c065 --- /dev/null +++ b/src/skills/loading/skill-root-discovery.ts @@ -0,0 +1,712 @@ +// Skill root discovery validates bounded filesystem candidates before loading skill records. +import fs from "node:fs"; +import path from "node:path"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { walkDirectorySync } from "../../infra/fs-safe.js"; +import { isPathInside } from "../../infra/path-guards.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { readSkillFrontmatterSafe } from "./local-loader.js"; +import { compactSkillPath } from "./skill-paths.js"; +import { findContainingAllowedSkillSymlinkTarget, tryRealpath } from "./symlink-targets.js"; + +const skillsLogger = createSubsystemLogger("skills"); + +const DEFAULT_MAX_CANDIDATES_PER_ROOT = 300; +const DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE = 200; +const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000; +const DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN = 1_000; +const DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN = 10_000; +// Match Codex's bounded recursive skills discovery without letting broad +// workspace roots turn into unbounded filesystem walks. +const MAX_GROUPED_SKILL_SCAN_DEPTH = 6; +const MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH = 2; + +export type ResolvedSkillDiscoveryLimits = { + maxCandidatesPerRoot: number; + maxSkillsLoadedPerSource: number; + maxSkillFileBytes: number; +}; + +export type CandidateSkillDir = { + skillDir: string; + skillDirRealPath: string; + name: string; + skillMdRealPath: string; +}; + +type DiscoveredSkillCandidates = { + candidates: CandidateSkillDir[]; + rootIsSkill: boolean; +}; + +type ChildDirectoryScan = { + dirs: string[]; + scannedEntryCount: number; + truncated: boolean; +}; + +type SkillDiscoveryBudget = { + remainingDirectoryScans: number; + remainingRawEntries: number; + truncated: boolean; +}; + +export function resolveSkillDiscoveryLimits(config?: OpenClawConfig): ResolvedSkillDiscoveryLimits { + const limits = config?.skills?.limits; + return { + maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT, + maxSkillsLoadedPerSource: + limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE, + maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES, + }; +} + +function listChildDirectories( + dir: string, + opts?: { + followSymlinks?: boolean; + maxCandidateDirs?: number; + maxRawEntriesToScan?: number; + }, +): ChildDirectoryScan { + const maxRawEntriesToScan = + opts?.maxRawEntriesToScan === undefined + ? resolveRawEntryScanLimit(opts?.maxCandidateDirs) + : Math.max(0, opts.maxRawEntriesToScan); + const scan = walkDirectorySync(dir, { + maxDepth: 1, + maxEntries: maxRawEntriesToScan, + symlinks: opts?.followSymlinks === false ? "skip" : "follow", + include: (entry) => + entry.kind === "directory" && !entry.name.startsWith(".") && entry.name !== "node_modules", + }); + if (scan.scannedEntryCount === 0 && scan.entries.length === 0) { + return { dirs: [], scannedEntryCount: 0, truncated: false }; + } + return { + dirs: scan.entries.map((entry) => entry.name), + scannedEntryCount: scan.scannedEntryCount, + truncated: scan.truncated, + }; +} + +function resolveRawEntryScanLimit(maxCandidateDirs: number | undefined): number { + if (maxCandidateDirs === undefined) { + return Number.POSITIVE_INFINITY; + } + const normalized = Math.max(0, maxCandidateDirs); + if (normalized === 0) { + return 0; + } + return Math.min( + DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN, + Math.max(DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN, normalized * 10), + ); +} + +function createSkillDiscoveryBudget(maxCandidateDirs: number): SkillDiscoveryBudget { + const normalized = Math.max(0, maxCandidateDirs); + return { + remainingDirectoryScans: normalized * MAX_GROUPED_SKILL_SCAN_DEPTH, + remainingRawEntries: resolveRawEntryScanLimit(normalized) * (normalized + 1), + truncated: false, + }; +} + +function listBudgetedChildDirectories( + dir: string, + budget: SkillDiscoveryBudget, + opts: { followSymlinks?: boolean; maxCandidateDirs: number }, +): ChildDirectoryScan { + if (budget.remainingDirectoryScans <= 0 || budget.remainingRawEntries <= 0) { + budget.truncated = true; + return { dirs: [], scannedEntryCount: 0, truncated: false }; + } + + budget.remainingDirectoryScans -= 1; + const maxRawEntriesToScan = Math.min( + resolveRawEntryScanLimit(opts.maxCandidateDirs), + budget.remainingRawEntries, + ); + const scan = listChildDirectories(dir, { + followSymlinks: opts.followSymlinks, + maxCandidateDirs: opts.maxCandidateDirs, + maxRawEntriesToScan, + }); + budget.remainingRawEntries = Math.max(0, budget.remainingRawEntries - scan.scannedEntryCount); + budget.truncated ||= scan.truncated; + return scan; +} + +function containsDiscoverableSkill( + dir: string, + opts: { + maxCandidateDirs: number; + maxSkillFileBytes?: number; + skipTopLevelDirName?: string; + }, +): boolean { + const discoveryBudget = createSkillDiscoveryBudget(opts.maxCandidateDirs); + const queue: Array<{ dir: string; depth: number }> = [{ dir, depth: 0 }]; + for (const candidate of queue) { + if (!candidate) { + continue; + } + if (candidate.depth > 0 && fs.existsSync(path.join(candidate.dir, "SKILL.md"))) { + if (hasLoadableSkillFrontmatter(dir, candidate.dir, opts.maxSkillFileBytes)) { + return true; + } + continue; + } + if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) { + continue; + } + if ( + hasCandidateSymlinkChild( + candidate.dir, + candidate.depth === 0 ? opts.skipTopLevelDirName : undefined, + resolveRawEntryScanLimit(opts.maxCandidateDirs), + ) + ) { + return true; + } + const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, { + followSymlinks: false, + maxCandidateDirs: opts.maxCandidateDirs, + }).dirs; + for (const childDir of childDirs.toSorted().slice(0, opts.maxCandidateDirs)) { + if (candidate.depth === 0 && childDir === opts.skipTopLevelDirName) { + continue; + } + queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 }); + } + } + return false; +} + +function hasCandidateSymlinkChild( + dir: string, + skipName: string | undefined, + maxEntriesToScan: number, +): boolean { + const maxEntries = Math.max(0, maxEntriesToScan); + if (maxEntries === 0) { + return false; + } + let handle: fs.Dir | undefined; + try { + handle = fs.opendirSync(dir); + for (let scanned = 0; scanned < maxEntries; scanned += 1) { + const entry = handle.readSync(); + if (!entry) { + break; + } + if (entry.name === skipName || entry.name.startsWith(".") || entry.name === "node_modules") { + continue; + } + if (entry.isSymbolicLink()) { + return true; + } + } + } catch { + return false; + } finally { + handle?.closeSync(); + } + return false; +} + +function hasLoadableSkillFrontmatter( + rootDir: string, + skillDir: string, + maxSkillFileBytes?: number, +): boolean { + const frontmatter = readSkillFrontmatterSafe({ + rootDir, + filePath: path.join(skillDir, "SKILL.md"), + maxBytes: maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES, + }); + const fallbackName = path.basename(skillDir).trim(); + const name = frontmatter?.name?.trim() || fallbackName; + return Boolean(name) && Boolean(frontmatter?.description?.trim()); +} + +function isSymlinkPath(filePath: string): boolean { + try { + return fs.lstatSync(filePath).isSymbolicLink(); + } catch { + return false; + } +} + +function buildEscapedSkillPathReason(params: { source: string; candidatePath: string }): { + reason: string; + consoleHint: string; +} { + const candidateIsSymlink = isSymlinkPath(params.candidatePath); + if (params.source === "openclaw-bundled" && candidateIsSymlink) { + return { + reason: "bundled-symlink-escape", + consoleHint: + "reason=bundled-symlink-escape hint=likely-stray-local-symlink-or-checkout-mutation", + }; + } + if (candidateIsSymlink) { + return { reason: "symlink-escape", consoleHint: "reason=symlink-escape" }; + } + if (params.source === "openclaw-bundled") { + return { + reason: "bundled-root-escape", + consoleHint: + "reason=bundled-root-escape hint=likely-stray-local-symlink-or-checkout-mutation", + }; + } + return { reason: "path-escape", consoleHint: "reason=path-escape" }; +} + +function warnEscapedSkillPath(params: { + source: string; + rootDir: string; + rootRealPath: string; + candidatePath: string; + candidateRealPath: string; +}) { + const compactRootDir = compactSkillPath(params.rootDir); + const compactRootRealPath = compactSkillPath(params.rootRealPath); + const compactCandidatePath = compactSkillPath(params.candidatePath); + const compactCandidateRealPath = compactSkillPath(params.candidateRealPath); + const rootResolved = + path.resolve(params.rootDir) === params.rootRealPath + ? "" + : ` rootResolved=${compactRootRealPath}`; + const escapeReason = buildEscapedSkillPathReason({ + source: params.source, + candidatePath: params.candidatePath, + }); + skillsLogger.warn("Skipping escaped skill path outside its configured root.", { + source: params.source, + rootDir: params.rootDir, + rootRealPath: params.rootRealPath, + path: params.candidatePath, + realPath: params.candidateRealPath, + reason: escapeReason.reason, + consoleMessage: + `Skipping escaped skill path outside its configured root: ` + + `source=${params.source} root=${compactRootDir}${rootResolved} ` + + `${escapeReason.consoleHint} requested=${compactCandidatePath} ` + + `resolved=${compactCandidateRealPath}`, + }); +} + +function resolveContainedSkillPath(params: { + source: string; + rootDir: string; + rootRealPath: string; + candidatePath: string; + allowedSymlinkTargetRealPaths?: readonly string[]; +}): string | null { + const candidateRealPath = tryRealpath(params.candidatePath); + if (!candidateRealPath) { + return null; + } + if ( + isPathInside(params.rootRealPath, candidateRealPath) || + findContainingAllowedSkillSymlinkTarget( + params.allowedSymlinkTargetRealPaths ?? [], + candidateRealPath, + ) !== null + ) { + return candidateRealPath; + } + warnEscapedSkillPath({ + source: params.source, + rootDir: params.rootDir, + rootRealPath: params.rootRealPath, + candidatePath: path.resolve(params.candidatePath), + candidateRealPath, + }); + return null; +} + +function resolveNestedSkillsRoot( + dir: string, + opts?: { maxEntriesToScan?: number; maxSkillFileBytes?: number }, +): { baseDir: string; note?: string } { + if (hasLoadableSkillFrontmatter(dir, dir, opts?.maxSkillFileBytes)) { + return { baseDir: dir }; + } + const rootSkillMdExists = fs.existsSync(path.join(dir, "SKILL.md")); + const nested = path.join(dir, "skills"); + try { + if (!fs.existsSync(nested) || !fs.statSync(nested).isDirectory()) { + return { baseDir: dir }; + } + } catch { + return { baseDir: dir }; + } + + const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100); + if ( + !rootSkillMdExists && + containsDiscoverableSkill(dir, { + maxCandidateDirs: scanLimit, + maxSkillFileBytes: opts?.maxSkillFileBytes, + skipTopLevelDirName: "skills", + }) + ) { + return { baseDir: dir }; + } + + const discoveryBudget = createSkillDiscoveryBudget(scanLimit); + const queue: Array<{ dir: string; depth: number }> = [{ dir: nested, depth: 0 }]; + for (const candidate of queue) { + if (!candidate) { + continue; + } + if (hasLoadableSkillFrontmatter(nested, candidate.dir, opts?.maxSkillFileBytes)) { + return { baseDir: nested, note: `Detected nested skills root at ${nested}` }; + } + if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) { + continue; + } + const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, { + followSymlinks: false, + maxCandidateDirs: scanLimit, + }).dirs; + for (const childDir of childDirs.toSorted().slice(0, scanLimit)) { + queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 }); + } + } + return { baseDir: dir }; +} + +function shouldEnforceConfiguredSkillRootContainment(source: string): boolean { + return source !== "openclaw-managed" && source !== "agents-skills-personal"; +} + +function shouldUseConfiguredSymlinkTargets(source: string): boolean { + return ( + source === "openclaw-workspace" || + source === "openclaw-extra" || + source === "agents-skills-project" + ); +} + +function resolveSkillRootCandidatePath(params: { + source: string; + rootDir: string; + rootRealPath: string; + candidatePath: string; + allowedSymlinkTargetRealPaths: readonly string[]; +}): string | null { + if (!shouldEnforceConfiguredSkillRootContainment(params.source)) { + return tryRealpath(params.candidatePath); + } + return resolveContainedSkillPath({ + source: params.source, + rootDir: params.rootDir, + rootRealPath: params.rootRealPath, + candidatePath: params.candidatePath, + allowedSymlinkTargetRealPaths: shouldUseConfiguredSymlinkTargets(params.source) + ? params.allowedSymlinkTargetRealPaths + : [], + }); +} + +export function canonicalSkillDirForSource( + source: string, + skillDirRealPath: string, +): string | undefined { + return shouldEnforceConfiguredSkillRootContainment(source) ? undefined : skillDirRealPath; +} + +function resolveSkillFilePath(params: { + source: string; + skillDir: string; + skillDirRealPath: string; + candidatePath: string; +}): string | null { + return resolveContainedSkillPath({ + source: params.source, + rootDir: params.skillDir, + rootRealPath: params.skillDirRealPath, + candidatePath: params.candidatePath, + }); +} + +/** Discover validated skill directory candidates below one configured source root. */ +export function discoverSkillCandidates(params: { + dir: string; + source: string; + limits: ResolvedSkillDiscoveryLimits; + allowedSymlinkTargetRealPaths: readonly string[]; +}): DiscoveredSkillCandidates { + const rootDir = path.resolve(params.dir); + if (!fs.existsSync(rootDir)) { + return { candidates: [], rootIsSkill: false }; + } + const rootRealPath = tryRealpath(rootDir) ?? rootDir; + const resolved = resolveNestedSkillsRoot(params.dir, { + maxEntriesToScan: params.limits.maxCandidatesPerRoot, + maxSkillFileBytes: params.limits.maxSkillFileBytes, + }); + const baseDir = resolved.baseDir; + const baseDirRealPath = resolveSkillRootCandidatePath({ + source: params.source, + rootDir, + rootRealPath, + candidatePath: baseDir, + allowedSymlinkTargetRealPaths: params.allowedSymlinkTargetRealPaths, + }); + if (!baseDirRealPath) { + return { candidates: [], rootIsSkill: false }; + } + + const rootSkillMd = path.join(baseDir, "SKILL.md"); + if (fs.existsSync(rootSkillMd)) { + const rootSkillRealPath = resolveSkillFilePath({ + source: params.source, + skillDir: baseDir, + skillDirRealPath: baseDirRealPath, + candidatePath: rootSkillMd, + }); + return { + candidates: rootSkillRealPath + ? [ + { + skillDir: baseDir, + skillDirRealPath: baseDirRealPath, + name: path.basename(baseDir), + skillMdRealPath: rootSkillRealPath, + }, + ] + : [], + rootIsSkill: true, + }; + } + + const maxCandidatesPerRoot = Math.max(0, params.limits.maxCandidatesPerRoot); + const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource); + const nestedSkillsRootPath = path.resolve(baseDir, "skills"); + const baseDirIsNestedSkillsRoot = path.resolve(baseDir) === path.resolve(rootDir, "skills"); + const baseDirLooksLikeSkillsRoot = path.basename(baseDir) === "skills"; + const discoveryBudget = createSkillDiscoveryBudget(maxCandidatesPerRoot); + const childDirScan = listBudgetedChildDirectories(baseDir, discoveryBudget, { + maxCandidateDirs: maxCandidatesPerRoot, + }); + const childDirs = childDirScan.dirs; + const sortedChildDirs = childDirs.toSorted(); + const limitedChildren = + maxSkillsLoadedPerSource === 0 ? [] : sortedChildDirs.slice(0, maxCandidatesPerRoot); + if ( + maxSkillsLoadedPerSource > 0 && + sortedChildDirs.includes("skills") && + !limitedChildren.includes("skills") + ) { + limitedChildren.push("skills"); + } + + if (childDirScan.truncated) { + skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", { + dir: params.dir, + baseDir, + childDirCount: childDirs.length, + scannedEntryCount: childDirScan.scannedEntryCount, + maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot), + maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot, + maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource, + }); + } else if (childDirs.length > maxCandidatesPerRoot) { + skillsLogger.warn("Skills root has many entries, truncating discovery.", { + dir: params.dir, + baseDir, + childDirCount: childDirs.length, + maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot, + maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource, + }); + } + + const skillCandidates: CandidateSkillDir[] = []; + const scanQueue: Array<{ skillDir: string; name: string; depth: number }> = limitedChildren.map( + (name) => ({ + skillDir: path.join(baseDir, name), + name, + depth: name === "skills" && !fs.existsSync(path.join(baseDir, name, "SKILL.md")) ? 0 : 1, + }), + ); + + for (const candidate of scanQueue) { + if (!candidate) { + continue; + } + const skillDirRealPath = resolveSkillRootCandidatePath({ + source: params.source, + rootDir, + rootRealPath: baseDirRealPath, + candidatePath: candidate.skillDir, + allowedSymlinkTargetRealPaths: params.allowedSymlinkTargetRealPaths, + }); + if (!skillDirRealPath) { + continue; + } + + const skillMd = path.join(candidate.skillDir, "SKILL.md"); + if (fs.existsSync(skillMd)) { + const skillMdRealPath = resolveSkillFilePath({ + source: params.source, + skillDir: candidate.skillDir, + skillDirRealPath, + candidatePath: skillMd, + }); + if (skillMdRealPath) { + skillCandidates.push({ + skillDir: candidate.skillDir, + skillDirRealPath, + name: candidate.name, + skillMdRealPath, + }); + } + continue; + } + + const candidatePath = path.resolve(candidate.skillDir); + const maxGroupedDepth = + params.source === "openclaw-extra" && + !baseDirIsNestedSkillsRoot && + !baseDirLooksLikeSkillsRoot && + candidatePath !== nestedSkillsRootPath && + !isPathInside(nestedSkillsRootPath, candidatePath) + ? MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH + : MAX_GROUPED_SKILL_SCAN_DEPTH; + if (candidate.depth >= maxGroupedDepth) { + continue; + } + + const nestedChildScan = listBudgetedChildDirectories(candidate.skillDir, discoveryBudget, { + maxCandidateDirs: maxCandidatesPerRoot, + }); + const nestedChildren = nestedChildScan.dirs; + if (nestedChildScan.truncated) { + skillsLogger.warn("Nested skills directory looks suspiciously large, truncating discovery.", { + dir: params.dir, + baseDir, + nestedDir: candidate.skillDir, + nestedChildDirCount: nestedChildren.length, + scannedEntryCount: nestedChildScan.scannedEntryCount, + maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot), + maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot, + maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource, + maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, + }); + } else if (nestedChildren.length > maxCandidatesPerRoot) { + skillsLogger.warn("Nested skills directory has many entries, truncating discovery.", { + dir: params.dir, + baseDir, + nestedDir: candidate.skillDir, + nestedChildDirCount: nestedChildren.length, + maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot, + maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource, + maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, + }); + } + + for (const nestedName of nestedChildren.toSorted().slice(0, maxCandidatesPerRoot)) { + scanQueue.push({ + skillDir: path.join(candidate.skillDir, nestedName), + name: `${candidate.name}/${nestedName}`, + depth: candidate.depth + 1, + }); + } + } + + if (discoveryBudget.truncated) { + skillsLogger.warn("Skills root hit recursive discovery budget, truncating discovery.", { + dir: params.dir, + baseDir, + maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot, + maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource, + maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, + }); + } + + return { + candidates: skillCandidates.toSorted((a, b) => a.name.localeCompare(b.name)), + rootIsSkill: false, + }; +} + +function resolvePluginSkillRootRealPaths(pluginSkillDirs: readonly string[]): string[] { + return uniqueStrings( + pluginSkillDirs.map((dir) => tryRealpath(dir)).filter((dir): dir is string => Boolean(dir)), + ); +} + +/** Discover validated generated plugin-skill symlink candidates. */ +export function discoverPluginSkills(params: { + pluginSkillsDir: string; + pluginSkillDirs: readonly string[]; + source: string; + limits: ResolvedSkillDiscoveryLimits; +}): CandidateSkillDir[] { + const allowedRootRealPaths = resolvePluginSkillRootRealPaths(params.pluginSkillDirs); + if (allowedRootRealPaths.length === 0) { + return []; + } + + const rootDir = path.resolve(params.pluginSkillsDir); + if (!fs.existsSync(rootDir)) { + return []; + } + const rootRealPath = tryRealpath(rootDir) ?? rootDir; + const maxCandidatesPerRoot = Math.max(0, params.limits.maxCandidatesPerRoot); + const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource); + const childDirScan = listChildDirectories(rootDir, { + maxCandidateDirs: maxCandidatesPerRoot, + }); + const childDirs = + maxSkillsLoadedPerSource === 0 + ? [] + : childDirScan.dirs.toSorted().slice(0, maxCandidatesPerRoot); + const candidates: CandidateSkillDir[] = []; + + for (const name of childDirs) { + const skillDir = path.join(rootDir, name); + if (!isSymlinkPath(skillDir)) { + continue; + } + const skillDirRealPath = tryRealpath(skillDir); + if ( + !skillDirRealPath || + findContainingAllowedSkillSymlinkTarget(allowedRootRealPaths, skillDirRealPath) === null + ) { + if (skillDirRealPath) { + warnEscapedSkillPath({ + source: params.source, + rootDir, + rootRealPath, + candidatePath: path.resolve(skillDir), + candidateRealPath: skillDirRealPath, + }); + } + continue; + } + + const skillMd = path.join(skillDir, "SKILL.md"); + let skillMdStat: fs.Stats; + try { + skillMdStat = fs.lstatSync(skillMd); + } catch { + continue; + } + if (!skillMdStat.isFile() || skillMdStat.isSymbolicLink()) { + continue; + } + const skillMdRealPath = tryRealpath(skillMd); + if (!skillMdRealPath || !isPathInside(skillDirRealPath, skillMdRealPath)) { + continue; + } + candidates.push({ skillDir, skillDirRealPath, name, skillMdRealPath }); + } + return candidates; +} diff --git a/src/skills/loading/skills.test.ts b/src/skills/loading/skills.test.ts index 1f5cb2157c6b..c9c5e643f26f 100644 --- a/src/skills/loading/skills.test.ts +++ b/src/skills/loading/skills.test.ts @@ -26,7 +26,7 @@ import { } from "../test-support/home-env.test-support.js"; import type { SkillEntry, SkillSnapshot } from "../types.js"; import { shouldIncludeSkill } from "./config.js"; -import { buildWorkspaceSkillsPrompt } from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; vi.mock("./plugin-skills.js", () => ({ resolvePluginSkillDirs: () => [], @@ -43,6 +43,10 @@ const resolveTestSkillDirs = (workspaceDir: string) => ({ }); const makeWorkspace = async () => await fixtureSuite.createCaseDir("workspace"); +const buildWorkspaceSkillsPrompt = ( + workspaceDir: string, + opts?: Parameters[1], +): string => buildSkillSnapshot(workspaceDir, opts).prompt; const apiKeyField = ["api", "Key"].join(""); function withWorkspaceHome(workspaceDir: string, cb: () => T): T { diff --git a/src/skills/loading/workspace-bundled-allowlist.test.ts b/src/skills/loading/workspace-bundled-allowlist.test.ts index 76c6a37fc2d1..688b31f22389 100644 --- a/src/skills/loading/workspace-bundled-allowlist.test.ts +++ b/src/skills/loading/workspace-bundled-allowlist.test.ts @@ -5,7 +5,12 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { writeSkill } from "../test-support/e2e-test-helpers.js"; -import { buildWorkspaceSkillsPrompt } from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; + +const buildWorkspaceSkillsPrompt = ( + workspaceDir: string, + opts?: Parameters[1], +): string => buildSkillSnapshot(workspaceDir, opts).prompt; describe("buildWorkspaceSkillsPrompt", () => { it("applies bundled allowlist without affecting workspace skills", async () => { diff --git a/src/skills/loading/workspace-load.test.ts b/src/skills/loading/workspace-load.test.ts deleted file mode 100644 index 466e25c4fcaf..000000000000 --- a/src/skills/loading/workspace-load.test.ts +++ /dev/null @@ -1,1415 +0,0 @@ -// Workspace load tests cover loading skills from workspace directories and manifests. -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; -import { loggingState } from "../../logging/state.js"; -import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; -import { clearCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-state.js"; -import { resolveInstalledPluginIndexPolicyHash } from "../../plugins/installed-plugin-index-policy.js"; -import type { - PluginManifestRecord, - PluginManifestRegistry, -} from "../../plugins/manifest-registry.js"; -import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; -import { writeSkill, writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js"; -import { - restoreMockSkillsHomeEnv, - setMockSkillsHomeEnv, - type SkillsHomeEnvSnapshot, -} from "../test-support/home-env.test-support.js"; -import { writePluginWithSkill } from "../test-support/skill-plugin-fixtures.test-support.js"; -import { readSkillFrontmatterSafe } from "./local-loader.js"; -import { loadWorkspaceSkillEntries } from "./workspace.js"; - -vi.mock("../../plugins/manifest-registry.js", async () => { - const fsLocal = await import("node:fs"); - const pathLocal = await import("node:path"); - return { - loadPluginManifestRegistry: (params: { workspaceDir?: string }) => { - const extensionsRoot = pathLocal.join(params.workspaceDir ?? "", ".openclaw", "extensions"); - const plugins = []; - for (const id of ["open-prose", "browser"]) { - const rootDir = pathLocal.join(extensionsRoot, id); - const manifestPath = pathLocal.join(rootDir, "openclaw.plugin.json"); - if (!fsLocal.existsSync(manifestPath)) { - continue; - } - const manifest = JSON.parse(fsLocal.readFileSync(manifestPath, "utf8")) as { - enabledByDefault?: boolean; - skills?: string[]; - }; - plugins.push({ - id, - origin: id === "browser" ? "bundled" : "workspace", - enabledByDefault: manifest.enabledByDefault, - providers: [], - legacyPluginIds: [], - kind: [], - skills: manifest.skills ?? ["./skills"], - rootDir, - }); - } - return { plugins, diagnostics: [] }; - }, - }; -}); - -let fakeHome = ""; -let envSnapshot: SkillsHomeEnvSnapshot; -let tempRoot = ""; -let workspaceCaseIndex = 0; - -function createWorkspacePluginRegistry(workspaceDir: string): PluginManifestRegistry { - const extensionsRoot = path.join(workspaceDir, ".openclaw", "extensions"); - const plugins: PluginManifestRecord[] = []; - for (const id of ["open-prose", "browser"]) { - const rootDir = path.join(extensionsRoot, id); - const manifestPath = path.join(rootDir, "openclaw.plugin.json"); - if (!fsSync.existsSync(manifestPath)) { - continue; - } - const manifest = JSON.parse(fsSync.readFileSync(manifestPath, "utf8")) as { - id?: string; - enabledByDefault?: boolean; - skills?: string[]; - configSchema?: Record; - }; - plugins.push({ - id: manifest.id ?? id, - origin: id === "browser" ? "bundled" : "workspace", - enabledByDefault: manifest.enabledByDefault, - channels: [], - providers: [], - cliBackends: [], - legacyPluginIds: [], - kind: [], - skills: manifest.skills ?? ["./skills"], - hooks: [], - rootDir, - source: rootDir, - manifestPath, - configSchema: manifest.configSchema, - }); - } - return { plugins, diagnostics: [] }; -} - -function createWorkspacePluginMetadataSnapshot(params: { - workspaceDir: string; - config?: OpenClawConfig; - manifestRegistry: PluginManifestRegistry; -}): PluginMetadataSnapshot { - const policyHash = resolveInstalledPluginIndexPolicyHash(params.config); - const ownerMaps = { - channels: new Map(), - channelConfigs: new Map(), - providers: new Map(), - modelCatalogProviders: new Map(), - cliBackends: new Map(), - setupProviders: new Map(), - commandAliases: new Map(), - contracts: new Map(), - }; - return { - policyHash, - workspaceDir: params.workspaceDir, - index: { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash, - generatedAtMs: 1, - installRecords: {}, - plugins: [], - diagnostics: [], - }, - registryDiagnostics: [], - manifestRegistry: params.manifestRegistry, - plugins: params.manifestRegistry.plugins, - diagnostics: params.manifestRegistry.diagnostics, - byPluginId: new Map(params.manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])), - normalizePluginId: (pluginId) => pluginId, - owners: ownerMaps, - metrics: { - registrySnapshotMs: 0, - manifestRegistryMs: 0, - ownerMapsMs: 0, - totalMs: 0, - indexPluginCount: 0, - manifestPluginCount: params.manifestRegistry.plugins.length, - }, - }; -} - -function setWorkspacePluginMetadataSnapshot(workspaceDir: string, config?: OpenClawConfig): void { - const manifestRegistry = createWorkspacePluginRegistry(workspaceDir); - setCurrentPluginMetadataSnapshot( - createWorkspacePluginMetadataSnapshot({ - workspaceDir, - manifestRegistry, - ...(config === undefined ? {} : { config }), - }), - { - workspaceDir, - ...(config === undefined ? {} : { config }), - }, - ); -} - -function collectMatching(items: readonly T[], predicate: (item: T) => boolean): T[] { - const matches: T[] = []; - for (const item of items) { - if (predicate(item)) { - matches.push(item); - } - } - return matches; -} - -async function expectMissingPath(pathToCheck: string) { - let thrown: unknown; - try { - await fs.lstat(pathToCheck); - } catch (error) { - thrown = error; - } - expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT"); -} - -async function createTempWorkspaceDir() { - const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); - await fs.mkdir(workspaceDir, { recursive: true }); - return workspaceDir; -} - -function captureWarningLogger() { - setLoggerOverride({ level: "silent", consoleLevel: "warn" }); - const warn = vi.fn(); - loggingState.rawConsole = { - log: vi.fn(), - info: vi.fn(), - warn, - error: vi.fn(), - }; - return warn; -} - -function firstWarningLine(warn: ReturnType): string { - const [line] = warn.mock.calls[0] ?? []; - return String(line); -} - -function loadTestWorkspaceSkillEntries( - workspaceDir: string, - opts?: Parameters[1], -) { - setWorkspacePluginMetadataSnapshot(workspaceDir, opts?.config); - return loadWorkspaceSkillEntries(workspaceDir, { - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: "", - pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"), - ...opts, - }); -} - -beforeAll(async () => { - tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-workspace-")); - fakeHome = path.join(tempRoot, "home"); - await fs.mkdir(fakeHome, { recursive: true }); - envSnapshot = setMockSkillsHomeEnv(fakeHome); -}); - -afterEach(async () => { - clearCurrentPluginMetadataSnapshot(); - setLoggerOverride(null); - loggingState.rawConsole = null; - resetLogger(); -}); - -afterAll(async () => { - await restoreMockSkillsHomeEnv(envSnapshot, async () => { - if (tempRoot) { - await fs.rm(tempRoot, { recursive: true, force: true }); - } - }); -}); - -async function setupWorkspaceWithProsePlugin() { - const workspaceDir = await createTempWorkspaceDir(); - const managedDir = path.join(workspaceDir, ".managed"); - const bundledDir = path.join(workspaceDir, ".bundled"); - const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "open-prose"); - - await writePluginWithSkill({ - pluginRoot, - pluginId: "open-prose", - skillId: "prose", - skillDescription: "test", - }); - - return { workspaceDir, managedDir, bundledDir }; -} - -async function createEscapedBundledSkillFixture(params?: { - workspaceDir?: string; - outsideDir?: string; -}) { - const workspaceDir = params?.workspaceDir ?? (await createTempWorkspaceDir()); - const outsideDir = params?.outsideDir ?? (await createTempWorkspaceDir()); - const bundledDir = path.join(workspaceDir, ".bundled"); - const escapedSkillDir = path.join(outsideDir, "outside-bundled-skill"); - await writeSkill({ - dir: escapedSkillDir, - name: "outside-bundled-skill", - description: "Outside bundled", - }); - await fs.mkdir(bundledDir, { recursive: true }); - const requestedPath = path.join(bundledDir, "escaped-bundled-skill"); - await fs.symlink(escapedSkillDir, requestedPath, "dir"); - return { workspaceDir, outsideDir, bundledDir, escapedSkillDir, requestedPath }; -} - -describe("loadWorkspaceSkillEntries", () => { - it("filters plugin-shipped skills through plugin config", async () => { - const { workspaceDir, managedDir } = await setupWorkspaceWithProsePlugin(); - - const enabledEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - plugins: { - entries: { "open-prose": { enabled: true } }, - }, - }, - managedSkillsDir: managedDir, - }); - - expect(enabledEntries.map((entry) => entry.skill.name)).toContain("prose"); - - const blockedEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - plugins: { - allow: ["something-else"], - }, - }, - managedSkillsDir: managedDir, - }); - - expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("prose"); - }); - - it("loads the browser plugin automation skill when the bundled plugin is enabled", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const managedDir = path.join(workspaceDir, ".managed"); - const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "browser"); - - await writePluginWithSkill({ - pluginRoot, - pluginId: "browser", - skillId: "browser-automation", - skillDescription: "Browser automation", - }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "browser", - enabledByDefault: true, - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf8", - ); - - const enabledEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: {}, - managedSkillsDir: managedDir, - }); - - const browserEntry = enabledEntries.find((entry) => entry.skill.name === "browser-automation"); - const browserSkillDir = path.join(pluginRoot, "skills", "browser-automation"); - expect(browserEntry?.skill.baseDir).toBe( - path.join(workspaceDir, ".plugin-skills", "browser-automation"), - ); - expect(browserEntry?.skill.filePath).toBe( - path.join(workspaceDir, ".plugin-skills", "browser-automation", "SKILL.md"), - ); - await expect( - fs.readlink(path.join(workspaceDir, ".plugin-skills", "browser-automation")), - ).resolves.toBe(browserSkillDir); - - const blockedEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - plugins: { - entries: { browser: { enabled: false } }, - }, - }, - managedSkillsDir: managedDir, - }); - - expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("browser-automation"); - await expectMissingPath(path.join(workspaceDir, ".plugin-skills", "browser-automation")); - }); - - it("loads frontmatter edge cases in one workspace", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillDir = path.join(workspaceDir, "skills", "fallback-name"); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - ["---", "description: Skill without explicit name", "---", "", "# Fallback"].join("\n"), - "utf8", - ); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "hidden-skill"), - name: "hidden-skill", - description: "Hidden prompt entry", - frontmatterExtra: "disable-model-invocation: true", - }); - const bomSkillDir = path.join(workspaceDir, "skills", "bom-skill"); - await fs.mkdir(bomSkillDir, { recursive: true }); - await fs.writeFile( - path.join(bomSkillDir, "SKILL.md"), - "\uFEFF---\nname: bom-skill\ndescription: BOM-prefixed skill\n---\n\n# BOM skill\n", - "utf8", - ); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir); - - expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name"); - expect(entries.map((entry) => entry.skill.name)).toContain("bom-skill"); - const hiddenEntry = entries.find((entry) => entry.skill.name === "hidden-skill"); - - expect(hiddenEntry?.invocation?.disableModelInvocation).toBe(true); - expect(hiddenEntry?.exposure?.includeInAvailableSkillsPrompt).toBe(false); - }); - - it("loads workspace metadata with JSON5-style trailing commas", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillDir = path.join(workspaceDir, "skills", "json5-metadata"); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: json5-metadata -description: JSON5-style metadata -metadata: - { - "openclaw": - { - "requires": - { - "env": ["EXAMPLE_VAR"], - }, - }, - } ---- -`, - "utf8", - ); - - const entry = loadTestWorkspaceSkillEntries(workspaceDir).find( - (candidate) => candidate.skill.name === "json5-metadata", - ); - - expect(entry?.metadata?.requires?.env).toEqual(["EXAMPLE_VAR"]); - }); - - it("warns for malformed files and keeps loading sibling workspace skills", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const brokenDir = path.join(workspaceDir, "skills", "broken"); - const brokenFile = path.join(brokenDir, "SKILL.md"); - const unterminatedDir = path.join(workspaceDir, "skills", "unterminated"); - const unterminatedFile = path.join(unterminatedDir, "SKILL.md"); - await fs.mkdir(brokenDir, { recursive: true }); - await fs.mkdir(unterminatedDir, { recursive: true }); - await fs.writeFile( - brokenFile, - `--- -name: [broken -description: Broken skill ---- -`, - "utf8", - ); - await fs.writeFile( - unterminatedFile, - "---\nname: unterminated\ndescription: Missing closing delimiter\n", - "utf8", - ); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "valid"), - name: "valid", - description: "Valid sibling", - }); - const warn = captureWarningLogger(); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir); - const warningText = warn.mock.calls.flat().map(String).join("\n"); - - expect(entries.map((entry) => entry.skill.name)).toContain("valid"); - expect(entries.map((entry) => entry.skill.name)).not.toContain("broken"); - expect(warningText).toContain(brokenFile); - expect(warningText).toContain("invalid frontmatter: BAD_INDENT"); - expect(entries.map((entry) => entry.skill.name)).not.toContain("unterminated"); - expect(warningText).toContain(unterminatedFile); - expect(warningText).toContain( - "invalid frontmatter: UNTERMINATED_FRONTMATTER: missing closing --- delimiter", - ); - }); - - it("applies agent skill filters and replacement semantics", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeWorkspaceSkills(workspaceDir, [ - { name: "github", description: "GitHub" }, - { name: "weather", description: "Weather" }, - { name: "docs-search", description: "Docs" }, - ]); - - const defaultEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - agents: { - defaults: { - skills: ["github"], - }, - list: [{ id: "writer" }], - }, - }, - agentId: "writer", - }); - - expect(defaultEntries.map((entry) => entry.skill.name)).toEqual(["github"]); - - const replacementEntries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - agents: { - defaults: { - skills: ["github"], - }, - list: [{ id: "writer", skills: ["docs-search"] }], - }, - }, - agentId: "writer", - }); - - expect(replacementEntries.map((entry) => entry.skill.name)).toEqual(["docs-search"]); - }); - - it("keeps remote-eligible skills when agent filtering is active", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "remote-only"), - name: "remote-only", - description: "Needs a remote bin", - metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}', - }); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - agents: { - defaults: { - skills: ["remote-only"], - }, - list: [{ id: "writer" }], - }, - }, - agentId: "writer", - eligibility: { - remote: { - platforms: ["linux"], - hasBin: () => false, - hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"), - note: "sandbox", - }, - }, - }); - - expect(entries.map((entry) => entry.skill.name)).toEqual(["remote-only"]); - }); - - it("filters remote-ineligible skills when no agent skill filter is active", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "local-only"), - name: "local-only", - description: "Always available", - }); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "remote-only"), - name: "remote-only", - description: "Needs a remote bin", - metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}', - }); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - eligibility: { - remote: { - platforms: ["linux"], - hasBin: () => false, - hasAnyBin: () => false, - note: "sandbox", - }, - }, - }); - - expect(entries.map((entry) => entry.skill.name)).toEqual(["local-only"]); - }); - - it.runIf(process.platform !== "win32")( - "skips workspace skill paths that resolve outside the workspace root", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const outsideDir = await createTempWorkspaceDir(); - const escapedSkillDir = path.join(outsideDir, "outside-skill"); - await writeSkill({ - dir: escapedSkillDir, - name: "outside-skill", - description: "Outside", - }); - await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true }); - const requestedPath = path.join(workspaceDir, "skills", "escaped-skill"); - await fs.symlink(escapedSkillDir, requestedPath, "dir"); - const fileLinkSkillDir = path.join(workspaceDir, "skills", "escaped-file"); - await fs.mkdir(fileLinkSkillDir, { recursive: true }); - await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(fileLinkSkillDir, "SKILL.md")); - const targetDir = path.join(workspaceDir, "safe-target"); - await writeSkill({ - dir: targetDir, - name: "symlink-target", - description: "Target skill", - }); - const symlinkedSkillDir = path.join(workspaceDir, "skills", "symlinked"); - await fs.mkdir(symlinkedSkillDir, { recursive: true }); - await fs.symlink(path.join(targetDir, "SKILL.md"), path.join(symlinkedSkillDir, "SKILL.md")); - const warn = captureWarningLogger(); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir); - - expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill"); - expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill"); - expect(entries.map((entry) => entry.skill.name)).not.toContain("symlink-target"); - const warningLine = firstWarningLine(warn); - expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); - expect(warningLine).toContain("reason=symlink-escape"); - expect(warningLine).toContain("source=openclaw-workspace"); - expect(warningLine).toContain(`root=${path.join(workspaceDir, "skills")}`); - expect(warningLine).toContain(`requested=${requestedPath}`); - expect(warningLine).toContain("resolved="); - }, - ); - - it.runIf(process.platform !== "win32")( - "allows configured skill symlink targets outside their source root", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillName = `manager-${++workspaceCaseIndex}`; - const targetRoot = path.join(tempRoot, `${skillName}-skills`); - const targetSkillDir = path.join(targetRoot, skillName); - await writeSkill({ - dir: targetSkillDir, - name: skillName, - description: "Manager skill", - }); - const workspaceSkillsDir = path.join(workspaceDir, "skills"); - await fs.mkdir(workspaceSkillsDir, { recursive: true }); - const symlinkPath = path.join(workspaceSkillsDir, skillName); - await fs.symlink(targetSkillDir, symlinkPath, "dir"); - const warn = captureWarningLogger(); - - try { - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { - allowSymlinkTargets: [targetRoot], - }, - }, - }, - }); - - expect(entries.map((entry) => entry.skill.name)).toContain(skillName); - expect(warn).not.toHaveBeenCalled(); - } finally { - await fs.unlink(symlinkPath).catch(() => undefined); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "loads managed skill directory symlinks outside the managed root", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const managedDir = path.join(workspaceDir, ".managed"); - const skillName = `managed-${++workspaceCaseIndex}`; - const targetSkillDir = path.join(tempRoot, `${skillName}-target`, skillName); - await writeSkill({ - dir: targetSkillDir, - name: skillName, - description: "Managed symlink target", - }); - await fs.mkdir(managedDir, { recursive: true }); - const symlinkPath = path.join(managedDir, skillName); - await fs.symlink(targetSkillDir, symlinkPath, "dir"); - const warn = captureWarningLogger(); - - try { - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - managedSkillsDir: managedDir, - }); - - expect(entries.map((entry) => entry.skill.name)).toContain(skillName); - expect(warn).not.toHaveBeenCalled(); - } finally { - await fs.unlink(symlinkPath).catch(() => undefined); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "keeps SKILL.md containment for managed symlinked skill directories", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const managedDir = path.join(workspaceDir, ".managed"); - const skillName = `managed-file-link-${++workspaceCaseIndex}`; - const targetSkillDir = path.join(tempRoot, `${skillName}-target`, skillName); - const outsideDir = path.join(tempRoot, `${skillName}-outside`); - await fs.mkdir(targetSkillDir, { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - await writeSkill({ - dir: outsideDir, - name: skillName, - description: "Escaped metadata", - }); - await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(targetSkillDir, "SKILL.md")); - await fs.mkdir(managedDir, { recursive: true }); - const symlinkPath = path.join(managedDir, skillName); - await fs.symlink(targetSkillDir, symlinkPath, "dir"); - const warn = captureWarningLogger(); - - try { - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - managedSkillsDir: managedDir, - }); - - expect(entries.map((entry) => entry.skill.name)).not.toContain(skillName); - const warningLine = firstWarningLine(warn); - expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); - expect(warningLine).toContain("source=openclaw-managed"); - expect(warningLine).toContain("reason=symlink-escape"); - } finally { - await fs.unlink(symlinkPath).catch(() => undefined); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "calls out bundled symlink escapes with compact home-relative paths", - async () => { - const { workspaceDir, bundledDir, requestedPath } = await createEscapedBundledSkillFixture(); - const warn = captureWarningLogger(); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir, { - bundledSkillsDir: bundledDir, - }); - - expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-bundled-skill"); - const warningLine = firstWarningLine(warn); - expect(warningLine).toContain("Skipping escaped skill path outside its configured root:"); - expect(warningLine).toContain("source=openclaw-bundled"); - expect(warningLine).toContain("reason=bundled-symlink-escape"); - expect(warningLine).toContain("hint=likely-stray-local-symlink-or-checkout-mutation"); - expect(warningLine).toContain(`requested=${requestedPath}`); - expect(warningLine).toContain("resolved="); - }, - ); - - it.runIf(process.platform !== "win32")( - "uses compact home-relative paths in escaped skill console warnings", - async () => { - const { workspaceDir, bundledDir } = await createEscapedBundledSkillFixture({ - workspaceDir: path.join(fakeHome, "workspace"), - outsideDir: path.join(fakeHome, "outside"), - }); - const warn = captureWarningLogger(); - - loadTestWorkspaceSkillEntries(workspaceDir, { - bundledSkillsDir: bundledDir, - }); - - const warningLine = firstWarningLine(warn); - expect(warningLine).toContain("root=~/workspace/.bundled"); - expect(warningLine).toContain("requested=~/workspace/.bundled/escaped-bundled-skill"); - expect(warningLine).toContain("resolved=~/outside/outside-bundled-skill"); - }, - ); - - it.runIf(process.platform !== "win32")( - "reads skill frontmatter when the allowed root is the filesystem root", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillDir = path.join(workspaceDir, "skills", "root-allowed"); - await writeSkill({ - dir: skillDir, - name: "root-allowed", - description: "Readable from filesystem root", - }); - - const frontmatter = readSkillFrontmatterSafe({ - rootDir: path.parse(skillDir).root, - filePath: path.join(skillDir, "SKILL.md"), - }); - - expect(frontmatter?.name).toBe("root-allowed"); - expect(frontmatter?.description).toBe("Readable from filesystem root"); - }, - ); - - describe("nested skill subdirectories", () => { - it("discovers SKILL.md two levels deep under a grouping subfolder", async () => { - const workspaceDir = await createTempWorkspaceDir(); - // Grouped layout: skills/group/skill/SKILL.md (no SKILL.md at skills/group/). - await writeSkill({ - dir: path.join(workspaceDir, "skills", "group", "nested-skill"), - name: "nested-skill", - description: "Nested under a group folder", - }); - - const entries = loadTestWorkspaceSkillEntries(workspaceDir); - const names = entries.map((entry) => entry.skill.name); - expect(names).toContain("nested-skill"); - }); - - it("keeps loading direct skills (skills/skill/SKILL.md) unchanged", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "direct-skill"), - name: "direct-skill", - description: "Direct skill at first level", - }); - // Sibling group with a deeper skill. - await writeSkill({ - dir: path.join(workspaceDir, "skills", "group", "grouped-skill"), - name: "grouped-skill", - description: "Skill nested under a group", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name); - expect(names).toContain("direct-skill"); - expect(names).toContain("grouped-skill"); - }); - - it("does not count invalid grouped candidates against the loaded skill cap", async () => { - const workspaceDir = await createTempWorkspaceDir(); - for (const nestedName of ["a", "b"]) { - const invalidDir = path.join(workspaceDir, "skills", "00-group", nestedName); - await fs.mkdir(invalidDir, { recursive: true }); - await fs.writeFile( - path.join(invalidDir, "SKILL.md"), - `---\nname: ${nestedName}\n---\n\n# Invalid\n`, - "utf-8", - ); - } - await writeSkill({ - dir: path.join(workspaceDir, "skills", "01-valid"), - name: "valid-skill", - description: "Valid sibling after invalid grouped candidates", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - limits: { - maxCandidatesPerRoot: 10, - maxSkillsLoadedPerSource: 1, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toEqual(["valid-skill"]); - }); - - it("loads earlier grouped skills before later direct siblings hit the source cap", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "00-group", "grouped"), - name: "grouped-skill", - description: "Grouped skill before direct siblings", - }); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "01-direct"), - name: "direct-skill", - description: "Direct sibling after grouped skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - limits: { - maxCandidatesPerRoot: 10, - maxSkillsLoadedPerSource: 1, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toEqual(["grouped-skill"]); - }); - - it("keeps later grouped siblings discoverable when an earlier group is noisy", async () => { - const workspaceDir = await createTempWorkspaceDir(); - async function createNoisyTree(dir: string, depth: number): Promise { - if (depth === 0) { - return; - } - for (const name of ["00-a", "01-b"]) { - const childDir = path.join(dir, name); - await fs.mkdir(childDir, { recursive: true }); - await createNoisyTree(childDir, depth - 1); - } - } - await createNoisyTree(path.join(workspaceDir, "skills", "00-noisy"), 6); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "01-later", "later-skill"), - name: "later-skill", - description: "Grouped sibling after a noisy tree", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - limits: { - maxCandidatesPerRoot: 2, - maxSkillsLoadedPerSource: 10, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("later-skill"); - }); - - it("discovers deeply nested SKILL.md files within the Codex-compatible depth", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "a", "b", "c"), - name: "deep-skill", - description: "Discovered through grouped folders", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name); - expect(names).toContain("deep-skill"); - }); - - it("discovers deeply nested skills in configured roots named skills", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const parentDir = await createTempWorkspaceDir(); - const skillsDir = path.join(parentDir, "skills"); - await writeSkill({ - dir: path.join(skillsDir, "d0", "d1", "d2", "d3", "d4", "d5"), - name: "configured-deep-skill", - description: "Depth 6 from configured skills root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillsDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("configured-deep-skill"); - }); - - it("uses the nested skills folder as the depth root for repo-style extra dirs", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(repoDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), - name: "repo-depth-skill", - description: "Depth 6 from nested skills root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("repo-depth-skill"); - }); - - it("ignores invalid outside candidates when resolving repo-style extra dirs", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - await fs.mkdir(path.join(repoDir, "examples", "bad"), { recursive: true }); - await fs.writeFile( - path.join(repoDir, "examples", "bad", "SKILL.md"), - "---\nname: bad\n---\n", - ); - await writeSkill({ - dir: path.join(repoDir, "skills", "group", "valid"), - name: "repo-nested-skill", - description: "Valid nested repo skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("repo-nested-skill"); - expect(names).not.toContain("bad"); - }); - - it("ignores invalid root SKILL.md files when resolving repo-style extra dirs", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - await fs.writeFile(path.join(repoDir, "SKILL.md"), "---\nname: bad\n---\n"); - await writeSkill({ - dir: path.join(repoDir, "examples", "valid"), - name: "outside-valid-skill", - description: "Valid outside repo skill", - }); - await writeSkill({ - dir: path.join(repoDir, "skills", "group", "valid"), - name: "repo-nested-skill", - description: "Valid nested repo skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("repo-nested-skill"); - expect(names).not.toContain("outside-valid-skill"); - expect(names).not.toContain("bad"); - }); - - it("treats invalid outside SKILL.md files as terminal during repo-root detection", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - await fs.mkdir(path.join(repoDir, "examples", "bad", "child"), { recursive: true }); - await fs.writeFile( - path.join(repoDir, "examples", "bad", "SKILL.md"), - "---\nname: bad\n---\n", - ); - await writeSkill({ - dir: path.join(repoDir, "examples", "bad", "child"), - name: "outside-child", - description: "Valid child hidden behind invalid terminal parent", - }); - await writeSkill({ - dir: path.join(repoDir, "skills", "group", "valid"), - name: "repo-nested-skill", - description: "Valid nested repo skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("repo-nested-skill"); - expect(names).not.toContain("outside-child"); - }); - - it.runIf(process.platform !== "win32")( - "does not follow outside symlink dirs during repo-root detection", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - const outsideDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(outsideDir, "linked"), - name: "outside-linked-skill", - description: "Outside linked skill", - }); - await fs.mkdir(path.join(repoDir, "examples"), { recursive: true }); - await fs.symlink(outsideDir, path.join(repoDir, "examples", "linked"), "dir"); - await writeSkill({ - dir: path.join(repoDir, "skills", "group", "valid"), - name: "repo-nested-skill", - description: "Valid nested repo skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("repo-nested-skill"); - expect(names).not.toContain("outside-linked-skill"); - }, - ); - - it.runIf(process.platform !== "win32")( - "keeps configured roots with possible symlink skills outside nested skills", - async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - const targetRoot = path.join(tempRoot, `linked-root-${workspaceCaseIndex++}`); - const targetSkillDir = path.join(targetRoot, "linked-skill"); - await writeSkill({ - dir: targetSkillDir, - name: "linked-skill", - description: "Allowed linked skill", - }); - await fs.mkdir(path.join(repoDir, "group"), { recursive: true }); - await fs.symlink(targetSkillDir, path.join(repoDir, "group", "linked-skill"), "dir"); - await writeSkill({ - dir: path.join(repoDir, "skills", "group", "valid"), - name: "repo-nested-skill", - description: "Valid nested repo skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { - allowSymlinkTargets: [targetRoot], - extraDirs: [repoDir], - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("linked-skill"); - expect(names).toContain("repo-nested-skill"); - }, - ); - - it("keeps a configured direct skill root even when it has nested skill fixtures", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: skillDir, - name: "direct-root", - description: "Configured direct skill root", - }); - await writeSkill({ - dir: path.join(skillDir, "skills", "examples", "fixture"), - name: "fixture-skill", - description: "Nested fixture skill should not replace the root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("direct-root"); - expect(names).not.toContain("fixture-skill"); - }); - - it("does not re-root extra dirs from ignored nested skill files", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const repoDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(repoDir, "valid"), - name: "valid-root-skill", - description: "Direct child skill under configured root", - }); - await writeSkill({ - dir: path.join(repoDir, "skills", "node_modules", "pkg"), - name: "ignored-package-skill", - description: "Ignored nested dependency fixture", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [repoDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("valid-root-skill"); - expect(names).not.toContain("ignored-package-skill"); - }); - - it("keeps direct child skills when a configured root also has a skills child", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillRootDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(skillRootDir, "valid"), - name: "valid-root-skill", - description: "Direct child skill under configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "skills", "examples", "fixture"), - name: "fixture-skill", - description: "Nested fixture should not replace the configured root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillRootDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("valid-root-skill"); - expect(names).toContain("fixture-skill"); - }); - - it("keeps nested skills when top-level candidate cap is filled by direct skills", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillRootDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(skillRootDir, "00-valid"), - name: "valid-root-skill", - description: "Direct child skill under configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "skills", "examples", "fixture"), - name: "fixture-skill", - description: "Nested fixture should still be scanned", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillRootDir] }, - limits: { - maxCandidatesPerRoot: 1, - maxSkillsLoadedPerSource: 10, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("valid-root-skill"); - expect(names).toContain("fixture-skill"); - }); - - it("keeps nested skills depth when a configured root also has direct skills", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillRootDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(skillRootDir, "valid"), - name: "valid-root-skill", - description: "Direct child skill under configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), - name: "deep-nested-skill", - description: "Depth 6 from nested skills root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillRootDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("valid-root-skill"); - expect(names).toContain("deep-nested-skill"); - }); - - it("keeps configured root grouping outside skills within watcher depth", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillRootDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(skillRootDir, "group", "within-depth"), - name: "within-depth", - description: "Depth 2 from configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "group", "d1", "too-deep"), - name: "too-deep", - description: "Depth 3 from configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), - name: "deep-nested-skill", - description: "Depth 6 from nested skills root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillRootDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("within-depth"); - expect(names).toContain("deep-nested-skill"); - expect(names).not.toContain("too-deep"); - }); - - it("keeps grouped child skills when a configured root also has a skills child", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const skillRootDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(skillRootDir, "group", "valid"), - name: "valid-grouped-skill", - description: "Grouped child skill under configured root", - }); - await writeSkill({ - dir: path.join(skillRootDir, "skills", "examples", "fixture"), - name: "fixture-skill", - description: "Nested fixture should not replace the configured root", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - load: { extraDirs: [skillRootDir] }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(names).toContain("valid-grouped-skill"); - expect(names).toContain("fixture-skill"); - }); - - it("does not descend beyond the bounded grouped skill depth", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"), - name: "within-depth", - description: "Depth 6 loads", - }); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "e0", "e1", "e2", "e3", "e4", "e5", "e6"), - name: "too-deep", - description: "Depth 7 does not load", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name); - expect(names).toContain("within-depth"); - expect(names).not.toContain("too-deep"); - }); - - it("does not fall through to child skills when an immediate SKILL.md is invalid", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const parentDir = path.join(workspaceDir, "skills", "group", "parent"); - await fs.mkdir(parentDir, { recursive: true }); - await fs.writeFile(path.join(parentDir, "SKILL.md"), "---\nname: parent\n---\n", "utf-8"); - await writeSkill({ - dir: path.join(parentDir, "child"), - name: "too-deep", - description: "Should not be discovered through invalid parent fallback", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name); - expect(names).not.toContain("too-deep"); - }); - - it("treats an immediate SKILL.md as terminal and does not descend", async () => { - const workspaceDir = await createTempWorkspaceDir(); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "group"), - name: "group", - description: "Direct skill at the group level", - }); - await writeSkill({ - dir: path.join(workspaceDir, "skills", "group", "inner"), - name: "inner", - description: "Should be ignored when parent is itself a skill", - }); - - const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name); - expect(names).toContain("group"); - expect(names).not.toContain("inner"); - }); - - it("warns and caps discovery in large grouping subfolders", async () => { - const workspaceDir = await createTempWorkspaceDir(); - for (let i = 0; i < 3; i += 1) { - const name = `nested-skill-${i}`; - await writeSkill({ - dir: path.join(workspaceDir, "skills", "group", name), - name, - description: `Nested skill ${i}`, - }); - } - const warn = captureWarningLogger(); - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - limits: { - maxCandidatesPerRoot: 2, - maxSkillsLoadedPerSource: 10, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect( - names.reduce((count, name) => count + (name.startsWith("nested-skill-") ? 1 : 0), 0), - ).toBe(2); - expect( - warn.mock.calls - .map(([line]) => String(line)) - .some((line) => - line.includes("Nested skills directory has many entries, truncating discovery."), - ), - ).toBe(true); - }); - - it("does not spend nested candidate budget on ignored raw entries", async () => { - const workspaceDir = await createTempWorkspaceDir(); - const groupDir = path.join(workspaceDir, "skills", "group"); - await fs.mkdir(groupDir, { recursive: true }); - for (let i = 0; i < 50; i += 1) { - await fs.writeFile(path.join(groupDir, `ignored-${String(i).padStart(2, "0")}.txt`), ""); - } - for (const name of ["valid-a", "valid-b", "valid-c"]) { - await writeSkill({ - dir: path.join(groupDir, name), - name, - description: `${name} nested under a group`, - }); - } - - const names = loadTestWorkspaceSkillEntries(workspaceDir, { - config: { - skills: { - limits: { - maxCandidatesPerRoot: 2, - maxSkillsLoadedPerSource: 10, - }, - }, - }, - }).map((entry) => entry.skill.name); - - expect(collectMatching(names, (name) => name.startsWith("valid-"))).toEqual([ - "valid-a", - "valid-b", - ]); - }); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/skills/loading/workspace-precedence.test.ts b/src/skills/loading/workspace-precedence.test.ts index bf0308e008fe..a502f04a4d70 100644 --- a/src/skills/loading/workspace-precedence.test.ts +++ b/src/skills/loading/workspace-precedence.test.ts @@ -6,7 +6,12 @@ import { createFixtureSuite } from "../../test-utils/fixture-suite.js"; import { writeSkill } from "../test-support/e2e-test-helpers.js"; import type { OpenClawSkillMetadata, SkillEntry } from "../types.js"; import { createSyntheticSourceInfo } from "./skill-contract.js"; -import { buildWorkspaceSkillsPrompt } from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; + +const buildWorkspaceSkillsPrompt = ( + workspaceDir: string, + opts?: Parameters[1], +): string => buildSkillSnapshot(workspaceDir, opts).prompt; vi.mock("./plugin-skills.js", () => ({ resolvePluginSkillDirs: () => [], diff --git a/src/skills/loading/workspace-skill-loader.test.ts b/src/skills/loading/workspace-skill-loader.test.ts new file mode 100644 index 000000000000..0e9003a1d8e2 --- /dev/null +++ b/src/skills/loading/workspace-skill-loader.test.ts @@ -0,0 +1,527 @@ +// Workspace skill loader tests cover source merging, metadata, filtering, and precedence. +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; +import { loggingState } from "../../logging/state.js"; +import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; +import { clearCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-state.js"; +import { resolveInstalledPluginIndexPolicyHash } from "../../plugins/installed-plugin-index-policy.js"; +import type { + PluginManifestRecord, + PluginManifestRegistry, +} from "../../plugins/manifest-registry.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; +import { writeSkill, writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js"; +import { + restoreMockSkillsHomeEnv, + setMockSkillsHomeEnv, + type SkillsHomeEnvSnapshot, +} from "../test-support/home-env.test-support.js"; +import { writePluginWithSkill } from "../test-support/skill-plugin-fixtures.test-support.js"; +import { loadWorkspaceSkills } from "./workspace-skill-loader.js"; + +vi.mock("../../plugins/manifest-registry.js", async () => { + const fsLocal = await import("node:fs"); + const pathLocal = await import("node:path"); + return { + loadPluginManifestRegistry: (params: { workspaceDir?: string }) => { + const extensionsRoot = pathLocal.join(params.workspaceDir ?? "", ".openclaw", "extensions"); + const plugins = []; + for (const id of ["open-prose", "browser"]) { + const rootDir = pathLocal.join(extensionsRoot, id); + const manifestPath = pathLocal.join(rootDir, "openclaw.plugin.json"); + if (!fsLocal.existsSync(manifestPath)) { + continue; + } + const manifest = JSON.parse(fsLocal.readFileSync(manifestPath, "utf8")) as { + enabledByDefault?: boolean; + skills?: string[]; + }; + plugins.push({ + id, + origin: id === "browser" ? "bundled" : "workspace", + enabledByDefault: manifest.enabledByDefault, + providers: [], + legacyPluginIds: [], + kind: [], + skills: manifest.skills ?? ["./skills"], + rootDir, + }); + } + return { plugins, diagnostics: [] }; + }, + }; +}); + +let fakeHome = ""; +let envSnapshot: SkillsHomeEnvSnapshot; +let tempRoot = ""; +let workspaceCaseIndex = 0; + +function createWorkspacePluginRegistry(workspaceDir: string): PluginManifestRegistry { + const extensionsRoot = path.join(workspaceDir, ".openclaw", "extensions"); + const plugins: PluginManifestRecord[] = []; + for (const id of ["open-prose", "browser"]) { + const rootDir = path.join(extensionsRoot, id); + const manifestPath = path.join(rootDir, "openclaw.plugin.json"); + if (!fsSync.existsSync(manifestPath)) { + continue; + } + const manifest = JSON.parse(fsSync.readFileSync(manifestPath, "utf8")) as { + id?: string; + enabledByDefault?: boolean; + skills?: string[]; + configSchema?: Record; + }; + plugins.push({ + id: manifest.id ?? id, + origin: id === "browser" ? "bundled" : "workspace", + enabledByDefault: manifest.enabledByDefault, + channels: [], + providers: [], + cliBackends: [], + legacyPluginIds: [], + kind: [], + skills: manifest.skills ?? ["./skills"], + hooks: [], + rootDir, + source: rootDir, + manifestPath, + configSchema: manifest.configSchema, + }); + } + return { plugins, diagnostics: [] }; +} + +function createWorkspacePluginMetadataSnapshot(params: { + workspaceDir: string; + config?: OpenClawConfig; + manifestRegistry: PluginManifestRegistry; +}): PluginMetadataSnapshot { + const policyHash = resolveInstalledPluginIndexPolicyHash(params.config); + const ownerMaps = { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }; + return { + policyHash, + workspaceDir: params.workspaceDir, + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash, + generatedAtMs: 1, + installRecords: {}, + plugins: [], + diagnostics: [], + }, + registryDiagnostics: [], + manifestRegistry: params.manifestRegistry, + plugins: params.manifestRegistry.plugins, + diagnostics: params.manifestRegistry.diagnostics, + byPluginId: new Map(params.manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])), + normalizePluginId: (pluginId) => pluginId, + owners: ownerMaps, + metrics: { + registrySnapshotMs: 0, + manifestRegistryMs: 0, + ownerMapsMs: 0, + totalMs: 0, + indexPluginCount: 0, + manifestPluginCount: params.manifestRegistry.plugins.length, + }, + }; +} + +function setWorkspacePluginMetadataSnapshot(workspaceDir: string, config?: OpenClawConfig): void { + const manifestRegistry = createWorkspacePluginRegistry(workspaceDir); + setCurrentPluginMetadataSnapshot( + createWorkspacePluginMetadataSnapshot({ + workspaceDir, + manifestRegistry, + ...(config === undefined ? {} : { config }), + }), + { + workspaceDir, + ...(config === undefined ? {} : { config }), + }, + ); +} + +async function expectMissingPath(pathToCheck: string) { + let thrown: unknown; + try { + await fs.lstat(pathToCheck); + } catch (error) { + thrown = error; + } + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT"); +} + +async function createTempWorkspaceDir() { + const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`); + await fs.mkdir(workspaceDir, { recursive: true }); + return workspaceDir; +} + +function captureWarningLogger() { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + return warn; +} + +function loadTestWorkspaceSkills( + workspaceDir: string, + opts?: Parameters[1], +) { + setWorkspacePluginMetadataSnapshot(workspaceDir, opts?.config); + return loadWorkspaceSkills(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: "", + pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"), + ...opts, + }); +} + +beforeAll(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-workspace-")); + fakeHome = path.join(tempRoot, "home"); + await fs.mkdir(fakeHome, { recursive: true }); + envSnapshot = setMockSkillsHomeEnv(fakeHome); +}); + +afterEach(async () => { + clearCurrentPluginMetadataSnapshot(); + setLoggerOverride(null); + loggingState.rawConsole = null; + resetLogger(); +}); + +afterAll(async () => { + await restoreMockSkillsHomeEnv(envSnapshot, async () => { + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); + +async function setupWorkspaceWithProsePlugin() { + const workspaceDir = await createTempWorkspaceDir(); + const managedDir = path.join(workspaceDir, ".managed"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "open-prose"); + + await writePluginWithSkill({ + pluginRoot, + pluginId: "open-prose", + skillId: "prose", + skillDescription: "test", + }); + + return { workspaceDir, managedDir }; +} + +describe("loadWorkspaceSkills", () => { + it("filters plugin-shipped skills through plugin config", async () => { + const { workspaceDir, managedDir } = await setupWorkspaceWithProsePlugin(); + + const enabledEntries = loadTestWorkspaceSkills(workspaceDir, { + config: { + plugins: { + entries: { "open-prose": { enabled: true } }, + }, + }, + managedSkillsDir: managedDir, + }); + + expect(enabledEntries.map((entry) => entry.skill.name)).toContain("prose"); + + const blockedEntries = loadTestWorkspaceSkills(workspaceDir, { + config: { + plugins: { + allow: ["something-else"], + }, + }, + managedSkillsDir: managedDir, + }); + + expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("prose"); + }); + + it("loads the browser plugin automation skill when the bundled plugin is enabled", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const managedDir = path.join(workspaceDir, ".managed"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "browser"); + + await writePluginWithSkill({ + pluginRoot, + pluginId: "browser", + skillId: "browser-automation", + skillDescription: "Browser automation", + }); + await fs.writeFile( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "browser", + enabledByDefault: true, + skills: ["./skills"], + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + ), + "utf8", + ); + + const enabledEntries = loadTestWorkspaceSkills(workspaceDir, { + config: {}, + managedSkillsDir: managedDir, + }); + + const browserEntry = enabledEntries.find((entry) => entry.skill.name === "browser-automation"); + const browserSkillDir = path.join(pluginRoot, "skills", "browser-automation"); + expect(browserEntry?.skill.baseDir).toBe( + path.join(workspaceDir, ".plugin-skills", "browser-automation"), + ); + expect(browserEntry?.skill.filePath).toBe( + path.join(workspaceDir, ".plugin-skills", "browser-automation", "SKILL.md"), + ); + await expect( + fs.readlink(path.join(workspaceDir, ".plugin-skills", "browser-automation")), + ).resolves.toBe(browserSkillDir); + + const blockedEntries = loadTestWorkspaceSkills(workspaceDir, { + config: { + plugins: { + entries: { browser: { enabled: false } }, + }, + }, + managedSkillsDir: managedDir, + }); + + expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("browser-automation"); + await expectMissingPath(path.join(workspaceDir, ".plugin-skills", "browser-automation")); + }); + + it("loads frontmatter edge cases in one workspace", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillDir = path.join(workspaceDir, "skills", "fallback-name"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "description: Skill without explicit name", "---", "", "# Fallback"].join("\n"), + "utf8", + ); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "hidden-skill"), + name: "hidden-skill", + description: "Hidden prompt entry", + frontmatterExtra: "disable-model-invocation: true", + }); + const bomSkillDir = path.join(workspaceDir, "skills", "bom-skill"); + await fs.mkdir(bomSkillDir, { recursive: true }); + await fs.writeFile( + path.join(bomSkillDir, "SKILL.md"), + "\uFEFF---\nname: bom-skill\ndescription: BOM-prefixed skill\n---\n\n# BOM skill\n", + "utf8", + ); + + const entries = loadTestWorkspaceSkills(workspaceDir); + + expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name"); + expect(entries.map((entry) => entry.skill.name)).toContain("bom-skill"); + const hiddenEntry = entries.find((entry) => entry.skill.name === "hidden-skill"); + + expect(hiddenEntry?.invocation?.disableModelInvocation).toBe(true); + expect(hiddenEntry?.exposure?.includeInAvailableSkillsPrompt).toBe(false); + }); + + it("loads workspace metadata with JSON5-style trailing commas", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const skillDir = path.join(workspaceDir, "skills", "json5-metadata"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: json5-metadata +description: JSON5-style metadata +metadata: + { + "openclaw": + { + "requires": + { + "env": ["EXAMPLE_VAR"], + }, + }, + } +--- +`, + "utf8", + ); + + const entry = loadTestWorkspaceSkills(workspaceDir).find( + (candidate) => candidate.skill.name === "json5-metadata", + ); + + expect(entry?.metadata?.requires?.env).toEqual(["EXAMPLE_VAR"]); + }); + + it("warns for malformed files and keeps loading sibling workspace skills", async () => { + const workspaceDir = await createTempWorkspaceDir(); + const brokenDir = path.join(workspaceDir, "skills", "broken"); + const brokenFile = path.join(brokenDir, "SKILL.md"); + const unterminatedDir = path.join(workspaceDir, "skills", "unterminated"); + const unterminatedFile = path.join(unterminatedDir, "SKILL.md"); + await fs.mkdir(brokenDir, { recursive: true }); + await fs.mkdir(unterminatedDir, { recursive: true }); + await fs.writeFile( + brokenFile, + `--- +name: [broken +description: Broken skill +--- +`, + "utf8", + ); + await fs.writeFile( + unterminatedFile, + "---\nname: unterminated\ndescription: Missing closing delimiter\n", + "utf8", + ); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "valid"), + name: "valid", + description: "Valid sibling", + }); + const warn = captureWarningLogger(); + + const entries = loadTestWorkspaceSkills(workspaceDir); + const warningText = warn.mock.calls.flat().map(String).join("\n"); + + expect(entries.map((entry) => entry.skill.name)).toContain("valid"); + expect(entries.map((entry) => entry.skill.name)).not.toContain("broken"); + expect(warningText).toContain(brokenFile); + expect(warningText).toContain("invalid frontmatter: BAD_INDENT"); + expect(entries.map((entry) => entry.skill.name)).not.toContain("unterminated"); + expect(warningText).toContain(unterminatedFile); + expect(warningText).toContain( + "invalid frontmatter: UNTERMINATED_FRONTMATTER: missing closing --- delimiter", + ); + }); + + it("applies agent skill filters and replacement semantics", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeWorkspaceSkills(workspaceDir, [ + { name: "github", description: "GitHub" }, + { name: "weather", description: "Weather" }, + { name: "docs-search", description: "Docs" }, + ]); + + const defaultEntries = loadTestWorkspaceSkills(workspaceDir, { + config: { + agents: { + defaults: { + skills: ["github"], + }, + list: [{ id: "writer" }], + }, + }, + agentId: "writer", + }); + + expect(defaultEntries.map((entry) => entry.skill.name)).toEqual(["github"]); + + const replacementEntries = loadTestWorkspaceSkills(workspaceDir, { + config: { + agents: { + defaults: { + skills: ["github"], + }, + list: [{ id: "writer", skills: ["docs-search"] }], + }, + }, + agentId: "writer", + }); + + expect(replacementEntries.map((entry) => entry.skill.name)).toEqual(["docs-search"]); + }); + + it("keeps remote-eligible skills when agent filtering is active", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "remote-only"), + name: "remote-only", + description: "Needs a remote bin", + metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}', + }); + + const entries = loadTestWorkspaceSkills(workspaceDir, { + config: { + agents: { + defaults: { + skills: ["remote-only"], + }, + list: [{ id: "writer" }], + }, + }, + agentId: "writer", + eligibility: { + remote: { + platforms: ["linux"], + hasBin: () => false, + hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"), + note: "sandbox", + }, + }, + }); + + expect(entries.map((entry) => entry.skill.name)).toEqual(["remote-only"]); + }); + + it("filters remote-ineligible skills when no agent skill filter is active", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "local-only"), + name: "local-only", + description: "Always available", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "remote-only"), + name: "remote-only", + description: "Needs a remote bin", + metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}', + }); + + const entries = loadTestWorkspaceSkills(workspaceDir, { + eligibility: { + remote: { + platforms: ["linux"], + hasBin: () => false, + hasAnyBin: () => false, + note: "sandbox", + }, + }, + }); + + expect(entries.map((entry) => entry.skill.name)).toEqual(["local-only"]); + }); +}); diff --git a/src/skills/loading/workspace-skill-loader.ts b/src/skills/loading/workspace-skill-loader.ts new file mode 100644 index 000000000000..1e9c85df8b85 --- /dev/null +++ b/src/skills/loading/workspace-skill-loader.ts @@ -0,0 +1,560 @@ +// Workspace skill loading turns validated discovery candidates into source-aware skill entries. +import fs from "node:fs"; +import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { canonicalizePath } from "../../agents/utils/paths.js"; +import { isDefaultStateDir } from "../../config/paths.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isPathInside } from "../../infra/path-guards.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; +import { + isSessionSkillEnabled, + resolveEffectiveAgentSkillFilter, +} from "../discovery/agent-filter.js"; +import { normalizeSkillFilter } from "../discovery/filter.js"; +import { mergeRemoteNodeSkillEntries } from "../runtime/remote-skills.js"; +import type { + OpenClawSkillMetadata, + ParsedSkillFrontmatter, + SkillEligibilityContext, + SkillEntry, +} from "../types.js"; +import { getArchivedSkillFiles } from "../workshop/curator.js"; +import { resolveBundledSkillsDir } from "./bundled-dir.js"; +import { resolveBundledAllowlist, shouldIncludeSkill } from "./config.js"; +import { + resolveOpenClawMetadata, + resolveSkillInvocationPolicy, + resolveSkillKey, +} from "./frontmatter.js"; +import { + loadSkillsFromDirSafe, + readSkillFrontmatterSafe, + type LocalSkillLoadDiagnostic, +} from "./local-loader.js"; +import { resolvePluginSkillDirs } from "./plugin-skills.js"; +import type { Skill } from "./skill-contract.js"; +import { compactSkillPath, resolveSkillsUserHomeDir } from "./skill-paths.js"; +import { + canonicalSkillDirForSource, + discoverPluginSkills, + discoverSkillCandidates, + resolveSkillDiscoveryLimits, + type CandidateSkillDir, + type ResolvedSkillDiscoveryLimits, +} from "./skill-root-discovery.js"; +import { resolveAllowedSkillSymlinkTargetRealPaths, tryRealpath } from "./symlink-targets.js"; + +const skillsLogger = createSubsystemLogger("skills"); +const SKILL_SOURCE_ORIGIN_RELATIVE_PATH = path.join(".openclaw", "source-origin.json"); +const MAX_SKILL_SOURCE_ORIGIN_BYTES = 16 * 1024; + +type LoadedSkillRecord = { + skill: Skill; + frontmatter?: ParsedSkillFrontmatter; + syncSourceDir?: string; + syncDirName?: string; +}; + +function warnInvalidSkillFrontmatter(source: string, diagnostic: LocalSkillLoadDiagnostic): void { + skillsLogger.warn("Skipping skill with invalid frontmatter.", { + source, + filePath: diagnostic.path, + error: diagnostic.message, + consoleMessage: + `Skipping skill with invalid frontmatter: ` + + `file=${compactSkillPath(diagnostic.path)} error=${diagnostic.message}`, + }); +} + +function filterSkillEntries( + entries: SkillEntry[], + config?: OpenClawConfig, + skillFilter?: string[], + skillOverrides?: Readonly>, + eligibility?: SkillEligibilityContext, +): SkillEntry[] { + const bundledAllowlist = resolveBundledAllowlist(config); + let filtered = entries.filter((entry) => + shouldIncludeSkill({ entry, config, bundledAllowlist, eligibility }), + ); + if (skillFilter !== undefined || skillOverrides !== undefined) { + const normalized = normalizeSkillFilter(skillFilter) ?? []; + const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; + skillsLogger.debug(`Applying skill filter: ${label}`); + const resolvedFilter = skillFilter === undefined ? undefined : normalized; + filtered = filtered.filter((entry) => + isSessionSkillEnabled( + entry.skill.name, + resolvedFilter, + skillOverrides, + resolveSkillKey(entry.skill, entry), + ), + ); + skillsLogger.debug( + `After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`, + ); + } + return filtered; +} + +function loadContainedSkillRecords(params: { + skillDir: string; + source: string; + maxSkillFileBytes: number; + canonicalSkillDir?: string; +}): LoadedSkillRecord[] { + const expectedBaseDir = path.resolve(params.skillDir); + const loaded = loadSkillsFromDirSafe({ + dir: params.skillDir, + source: params.source, + maxBytes: params.maxSkillFileBytes, + onDiagnostic: (diagnostic) => warnInvalidSkillFrontmatter(params.source, diagnostic), + }); + const records = loaded.skills + .map((skill) => ({ + skill, + frontmatter: loaded.frontmatterByFilePath.get(skill.filePath), + })) + .filter((record) => path.resolve(record.skill.baseDir) === expectedBaseDir); + const canonicalSkillDir = params.canonicalSkillDir; + return canonicalSkillDir + ? records.map((record) => canonicalizeLoadedSkillRecord(record, canonicalSkillDir)) + : records; +} + +function readSourceInstallSkillKey(skillDir: string): string | undefined { + try { + const sourceOriginPath = path.join(skillDir, SKILL_SOURCE_ORIGIN_RELATIVE_PATH); + const stat = fs.lstatSync(sourceOriginPath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_SKILL_SOURCE_ORIGIN_BYTES) { + return undefined; + } + const skillDirRealPath = tryRealpath(skillDir); + const sourceOriginRealPath = tryRealpath(sourceOriginPath); + if ( + !skillDirRealPath || + !sourceOriginRealPath || + !isPathInside(skillDirRealPath, sourceOriginRealPath) + ) { + return undefined; + } + const raw = fs.readFileSync(sourceOriginPath, "utf8"); + const parsed = JSON.parse(raw) as { slug?: unknown }; + return normalizeOptionalString(parsed.slug); + } catch { + return undefined; + } +} + +function resolveSkillEntryMetadata(params: { + frontmatter: ParsedSkillFrontmatter; + skillDir: string; +}): OpenClawSkillMetadata | undefined { + const metadata = resolveOpenClawMetadata(params.frontmatter); + if (metadata?.skillKey) { + return metadata; + } + const sourceInstallSkillKey = readSourceInstallSkillKey(params.skillDir); + if (!sourceInstallSkillKey) { + return metadata; + } + return { ...metadata, skillKey: sourceInstallSkillKey }; +} + +function canonicalizeLoadedSkillRecord( + record: LoadedSkillRecord, + canonicalSkillDir: string, +): LoadedSkillRecord { + const originalBaseDir = path.resolve(record.skill.baseDir); + const canonicalBaseDir = path.resolve(canonicalSkillDir); + if (originalBaseDir === canonicalBaseDir) { + return record; + } + const filePath = path.join( + canonicalBaseDir, + path.relative(originalBaseDir, record.skill.filePath), + ); + return { + ...record, + syncSourceDir: canonicalBaseDir, + syncDirName: path.basename(originalBaseDir), + skill: { + ...record.skill, + filePath, + baseDir: canonicalBaseDir, + sourceInfo: record.skill.sourceInfo + ? { ...record.skill.sourceInfo, path: filePath, baseDir: canonicalBaseDir } + : record.skill.sourceInfo, + }, + }; +} + +function setSyncSourceForPluginSkill( + record: LoadedSkillRecord, + syncSourceDir: string, +): LoadedSkillRecord { + return { + ...record, + syncSourceDir, + syncDirName: path.basename(record.skill.baseDir), + }; +} + +function isCandidateOversized( + candidate: CandidateSkillDir, + limits: ResolvedSkillDiscoveryLimits, + rootIsSkill: boolean, +): boolean { + try { + const size = fs.statSync(candidate.skillMdRealPath).size; + if (size <= limits.maxSkillFileBytes) { + return false; + } + skillsLogger.warn( + rootIsSkill + ? "Skipping skills root due to oversized SKILL.md." + : "Skipping skill due to oversized SKILL.md.", + rootIsSkill + ? { + dir: candidate.skillDir, + filePath: path.join(candidate.skillDir, "SKILL.md"), + size, + maxSkillFileBytes: limits.maxSkillFileBytes, + } + : { + skill: candidate.name, + filePath: path.join(candidate.skillDir, "SKILL.md"), + size, + maxSkillFileBytes: limits.maxSkillFileBytes, + }, + ); + return true; + } catch { + return true; + } +} + +function loadDiscoveredSkillRecords(params: { + dir: string; + source: string; + limits: ResolvedSkillDiscoveryLimits; + allowedSymlinkTargetRealPaths: readonly string[]; +}): LoadedSkillRecord[] { + const discovered = discoverSkillCandidates(params); + const loadedSkills: LoadedSkillRecord[] = []; + const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource); + for (const candidate of discovered.candidates) { + if (!discovered.rootIsSkill && loadedSkills.length >= maxSkillsLoadedPerSource) { + break; + } + if (isCandidateOversized(candidate, params.limits, discovered.rootIsSkill)) { + continue; + } + loadedSkills.push( + ...loadContainedSkillRecords({ + skillDir: candidate.skillDir, + source: params.source, + maxSkillFileBytes: params.limits.maxSkillFileBytes, + canonicalSkillDir: canonicalSkillDirForSource(params.source, candidate.skillDirRealPath), + }), + ); + } + if (loadedSkills.length > maxSkillsLoadedPerSource && !discovered.rootIsSkill) { + return loadedSkills + .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) + .slice(0, maxSkillsLoadedPerSource); + } + return loadedSkills; +} + +function loadGeneratedPluginSkillRecords(params: { + pluginSkillsDir: string; + pluginSkillDirs: readonly string[]; + source: string; + limits: ResolvedSkillDiscoveryLimits; +}): LoadedSkillRecord[] { + const candidates = discoverPluginSkills(params); + const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource); + const loadedSkills: LoadedSkillRecord[] = []; + for (const candidate of candidates) { + if (isCandidateOversized(candidate, params.limits, false)) { + continue; + } + const loadedRecords = loadContainedSkillRecords({ + skillDir: candidate.skillDir, + source: params.source, + maxSkillFileBytes: params.limits.maxSkillFileBytes, + }); + loadedSkills.push( + ...loadedRecords.map((record) => + setSyncSourceForPluginSkill(record, candidate.skillDirRealPath), + ), + ); + if (loadedSkills.length >= maxSkillsLoadedPerSource) { + break; + } + } + if (loadedSkills.length > maxSkillsLoadedPerSource) { + return loadedSkills + .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) + .slice(0, maxSkillsLoadedPerSource); + } + return loadedSkills; +} + +function loadSkillEntries( + workspaceDir: string, + opts?: { + config?: OpenClawConfig; + agentId?: string; + managedSkillsDir?: string; + bundledSkillsDir?: string; + pluginSkillsDir?: string; + workspaceOnly?: boolean; + includeArchived?: boolean; + }, +): SkillEntry[] { + const limits = resolveSkillDiscoveryLimits(opts?.config); + const allowedSymlinkTargetRealPaths = resolveAllowedSkillSymlinkTargetRealPaths(opts?.config); + const loadSkills = (params: { dir: string; source: string }): LoadedSkillRecord[] => + loadDiscoveredSkillRecords({ ...params, limits, allowedSymlinkTargetRealPaths }); + + const workspaceOnly = opts?.workspaceOnly === true; + const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); + const workspaceSkillsDir = path.resolve(workspaceDir, "skills"); + const bundledSkillsDir = workspaceOnly + ? undefined + : (opts?.bundledSkillsDir ?? resolveBundledSkillsDir()); + const pluginSkillsDir = opts?.pluginSkillsDir ?? path.join(CONFIG_DIR, "plugin-skills"); + const extraDirsRaw = workspaceOnly ? [] : (opts?.config?.skills?.load?.extraDirs ?? []); + const extraDirs = normalizeTrimmedStringList(extraDirsRaw); + const pluginSkillDirs = workspaceOnly + ? [] + : resolvePluginSkillDirs({ workspaceDir, config: opts?.config, pluginSkillsDir }); + const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs]; + + const bundledSkills = bundledSkillsDir + ? loadSkills({ dir: bundledSkillsDir, source: "openclaw-bundled" }) + : []; + const extraSkills = [ + ...mergedExtraDirs.flatMap((dir) => + loadSkills({ dir: resolveUserPath(dir), source: "openclaw-extra" }), + ), + ...loadGeneratedPluginSkillRecords({ + pluginSkillsDir, + pluginSkillDirs, + source: "openclaw-extra", + limits, + }), + ]; + const managedSkills = workspaceOnly + ? [] + : loadSkills({ dir: managedSkillsDir, source: "openclaw-managed" }); + const osHomeDir = resolveSkillsUserHomeDir(); + const personalAgentsSkillsDir = osHomeDir + ? path.resolve(osHomeDir, ".agents", "skills") + : path.resolve(".agents", "skills"); + const personalAgentsSkills = + workspaceOnly || !isDefaultStateDir() + ? [] + : loadSkills({ dir: personalAgentsSkillsDir, source: "agents-skills-personal" }); + const projectAgentsSkillsDir = path.resolve(workspaceDir, ".agents", "skills"); + const projectAgentsSkills = workspaceOnly + ? [] + : loadSkills({ dir: projectAgentsSkillsDir, source: "agents-skills-project" }); + const workspaceSkills = loadSkills({ dir: workspaceSkillsDir, source: "openclaw-workspace" }); + + const merged = new Map(); + const archivedSkillFiles = opts?.includeArchived ? null : getArchivedSkillFiles(); + const mergeRecord = (record: LoadedSkillRecord) => { + if (archivedSkillFiles?.has(canonicalizePath(record.skill.filePath))) { + return; + } + merged.set(record.skill.name, record); + }; + for (const record of extraSkills) { + mergeRecord(record); + } + for (const record of bundledSkills) { + mergeRecord(record); + } + for (const record of managedSkills) { + mergeRecord(record); + } + for (const record of personalAgentsSkills) { + mergeRecord(record); + } + for (const record of projectAgentsSkills) { + mergeRecord(record); + } + for (const record of workspaceSkills) { + mergeRecord(record); + } + + return Array.from(merged.values()) + .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) + .map((record) => { + const skill = record.skill; + const frontmatter = + record.frontmatter ?? + readSkillFrontmatterSafe({ + rootDir: skill.baseDir, + filePath: skill.filePath, + maxBytes: limits.maxSkillFileBytes, + }) ?? + ({} as ParsedSkillFrontmatter); + const invocation = resolveSkillInvocationPolicy(frontmatter); + const entry: SkillEntry = { + skill, + frontmatter, + metadata: resolveSkillEntryMetadata({ frontmatter, skillDir: skill.baseDir }), + invocation, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: !invocation.disableModelInvocation, + userInvocable: invocation.userInvocable ?? true, + }, + }; + if (record.syncSourceDir !== undefined) { + entry.syncSourceDir = record.syncSourceDir; + } + if (record.syncDirName !== undefined) { + entry.syncDirName = record.syncDirName; + } + return entry; + }); +} + +function filterArchivedSkillEntries(entries: SkillEntry[]): SkillEntry[] { + const archivedSkillFiles = getArchivedSkillFiles(); + return entries.filter((entry) => !archivedSkillFiles.has(canonicalizePath(entry.skill.filePath))); +} + +function resolveEffectiveWorkspaceSkillFilter(opts?: { + config?: OpenClawConfig; + agentId?: string; + skillFilter?: string[]; +}): string[] | undefined { + if (opts?.skillFilter !== undefined) { + return normalizeSkillFilter(opts.skillFilter); + } + if (!opts?.config || !opts.agentId) { + return undefined; + } + return resolveEffectiveAgentSkillFilter(opts.config, opts.agentId); +} + +export function resolveWorkspaceSkillPromptEntries( + workspaceDir: string, + opts?: { + config?: OpenClawConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + entries?: SkillEntry[]; + agentId?: string; + skillFilter?: string[]; + skillOverrides?: Record; + eligibility?: SkillEligibilityContext; + }, +): { eligible: SkillEntry[]; skillFilter: string[] | undefined } { + const skillFilter = resolveEffectiveWorkspaceSkillFilter(opts); + const skillEntries = opts?.entries + ? filterArchivedSkillEntries(opts.entries) + : mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { + canExec: opts?.eligibility?.nodeSkills?.canExec, + node: opts?.eligibility?.nodeSkills?.node, + }); + return { + eligible: filterSkillEntries( + skillEntries, + opts?.config, + skillFilter, + opts?.skillOverrides, + opts?.eligibility, + ), + skillFilter, + }; +} + +export function loadWorkspaceSkills( + workspaceDir: string, + opts?: { + config?: OpenClawConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + pluginSkillsDir?: string; + skillFilter?: string[]; + skillOverrides?: Record; + agentId?: string; + eligibility?: SkillEligibilityContext; + workspaceOnly?: boolean; + includeArchived?: boolean; + }, +): SkillEntry[] { + const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { + canExec: opts?.eligibility?.nodeSkills?.canExec, + node: opts?.eligibility?.nodeSkills?.node, + }); + const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); + if ( + effectiveSkillFilter === undefined && + opts?.skillOverrides === undefined && + opts?.eligibility === undefined + ) { + return entries; + } + return filterSkillEntries( + entries, + opts?.config, + effectiveSkillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); +} + +export function loadVisibleSkills( + workspaceDir: string, + opts?: { + config?: OpenClawConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + skillFilter?: string[]; + skillOverrides?: Record; + agentId?: string; + eligibility?: SkillEligibilityContext; + }, +): SkillEntry[] { + const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { + canExec: opts?.eligibility?.nodeSkills?.canExec, + node: opts?.eligibility?.nodeSkills?.node, + }); + const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); + return filterSkillEntries( + entries, + opts?.config, + effectiveSkillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); +} + +export function filterWorkspaceSkills( + entries: SkillEntry[], + opts?: { + config?: OpenClawConfig; + skillFilter?: string[]; + skillOverrides?: Record; + eligibility?: SkillEligibilityContext; + }, +): SkillEntry[] { + return filterSkillEntries( + entries, + opts?.config, + opts?.skillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); +} diff --git a/src/skills/loading/prompt-resolution.test.ts b/src/skills/loading/workspace-skill-prompt-resolution.test.ts similarity index 91% rename from src/skills/loading/prompt-resolution.test.ts rename to src/skills/loading/workspace-skill-prompt-resolution.test.ts index 21854f77f577..41677db71d77 100644 --- a/src/skills/loading/prompt-resolution.test.ts +++ b/src/skills/loading/workspace-skill-prompt-resolution.test.ts @@ -1,4 +1,4 @@ -// Prompt resolution tests cover skill prompt lookup and active skill selection. +// Workspace skill prompt resolution tests cover snapshot reuse and degraded-secret filtering. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -7,15 +7,15 @@ import { setActiveDegradedSecretOwners } from "../../secrets/runtime-degraded-st import { writeSkill } from "../test-support/e2e-test-helpers.js"; import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION, type SkillEntry } from "../types.js"; -import { buildWorkspaceSkillSnapshot, resolveSkillsPromptForRun } from "./workspace.js"; +import { buildSkillSnapshot, resolveSkillsPrompt } from "./workspace-skill-prompt.js"; afterEach(() => { setActiveDegradedSecretOwners([]); }); -describe("resolveSkillsPromptForRun", () => { +describe("resolveSkillsPrompt", () => { it("prefers snapshot prompt when available", () => { - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: { prompt: "SNAPSHOT", skills: [] }, workspaceDir: "/tmp/openclaw", }); @@ -32,7 +32,7 @@ describe("resolveSkillsPromptForRun", () => { }), frontmatter: {}, }; - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ entries: [entry], workspaceDir: "/tmp/openclaw", }); @@ -53,7 +53,7 @@ describe("resolveSkillsPromptForRun", () => { }; expect( - resolveSkillsPromptForRun({ + resolveSkillsPrompt({ skillsSnapshot: { prompt: "", skills: [] }, entries: [entry], workspaceDir: "/tmp/openclaw", @@ -74,7 +74,7 @@ describe("resolveSkillsPromptForRun", () => { ]); expect( - resolveSkillsPromptForRun({ + resolveSkillsPrompt({ skillsSnapshot: { prompt: "LEAKED COLD INSTRUCTIONS\n\n \n cold-skill\n \n", @@ -98,7 +98,7 @@ describe("resolveSkillsPromptForRun", () => { }, ]); - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: { prompt: "LEGACY SKILL PROMPT", skills: [{ name: "cold-skill" }, { name: "healthy-skill" }], @@ -131,7 +131,7 @@ describe("resolveSkillsPromptForRun", () => { }), frontmatter: {}, }; - const snapshot = buildWorkspaceSkillSnapshot("/tmp/openclaw", { + const snapshot = buildSkillSnapshot("/tmp/openclaw", { entries: [cold, healthy], }); setActiveDegradedSecretOwners([ @@ -145,7 +145,7 @@ describe("resolveSkillsPromptForRun", () => { }, ]); - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: snapshot, entries: [cold, healthy], workspaceDir: "/tmp/openclaw", @@ -167,7 +167,7 @@ describe("resolveSkillsPromptForRun", () => { frontmatter: {}, }); const capturedEntries = [createEntry("cold-skill"), createEntry("healthy-skill")]; - const snapshot = buildWorkspaceSkillSnapshot("/tmp/openclaw", { + const snapshot = buildSkillSnapshot("/tmp/openclaw", { entries: capturedEntries, }); setActiveDegradedSecretOwners([ @@ -181,7 +181,7 @@ describe("resolveSkillsPromptForRun", () => { }, ]); - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: snapshot, entries: [...capturedEntries, createEntry("new-skill")], workspaceDir: "/tmp/openclaw", @@ -204,7 +204,7 @@ describe("resolveSkillsPromptForRun", () => { name: "healthy-skill", description: "Captured healthy", }); - const snapshot = buildWorkspaceSkillSnapshot(workspaceDir); + const snapshot = buildSkillSnapshot(workspaceDir); await writeSkill({ dir: path.join(workspaceDir, "skills", "healthy-skill"), name: "healthy-skill", @@ -227,7 +227,7 @@ describe("resolveSkillsPromptForRun", () => { ]); try { - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ skillsSnapshot: snapshot, workspaceDir, }); @@ -255,7 +255,7 @@ describe("resolveSkillsPromptForRun", () => { frontmatter: {}, }; - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ entries: [hidden], workspaceDir: "/tmp/openclaw", }); @@ -285,7 +285,7 @@ describe("resolveSkillsPromptForRun", () => { frontmatter: {}, }; - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ entries: [visible, hidden], config: { agents: { @@ -325,7 +325,7 @@ describe("resolveSkillsPromptForRun", () => { frontmatter: {}, }; - const prompt = resolveSkillsPromptForRun({ + const prompt = resolveSkillsPrompt({ entries: [inheritedEntry, explicitEntry], config: { agents: { diff --git a/src/skills/loading/compact-format.test.ts b/src/skills/loading/workspace-skill-prompt.test.ts similarity index 65% rename from src/skills/loading/compact-format.test.ts rename to src/skills/loading/workspace-skill-prompt.test.ts index ece02d71ce4e..13a767b13e36 100644 --- a/src/skills/loading/compact-format.test.ts +++ b/src/skills/loading/workspace-skill-prompt.test.ts @@ -1,8 +1,10 @@ -// Compact format tests cover compact skill prompt serialization. +// Workspace skill prompt tests cover catalog budgets, ordering, and compact paths. +import fsSync from "node:fs"; import os from "node:os"; -import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "openclaw/plugin-sdk/agent-sessions"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { withEnv } from "../../test-utils/env.js"; import { restoreMockSkillsHomeEnv, setMockSkillsHomeEnv, @@ -10,12 +12,17 @@ import { } from "../test-support/home-env.test-support.js"; import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js"; import type { SkillEntry } from "../types.js"; -import { formatSkillsForPrompt, type Skill } from "./skill-contract.js"; import { - formatSkillsCompact, - buildWorkspaceSkillsPrompt, - buildWorkspaceSkillSnapshot, -} from "./workspace.js"; + formatSkillsCompactForPrompt as formatSkillsCompact, + formatSkillsForPrompt, + type Skill, +} from "./skill-contract.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; + +const buildWorkspaceSkillsPrompt = ( + workspaceDir: string, + opts?: Parameters[1], +): string => buildSkillSnapshot(workspaceDir, opts).prompt; function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill { return createCanonicalFixtureSkill({ @@ -69,86 +76,6 @@ const COMPACT_OMITTED_NOTICE = const COMPACT_SHORTENED_NOTICE = "⚠️ Skills catalog using compact format (descriptions shortened). Run `openclaw skills check` to audit."; -describe("formatSkillsCompact", () => { - it("keeps the full-format XML output aligned with the upstream formatter for visible skills", () => { - const skills = [ - { ...makeSkill("weather", "Get weather & forecasts"), promptVersion: "sha256:abc123" }, - makeSkill("notes", "Summarize notes", "/tmp/notes/SKILL.md"), - ]; - expect(formatSkillsForPrompt(skills)).toBe(upstreamFormatSkillsForPrompt(skills)); - }); - - it("renders all passed skills in the full formatter without reapplying visibility policy", () => { - const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true }; - const out = formatSkillsForPrompt([makeSkill("visible"), hidden]); - expect(out).toContain("visible"); - expect(out).toContain("hidden"); - }); - - it("returns empty string for no skills", () => { - expect(formatSkillsCompact([])).toBe(""); - }); - - it("keeps compact descriptions with name, location, and version", () => { - const out = formatSkillsCompact([ - { ...makeSkill("weather", "Get weather data"), promptVersion: "sha256:abc123" }, - ]); - expect(out).toContain("weather"); - expect(out).toContain("Get weather data"); - expect(out).toContain("/skills/weather/SKILL.md"); - expect(out).toContain("sha256:abc123"); - }); - - it("omits descriptions when their compact budget is zero", () => { - const out = formatSkillsCompact([makeSkill("weather", "Get weather data")], { - descriptionMaxChars: 0, - }); - expect(out).toContain("weather"); - expect(out).not.toContain(""); - }); - - it("preserves location notes when compact descriptions are omitted", () => { - const out = formatSkillsCompact( - [ - { - ...makeSkill("remote", "Remote skill"), - locationNote: "Load with exec host=node node=node-1.", - }, - ], - { descriptionMaxChars: 0 }, - ); - - expect(out).toContain("Load with exec host=node node=node-1."); - }); - - it("truncates descriptions without splitting emoji surrogate pairs", () => { - const out = formatSkillsCompact([makeSkill("emoji", `${"A".repeat(16)}😀 trailing`)], { - descriptionMaxChars: 20, - }); - - expect(out).toContain(`${"A".repeat(16)}...`); - expect(out).not.toMatch(/[\uD800-\uDFFF]/u); - }); - - it("renders all passed skills without reapplying visibility policy", () => { - const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true }; - const out = formatSkillsCompact([makeSkill("visible"), hidden]); - expect(out).toContain("visible"); - expect(out).toContain("hidden"); - }); - - it("escapes XML special characters", () => { - const out = formatSkillsCompact([makeSkill("a { - const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(800))); - const compact = formatSkillsCompact(skills); - expect(compact.length).toBeLessThan(formatSkillsForPrompt(skills).length / 2); - }); -}); - describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => { let envSnapshot: SkillsHomeEnvSnapshot; @@ -394,7 +321,7 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => { const skills = Array.from({ length: 5 }, (_, i) => makeSkill(`skill-${i}`, "A skill", `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`), ); - const snapshot = buildWorkspaceSkillSnapshot("/fake", { + const snapshot = buildSkillSnapshot("/fake", { entries: skills.map(makeEntry), }); // Prompt should use compacted paths @@ -407,3 +334,188 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => { } }); }); + +describe("compactSkillPaths", () => { + function buildPromptForFixtureSkill(params: { + workspaceRoot: string; + skillDir: string; + name: string; + description: string; + }) { + return buildWorkspaceSkillsPrompt(params.workspaceRoot, { + entries: [ + { + skill: createCanonicalFixtureSkill({ + name: params.name, + description: params.description, + filePath: path.join(params.skillDir, "SKILL.md"), + baseDir: params.skillDir, + source: "test", + }), + frontmatter: {}, + metadata: undefined, + invocation: { disableModelInvocation: false, userInvocable: true }, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: true, + userInvocable: true, + }, + }, + ], + }); + } + + it("replaces home directory prefix with ~ in skill locations", () => { + const home = os.homedir(); + const skillDir = path.join(home, ".openclaw-test-skills", "test-skill"); + + const prompt = buildPromptForFixtureSkill({ + workspaceRoot: home, + skillDir, + name: "test-skill", + description: "A test skill for path compaction", + }); + + expect(prompt).not.toContain(home + path.sep); + expect(prompt).toContain("~/"); + expect(prompt).toContain("test-skill"); + expect(prompt).toContain("A test skill for path compaction"); + }); + + it("does not compact explicit state-root managed skill paths to OS-home tilde paths", () => { + const root = path.parse(os.homedir()).root; + const osHome = path.join(root, "data"); + const stateDir = path.join(osHome, ".openclaw"); + const skillDir = path.join(stateDir, "skills", "world-cup-soccer-openclaw-skill"); + const skillFile = path.join(skillDir, "SKILL.md"); + + const prompt = withEnv( + { + HOME: osHome, + OPENCLAW_HOME: osHome, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + () => + buildPromptForFixtureSkill({ + workspaceRoot: path.join(root, "workspace"), + skillDir, + name: "world-cup-soccer-openclaw-skill", + description: "World Cup standings lookup", + }), + ); + + expect(prompt).toContain(`${skillFile}`); + expect(prompt).not.toContain("~/.openclaw/skills/world-cup-soccer-openclaw-skill/SKILL.md"); + }); + + it("does not compact explicit state-root plugin skill paths to OS-home tilde paths", () => { + const root = path.parse(os.homedir()).root; + const osHome = path.join(root, "data"); + const stateDir = path.join(osHome, ".openclaw"); + const skillDir = path.join(stateDir, "plugin-skills", "calendar-plugin-skill"); + const skillFile = path.join(skillDir, "SKILL.md"); + + const prompt = withEnv( + { + HOME: osHome, + OPENCLAW_HOME: osHome, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + }, + () => + buildPromptForFixtureSkill({ + workspaceRoot: path.join(root, "workspace"), + skillDir, + name: "calendar-plugin-skill", + description: "Calendar plugin skill", + }), + ); + + expect(prompt).toContain(`${skillFile}`); + expect(prompt).not.toContain("~/.openclaw/plugin-skills/calendar-plugin-skill/SKILL.md"); + }); + + it("compacts managed skill paths when OS-home tilde reaches the same path", () => { + const home = os.homedir(); + const stateDir = path.join(home, ".openclaw"); + const skillDir = path.join(stateDir, "skills", "home-managed-skill"); + + const prompt = withEnv( + { + HOME: home, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_HOME: undefined, + }, + () => + buildPromptForFixtureSkill({ + workspaceRoot: path.join(home, "workspace"), + skillDir, + name: "home-managed-skill", + description: "Home managed skill", + }), + ); + + expect(prompt).toContain("~/.openclaw/skills/home-managed-skill/SKILL.md"); + expect(prompt).not.toContain(`${path.join(skillDir, "SKILL.md")}`); + }); + + it("preserves POSIX literal backslashes after home compaction", () => { + const home = os.homedir(); + const skillDir = path.join(home, ".openclaw-test-skills\\literal-skill"); + + const prompt = buildPromptForFixtureSkill({ + workspaceRoot: home, + skillDir, + name: "literal-skill", + description: "POSIX literal backslash skill", + }); + + const locationMatch = prompt.match(/([^<]+)<\/location>/); + if (!locationMatch) { + throw new Error("expected prompt location tag"); + } + expect(locationMatch[1]).toContain("~/"); + expect(locationMatch[1]).toContain("\\literal-skill"); + }); + + it("preserves paths outside home directory", () => { + const outsideHome = path.join(path.parse(os.homedir()).root, "openclaw-external-skills"); + const skillDir = path.join(outsideHome, "skills", "ext-skill"); + + const prompt = buildPromptForFixtureSkill({ + workspaceRoot: outsideHome, + skillDir, + name: "ext-skill", + description: "External skill", + }); + + expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + expect(prompt).toContain(path.join(skillDir, "SKILL.md")); + }); + + it("loads skills when the shared state database is unavailable", () => { + const root = fsSync.realpathSync( + fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-load-")), + ); + const blockedParent = path.join(root, "state-blocker"); + fsSync.writeFileSync(blockedParent, "not a directory", "utf8"); + const skillDir = path.join(root, "workspace", "skills", "available-skill"); + + try { + const prompt = withEnv({ OPENCLAW_STATE_DIR: path.join(blockedParent, "state") }, () => + buildPromptForFixtureSkill({ + workspaceRoot: path.join(root, "workspace"), + skillDir, + name: "available-skill", + description: "Available despite missing lifecycle state", + }), + ); + + expect(prompt).toContain("available-skill"); + expect(prompt).toContain("Available despite missing lifecycle state"); + } finally { + fsSync.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/skills/loading/workspace-skill-prompt.ts b/src/skills/loading/workspace-skill-prompt.ts new file mode 100644 index 000000000000..91b4828136f9 --- /dev/null +++ b/src/skills/loading/workspace-skill-prompt.ts @@ -0,0 +1,345 @@ +// Workspace skill prompt helpers render bounded catalogs and reusable snapshots. +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveEffectiveAgentSkillsLimits } from "../discovery/agent-filter.js"; +import { filterPromptVisibleSkillEntries } from "../discovery/skill-index.js"; +import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js"; +import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js"; +import { hasUnavailableSkillSecretOwners, isSkillSecretOwnerUnavailable } from "./config.js"; +import { resolveSkillKey } from "./frontmatter.js"; +import { + escapeSkillXml, + formatSkillsCompactForPrompt, + formatSkillsForPrompt, + type Skill, +} from "./skill-contract.js"; +import { compactPromptSkills } from "./skill-paths.js"; +import { resolveWorkspaceSkillPromptEntries } from "./workspace-skill-loader.js"; + +const COMPACT_DESCRIPTION_MAX_CHARS = 220; +const COMPACT_DESCRIPTION_MIN_CHARS = 4; +const DEFAULT_MAX_SKILLS_IN_PROMPT = 150; +const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 18_000; + +type ResolvedSkillsPromptLimits = { + maxSkillsInPrompt: number; + maxSkillsPromptChars: number; +}; + +function resolveSkillsPromptLimits( + config?: OpenClawConfig, + agentId?: string, +): ResolvedSkillsPromptLimits { + const limits = config?.skills?.limits; + const agentLimits = resolveEffectiveAgentSkillsLimits(config, agentId); + return { + maxSkillsInPrompt: limits?.maxSkillsInPrompt ?? DEFAULT_MAX_SKILLS_IN_PROMPT, + maxSkillsPromptChars: + agentLimits?.maxSkillsPromptChars ?? + limits?.maxSkillsPromptChars ?? + DEFAULT_MAX_SKILLS_PROMPT_CHARS, + }; +} + +type SkillsPromptFormat = { kind: "full" } | { kind: "compact"; descriptionMaxChars: number }; + +function buildSkillsLimitNote(params: { + truncated: boolean; + format: SkillsPromptFormat; + included: number; + total: number; +}): string { + if (params.truncated) { + const compactDetails = + params.format.kind === "compact" + ? ` (compact format, ${params.format.descriptionMaxChars > 0 ? "descriptions shortened" : "descriptions omitted"})` + : ""; + return `⚠️ Skills truncated: included ${params.included} of ${params.total}${compactDetails}. Run \`openclaw skills check\` to audit.`; + } + if (params.format.kind === "compact") { + const compactDetails = + params.format.descriptionMaxChars > 0 ? "descriptions shortened" : "descriptions omitted"; + return `⚠️ Skills catalog using compact format (${compactDetails}). Run \`openclaw skills check\` to audit.`; + } + return ""; +} + +function buildRenderedSkillsPrompt(params: { + remoteNote?: string; + skills: Skill[]; + total: number; + format: SkillsPromptFormat; + includeLimitNote?: boolean; +}): string { + // resolveCodeModeSkills in src/agents/code-mode-skills.ts parses this exact format; update both together. + // The production-renderer parity test in src/agents/code-mode.test.ts enforces this coupling. + const truncated = params.skills.length < params.total; + const limitNote = + params.includeLimitNote === false + ? "" + : buildSkillsLimitNote({ + truncated, + format: params.format, + included: params.skills.length, + total: params.total, + }); + const catalog = + params.format.kind === "compact" + ? formatSkillsCompactForPrompt(params.skills, { + descriptionMaxChars: params.format.descriptionMaxChars, + }) + : formatSkillsForPrompt(params.skills); + return [params.remoteNote, limitNote, catalog].filter(Boolean).join("\n"); +} + +function applySkillsPromptLimits(params: { + skills: Skill[]; + config?: OpenClawConfig; + agentId?: string; + remoteNote?: string; +}): string { + const limits = resolveSkillsPromptLimits(params.config, params.agentId); + const total = params.skills.length; + const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt)); + let skillsForPrompt = byCount; + + const renderWithinLimit = ( + skills: Skill[], + format: SkillsPromptFormat, + includeLimitNote = true, + ): string | undefined => { + const remoteNotes = params.remoteNote ? [params.remoteNote, undefined] : [undefined]; + for (const remoteNote of remoteNotes) { + const prompt = buildRenderedSkillsPrompt({ + remoteNote, + skills, + total, + format, + includeLimitNote, + }); + if (prompt.length <= limits.maxSkillsPromptChars) { + return prompt; + } + } + return undefined; + }; + + const fitsFull = (skills: Skill[], includeLimitNote = true): boolean => + renderWithinLimit(skills, { kind: "full" }, includeLimitNote) !== undefined; + const fitsCompact = ( + skills: Skill[], + descriptionMaxChars: number, + includeLimitNote = true, + ): boolean => + renderWithinLimit(skills, { kind: "compact", descriptionMaxChars }, includeLimitNote) !== + undefined; + + if (!fitsFull(skillsForPrompt)) { + if (!fitsCompact(skillsForPrompt, 0)) { + let lo = 0; + let hi = skillsForPrompt.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (fitsCompact(skillsForPrompt.slice(0, mid), 0)) { + lo = mid; + } else { + hi = mid - 1; + } + } + skillsForPrompt = skillsForPrompt.slice(0, lo); + } + + if (skillsForPrompt.length === 0 && byCount.length > 0) { + const fullWithoutNotice = renderWithinLimit(byCount, { kind: "full" }, false); + if (fullWithoutNotice !== undefined) { + return fullWithoutNotice; + } + let lo = 0; + let hi = byCount.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (fitsCompact(byCount.slice(0, mid), 0, false)) { + lo = mid; + } else { + hi = mid - 1; + } + } + if (lo > 0) { + skillsForPrompt = byCount.slice(0, lo); + } + } + + const includeLimitNote = fitsCompact(skillsForPrompt, 0); + let descriptionMaxChars = 0; + if ( + skillsForPrompt.length > 0 && + fitsCompact(skillsForPrompt, COMPACT_DESCRIPTION_MIN_CHARS, includeLimitNote) + ) { + let lo = COMPACT_DESCRIPTION_MIN_CHARS; + let hi = COMPACT_DESCRIPTION_MAX_CHARS; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (fitsCompact(skillsForPrompt, mid, includeLimitNote)) { + lo = mid; + } else { + hi = mid - 1; + } + } + descriptionMaxChars = lo; + } + return ( + renderWithinLimit( + skillsForPrompt, + { kind: "compact", descriptionMaxChars }, + includeLimitNote, + ) ?? "" + ); + } + + return renderWithinLimit(skillsForPrompt, { kind: "full" }) ?? ""; +} + +type WorkspaceSkillBuildOptions = { + config?: OpenClawConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + entries?: SkillEntry[]; + agentId?: string; + skillFilter?: string[]; + skillOverrides?: Record; + eligibility?: SkillEligibilityContext; +}; + +function resolveWorkspaceSkillPromptState( + workspaceDir: string, + opts?: WorkspaceSkillBuildOptions, +): { eligible: SkillEntry[]; prompt: string; resolvedSkills: Skill[]; skillFilter?: string[] } { + const { eligible, skillFilter } = resolveWorkspaceSkillPromptEntries(workspaceDir, opts); + const promptEntries = filterPromptVisibleSkillEntries(eligible); + const remoteNote = opts?.eligibility?.remote?.note?.trim(); + const resolvedSkills = promptEntries.map((entry) => entry.skill); + const promptSkills = compactPromptSkills(resolvedSkills).toSorted((a, b) => + a.name.localeCompare(b.name, "en"), + ); + const prompt = applySkillsPromptLimits({ + skills: promptSkills, + config: opts?.config, + agentId: opts?.agentId, + remoteNote, + }); + return { eligible, prompt, resolvedSkills, skillFilter }; +} + +export function buildSkillSnapshot( + workspaceDir: string, + opts?: WorkspaceSkillBuildOptions & { snapshotVersion?: number }, +): SkillSnapshot { + const { eligible, prompt, resolvedSkills, skillFilter } = resolveWorkspaceSkillPromptState( + workspaceDir, + opts, + ); + return { + prompt, + skills: eligible.map((entry) => ({ + name: entry.skill.name, + skillKey: resolveSkillKey(entry.skill, entry), + primaryEnv: entry.metadata?.primaryEnv, + requiredEnv: entry.metadata?.requires?.env?.slice(), + })), + ...(skillFilter === undefined ? {} : { skillFilter }), + ...(opts?.skillOverrides ? { skillOverrides: opts.skillOverrides } : {}), + ...(opts?.eligibility?.nodeSkills + ? { nodeSkillsEligibility: opts.eligibility.nodeSkills } + : {}), + resolvedSkills, + version: opts?.snapshotVersion, + promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION, + }; +} + +export function resolveSkillsPrompt(params: { + skillsSnapshot?: SkillSnapshot; + entries?: SkillEntry[]; + config?: OpenClawConfig; + workspaceDir: string; + agentId?: string; + eligibility?: SkillEligibilityContext; +}): string { + const snapshotPrompt = params.skillsSnapshot?.prompt?.trim(); + if (params.skillsSnapshot && !snapshotPrompt) { + return ""; + } + const snapshotHasLegacySkillIdentity = params.skillsSnapshot?.skills.some( + (skill) => !skill.skillKey, + ); + if (snapshotPrompt) { + const snapshotHasUnavailableSkill = + params.skillsSnapshot?.skills.some((skill) => + isSkillSecretOwnerUnavailable(skill.skillKey ?? skill.name), + ) || + (snapshotHasLegacySkillIdentity && hasUnavailableSkillSecretOwners()); + if ( + snapshotHasUnavailableSkill && + params.skillsSnapshot?.promptFormatVersion !== WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION + ) { + return ""; + } + if (snapshotHasLegacySkillIdentity && hasUnavailableSkillSecretOwners()) { + return ""; + } + const unavailableNames = new Set( + params.skillsSnapshot?.skills + .filter( + (skill) => skill.skillKey !== undefined && isSkillSecretOwnerUnavailable(skill.skillKey), + ) + .map((skill) => escapeSkillXml(skill.name)), + ); + if (unavailableNames.size === 0) { + return snapshotPrompt; + } + const catalogOpen = ""; + const catalogClose = ""; + const catalogStart = snapshotPrompt.indexOf(catalogOpen); + const catalogEnd = snapshotPrompt.indexOf(catalogClose, catalogStart + catalogOpen.length); + if ( + catalogStart < 0 || + catalogEnd < 0 || + snapshotPrompt.includes(catalogOpen, catalogStart + catalogOpen.length) || + snapshotPrompt.includes(catalogClose, catalogEnd + catalogClose.length) + ) { + return ""; + } + const bodyStart = catalogStart + catalogOpen.length; + const catalogBody = snapshotPrompt.slice(bodyStart, catalogEnd); + const blockPattern = /\n[ ]{2}\n[\s\S]*?\n[ ]{2}<\/skill>/g; + let cursor = 0; + let filteredBody = ""; + for (const match of catalogBody.matchAll(blockPattern)) { + const gap = catalogBody.slice(cursor, match.index); + const block = match[0]; + const name = /^[ ]{4}(.*)<\/name>$/m.exec(block)?.[1]; + if (gap.trim() || !name) { + return ""; + } + filteredBody += gap; + if (!unavailableNames.has(name)) { + filteredBody += block; + } + cursor = (match.index ?? 0) + block.length; + } + const tail = catalogBody.slice(cursor); + if (tail.trim()) { + return ""; + } + return `${snapshotPrompt.slice(0, bodyStart)}${filteredBody}${tail}${snapshotPrompt.slice(catalogEnd)}`.trim(); + } + if (params.entries && params.entries.length > 0) { + const prompt = buildSkillSnapshot(params.workspaceDir, { + entries: params.entries, + config: params.config, + agentId: params.agentId, + eligibility: params.eligibility, + }).prompt; + return prompt.trim() ? prompt : ""; + } + return ""; +} diff --git a/src/skills/loading/workspace-snapshot.test.ts b/src/skills/loading/workspace-skill-snapshot.test.ts similarity index 66% rename from src/skills/loading/workspace-snapshot.test.ts rename to src/skills/loading/workspace-skill-snapshot.test.ts index c9e90e6a5d3a..8ac718bd0ccb 100644 --- a/src/skills/loading/workspace-snapshot.test.ts +++ b/src/skills/loading/workspace-skill-snapshot.test.ts @@ -11,7 +11,7 @@ import { setMockSkillsHomeEnv, type SkillsHomeEnvSnapshot, } from "../test-support/home-env.test-support.js"; -import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; vi.mock("./plugin-skills.js", () => ({ resolvePluginSkillDirs: () => [], @@ -19,7 +19,6 @@ vi.mock("./plugin-skills.js", () => ({ const fixtureSuite = createFixtureSuite("openclaw-skills-snapshot-suite-"); let truncationWorkspaceTemplateDir = ""; -let nestedRepoTemplateDir = ""; let tempHome: TempHomeEnv | null = null; let skillsHomeEnv: SkillsHomeEnvSnapshot | null = null; @@ -38,16 +37,6 @@ beforeAll(async () => { description: "x".repeat(800), }); } - - nestedRepoTemplateDir = await fixtureSuite.createCaseDir("template-skills-repo"); - for (let i = 0; i < 8; i += 1) { - const name = `repo-skill-${String(i).padStart(2, "0")}`; - await writeSkill({ - dir: path.join(nestedRepoTemplateDir, "skills", name), - name, - description: `Desc ${i}`, - }); - } }); afterAll(async () => { @@ -66,12 +55,9 @@ function withWorkspaceHome(workspaceDir: string, cb: () => T): T { return withPathResolutionEnv(workspaceDir, { PATH: "" }, () => cb()); } -function buildSnapshot( - workspaceDir: string, - options?: Parameters[1], -) { +function buildSnapshot(workspaceDir: string, options?: Parameters[1]) { return withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { + buildSkillSnapshot(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), bundledSkillsDir: path.join(workspaceDir, ".bundled"), ...options, @@ -86,7 +72,7 @@ async function cloneTemplateDir(templateDir: string, prefix: string): Promise, + snapshot: ReturnType, params: { contains?: string[]; omits?: string[] }, ) { for (const name of params.contains ?? []) { @@ -99,7 +85,7 @@ function expectSnapshotNamesAndPrompt( } } -describe("buildWorkspaceSkillSnapshot", () => { +describe("buildSkillSnapshot", () => { it("returns an empty snapshot when skills dirs are missing", async () => { const workspaceDir = await fixtureSuite.createCaseDir("workspace"); @@ -125,7 +111,7 @@ describe("buildWorkspaceSkillSnapshot", () => { await fs.mkdir(path.join(home, ".agents"), { recursive: true }); await fs.symlink(compatibilitySkillsDir, path.join(home, ".agents", "skills"), "dir"); const buildHomeSnapshot = () => - buildWorkspaceSkillSnapshot(workspaceDir, { + buildSkillSnapshot(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), bundledSkillsDir: path.join(workspaceDir, ".bundled"), }); @@ -172,7 +158,7 @@ describe("buildWorkspaceSkillSnapshot", () => { expect(snapshot.skills.map((skill) => skill.name)).toContain("visible-skill"); }); - it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => { + it("keeps prompt output stable across equivalent snapshot builds", async () => { const workspaceDir = await fixtureSuite.createCaseDir("workspace"); await writeSkill({ dir: path.join(workspaceDir, "skills", "visible"), @@ -207,11 +193,10 @@ describe("buildWorkspaceSkillSnapshot", () => { }, }; - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, opts), - ); - const prompt = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillsPrompt(workspaceDir, opts), + const snapshot = withWorkspaceHome(workspaceDir, () => buildSkillSnapshot(workspaceDir, opts)); + const prompt = withWorkspaceHome( + workspaceDir, + () => buildSkillSnapshot(workspaceDir, opts).prompt, ); expect(snapshot.prompt).toBe(prompt); @@ -248,7 +233,7 @@ describe("buildWorkspaceSkillSnapshot", () => { const workspaceDir = await cloneTemplateDir(truncationWorkspaceTemplateDir, "workspace"); const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { + buildSkillSnapshot(workspaceDir, { config: { skills: { limits: { @@ -292,136 +277,4 @@ describe("buildWorkspaceSkillSnapshot", () => { ]); expect(snapshot.skillFilter).toEqual(["docs-search", "github"]); }); - - it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => { - const workspaceDir = await fixtureSuite.createCaseDir("workspace"); - const repoDir = await cloneTemplateDir(nestedRepoTemplateDir, "skills-repo"); - - const snapshot = withWorkspaceHome(workspaceDir, () => - buildWorkspaceSkillSnapshot(workspaceDir, { - config: { - skills: { - load: { - extraDirs: [repoDir], - }, - limits: { - maxCandidatesPerRoot: 5, - maxSkillsLoadedPerSource: 5, - }, - }, - }, - managedSkillsDir: path.join(workspaceDir, ".managed"), - bundledSkillsDir: path.join(workspaceDir, ".bundled"), - }), - ); - - const skillNames = snapshot.skills.map((skill) => skill.name); - expect(skillNames).toStrictEqual([ - "repo-skill-00", - "repo-skill-01", - "repo-skill-02", - "repo-skill-03", - "repo-skill-04", - ]); - for (const name of skillNames) { - expect(snapshot.prompt).toContain(name); - } - }); - - it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => { - const workspaceDir = await fixtureSuite.createCaseDir("workspace"); - - await writeSkill({ - dir: path.join(workspaceDir, "skills", "small-skill"), - name: "small-skill", - description: "Small", - }); - - await writeSkill({ - dir: path.join(workspaceDir, "skills", "big-skill"), - name: "big-skill", - description: "Big", - body: "x".repeat(5_000), - }); - - const snapshot = buildSnapshot(workspaceDir, { - config: { - skills: { - limits: { - maxSkillFileBytes: 1000, - }, - }, - }, - }); - - expectSnapshotNamesAndPrompt(snapshot, { - contains: ["small-skill"], - omits: ["big-skill"], - }); - }); - - it("detects nested skills roots beyond the first 25 entries", async () => { - const workspaceDir = await fixtureSuite.createCaseDir("workspace"); - const repoDir = await fixtureSuite.createCaseDir("skills-repo"); - - // Create 30 nested dirs, but only the last one is an actual skill. - for (let i = 0; i < 30; i += 1) { - await fs.mkdir(path.join(repoDir, "skills", `entry-${String(i).padStart(2, "0")}`), { - recursive: true, - }); - } - - await writeSkill({ - dir: path.join(repoDir, "skills", "entry-29"), - name: "late-skill", - description: "Nested skill discovered late", - }); - - const snapshot = buildSnapshot(workspaceDir, { - config: { - skills: { - load: { - extraDirs: [repoDir], - }, - limits: { - maxCandidatesPerRoot: 30, - maxSkillsLoadedPerSource: 30, - }, - }, - }, - }); - - expectSnapshotNamesAndPrompt(snapshot, { - contains: ["late-skill"], - }); - }); - - it("enforces maxSkillFileBytes for root-level SKILL.md", async () => { - const workspaceDir = await fixtureSuite.createCaseDir("workspace"); - const rootSkillDir = await fixtureSuite.createCaseDir("root-skill"); - - await writeSkill({ - dir: rootSkillDir, - name: "root-big-skill", - description: "Big", - body: "x".repeat(5_000), - }); - - const snapshot = buildSnapshot(workspaceDir, { - config: { - skills: { - load: { - extraDirs: [rootSkillDir], - }, - limits: { - maxSkillFileBytes: 1000, - }, - }, - }, - }); - - expectSnapshotNamesAndPrompt(snapshot, { - omits: ["root-big-skill"], - }); - }); }); diff --git a/src/skills/loading/workspace-sync.test.ts b/src/skills/loading/workspace-skill-sync.runtime.test.ts similarity index 75% rename from src/skills/loading/workspace-sync.test.ts rename to src/skills/loading/workspace-skill-sync.runtime.test.ts index 0cfc4d7d3ea0..fd15063526d8 100644 --- a/src/skills/loading/workspace-sync.test.ts +++ b/src/skills/loading/workspace-skill-sync.runtime.test.ts @@ -1,4 +1,4 @@ -// Workspace sync tests cover skill synchronization between workspace and runtime state. +// Workspace skill sync runtime tests cover sandbox synchronization and plugin-provided skills. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -6,14 +6,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { withEnv, withEnvAsync } from "../../test-utils/env.js"; import { bumpSkillsSnapshotVersion, getSkillsSnapshotVersion } from "../runtime/refresh-state.js"; import { writeSkill } from "../test-support/e2e-test-helpers.js"; -import { - buildWorkspaceSkillSnapshot, - buildWorkspaceSkillsPrompt, - syncSkillsToWorkspace, -} from "./workspace.js"; +import { buildSkillSnapshot } from "./workspace-skill-prompt.js"; +import { syncWorkspaceSkills } from "./workspace-skill-sync.runtime.js"; + +const mockResolvePluginSkillDirs = vi.hoisted(() => vi.fn(() => [] as string[])); vi.mock("./plugin-skills.js", () => ({ - resolvePluginSkillDirs: () => [], + resolvePluginSkillDirs: mockResolvePluginSkillDirs, })); async function pathExists(filePath: string): Promise { @@ -36,7 +35,7 @@ async function createCaseDir(prefix: string): Promise { } async function syncSourceSkillsToTarget(sourceWorkspace: string, targetWorkspace: string) { - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), @@ -44,6 +43,13 @@ async function syncSourceSkillsToTarget(sourceWorkspace: string, targetWorkspace }); } +function buildWorkspaceSkillsPrompt( + workspaceDir: string, + opts?: Parameters[1], +): string { + return buildSkillSnapshot(workspaceDir, opts).prompt; +} + async function expectSyncedSkillConfinement(params: { sourceWorkspace: string; targetWorkspace: string; @@ -91,7 +97,7 @@ afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); }); -describe("buildWorkspaceSkillsPrompt", () => { +describe("syncWorkspaceSkills", () => { const buildPrompt = ( workspaceDir: string, opts?: Parameters[1], @@ -126,7 +132,7 @@ describe("buildWorkspaceSkillsPrompt", () => { "export {}", ); - const skillUsagePaths = await syncSkillsToWorkspace({ + const skillUsagePaths = await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, config: { skills: { load: { extraDirs: [extraDir] } } }, @@ -177,7 +183,7 @@ describe("buildWorkspaceSkillsPrompt", () => { description: "Prompt-hidden skill", frontmatterExtra: "disable-model-invocation: true", }); - const skillsSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const skillsSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: getSkillsSnapshotVersion(sourceWorkspace), @@ -192,10 +198,10 @@ describe("buildWorkspaceSkillsPrompt", () => { skillsSnapshot, }; - const first = await syncSkillsToWorkspace(params); + const first = await syncWorkspaceSkills(params); await fs.rm(path.join(sourceWorkspace, "skills"), { recursive: true, force: true }); const copy = vi.spyOn(fs, "cp"); - const second = await syncSkillsToWorkspace(params); + const second = await syncWorkspaceSkills(params); const copyCount = copy.mock.calls.length; copy.mockRestore(); @@ -217,7 +223,7 @@ describe("buildWorkspaceSkillsPrompt", () => { description: `${name} skill`, }); } - const skillsSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const skillsSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: getSkillsSnapshotVersion(sourceWorkspace), @@ -229,7 +235,7 @@ describe("buildWorkspaceSkillsPrompt", () => { managedSkillsDir, skillsSnapshot, }; - await syncSkillsToWorkspace(syncParams); + await syncWorkspaceSkills(syncParams); const targetSkillsDir = path.join(targetWorkspace, "skills"); const manifestPath = path.join(targetSkillsDir, ".openclaw-sync.json"); @@ -246,7 +252,7 @@ describe("buildWorkspaceSkillsPrompt", () => { ); const copy = vi.spyOn(fs, "cp"); - const usagePaths = await syncSkillsToWorkspace(syncParams); + const usagePaths = await syncWorkspaceSkills(syncParams); const copyCount = copy.mock.calls.length; copy.mockRestore(); @@ -273,13 +279,13 @@ describe("buildWorkspaceSkillsPrompt", () => { }); } const snapshotVersion = getSkillsSnapshotVersion(sourceWorkspace); - const firstSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const firstSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, skillFilter: ["alpha", "beta"], snapshotVersion, }); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir, @@ -290,14 +296,14 @@ describe("buildWorkspaceSkillsPrompt", () => { const preservedMarker = path.join(targetWorkspace, "skills", "alpha", "preserved.txt"); await fs.writeFile(preservedMarker, "preserved"); - const secondSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const secondSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, skillFilter: ["alpha", "gamma"], snapshotVersion, }); const copy = vi.spyOn(fs, "cp"); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir, @@ -323,12 +329,12 @@ describe("buildWorkspaceSkillsPrompt", () => { await writeSkill({ dir: sourceSkillDir, name: "alpha", description: "Alpha skill" }); await fs.writeFile(path.join(sourceSkillDir, "asset.txt"), "before"); await fs.writeFile(path.join(sourceSkillDir, "removed.txt"), "stale"); - const firstSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const firstSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: getSkillsSnapshotVersion(sourceWorkspace), }); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir, @@ -339,12 +345,12 @@ describe("buildWorkspaceSkillsPrompt", () => { await fs.writeFile(path.join(sourceSkillDir, "asset.txt"), "after"); await fs.rm(path.join(sourceSkillDir, "removed.txt")); const nextVersion = bumpSkillsSnapshotVersion({ workspaceDir: sourceWorkspace }); - const secondSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const secondSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: nextVersion, }); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir, @@ -368,7 +374,7 @@ describe("buildWorkspaceSkillsPrompt", () => { const sourceSkillDir = path.join(sourceWorkspace, "skills", "alpha"); await writeSkill({ dir: sourceSkillDir, name: "alpha", description: "Alpha skill" }); await fs.writeFile(path.join(sourceSkillDir, "asset.txt"), "before"); - const firstSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const firstSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: getSkillsSnapshotVersion(sourceWorkspace), @@ -380,22 +386,22 @@ describe("buildWorkspaceSkillsPrompt", () => { managedSkillsDir, skillsSnapshot: firstSnapshot, }; - await syncSkillsToWorkspace(syncParams); + await syncWorkspaceSkills(syncParams); await fs.writeFile(path.join(sourceSkillDir, "asset.txt"), "after"); const nextVersion = bumpSkillsSnapshotVersion({ workspaceDir: sourceWorkspace }); - const secondSnapshot = buildWorkspaceSkillSnapshot(sourceWorkspace, { + const secondSnapshot = buildSkillSnapshot(sourceWorkspace, { bundledSkillsDir, managedSkillsDir, snapshotVersion: nextVersion, }); const copy = vi.spyOn(fs, "cp").mockRejectedValueOnce(new Error("injected copy failure")); - await syncSkillsToWorkspace({ ...syncParams, skillsSnapshot: secondSnapshot }); + await syncWorkspaceSkills({ ...syncParams, skillsSnapshot: secondSnapshot }); copy.mockRestore(); const manifestPath = path.join(targetWorkspace, "skills", ".openclaw-sync.json"); expect(await pathExists(manifestPath)).toBe(false); - await syncSkillsToWorkspace({ ...syncParams, skillsSnapshot: secondSnapshot }); + await syncWorkspaceSkills({ ...syncParams, skillsSnapshot: secondSnapshot }); expect(await pathExists(manifestPath)).toBe(true); expect( await fs.readFile(path.join(targetWorkspace, "skills", "alpha", "asset.txt"), "utf8"), @@ -404,7 +410,7 @@ describe("buildWorkspaceSkillsPrompt", () => { const interruptedTemp = path.join(targetWorkspace, "skills", ".openclaw-sync.interrupted.tmp"); await fs.rm(manifestPath); await fs.writeFile(interruptedTemp, "partial"); - await syncSkillsToWorkspace({ ...syncParams, skillsSnapshot: secondSnapshot }); + await syncWorkspaceSkills({ ...syncParams, skillsSnapshot: secondSnapshot }); expect(await pathExists(interruptedTemp)).toBe(false); expect(await pathExists(manifestPath)).toBe(true); }); @@ -442,7 +448,7 @@ describe("buildWorkspaceSkillsPrompt", () => { description: "Dot variant", }); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, agentId: "alpha", @@ -613,7 +619,7 @@ describe("buildWorkspaceSkillsPrompt", () => { metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}', }); - await syncSkillsToWorkspace({ + await syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, agentId: "alpha", @@ -661,7 +667,7 @@ describe("buildWorkspaceSkillsPrompt", () => { ); await withEnvAsync({ HOME: sourceWorkspace }, () => - syncSkillsToWorkspace({ + syncWorkspaceSkills({ sourceWorkspaceDir: sourceWorkspace, targetWorkspaceDir: targetWorkspace, bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), @@ -683,3 +689,154 @@ describe("buildWorkspaceSkillsPrompt", () => { ).toContain("Managed symlink target"); }); }); + +describe("syncWorkspaceSkills for plugin skills", () => { + it("syncs plugin skills from symlinked directories to sandbox workspace", async () => { + const sourceWorkspace = await createCaseDir("source"); + const targetWorkspace = await createCaseDir("target"); + + const realPluginSkillDir = await createCaseDir("real-plugin-skill"); + await writeSkill({ + dir: realPluginSkillDir, + name: "wiki-maintainer", + description: "Wiki maintenance skill for sandboxed agents", + }); + + const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); + await fs.mkdir(pluginSkillsDir, { recursive: true }); + const symlinkPath = path.join(pluginSkillsDir, "wiki-maintainer"); + + await fs.symlink( + realPluginSkillDir, + symlinkPath, + process.platform === "win32" ? "junction" : "dir", + ); + + mockResolvePluginSkillDirs.mockReturnValueOnce([realPluginSkillDir]); + + const skillUsagePaths = await syncWorkspaceSkills({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + pluginSkillsDir, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }); + + const syncedSkillDir = path.join(targetWorkspace, "skills", "wiki-maintainer"); + const syncedSkillMd = path.join(syncedSkillDir, "SKILL.md"); + const syncedStat = await fs.lstat(syncedSkillDir); + const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, { + bundledSkillsDir: path.join(targetWorkspace, ".bundled"), + managedSkillsDir: path.join(targetWorkspace, ".managed"), + }).replaceAll("\\", "/"); + + expect(await pathExists(syncedSkillMd)).toBe(true); + expect(syncedStat.isSymbolicLink()).toBe(false); + expect(prompt).toContain("Wiki maintenance skill for sandboxed agents"); + expect(prompt).toContain("skills/wiki-maintainer/SKILL.md"); + expect(prompt).not.toContain(realPluginSkillDir.replaceAll("\\", "/")); + expect(prompt).not.toContain(pluginSkillsDir.replaceAll("\\", "/")); + expect(prompt).not.toContain(symlinkPath.replaceAll("\\", "/")); + expect(skillUsagePaths).toEqual([ + { + readPath: syncedSkillMd, + skillFile: path.join(realPluginSkillDir, "SKILL.md"), + skillName: "wiki-maintainer", + skillSource: "workspace", + }, + ]); + }); + + it("syncs multiple plugin skills directories to sandbox workspace", async () => { + const sourceWorkspace = await createCaseDir("source-multi"); + const targetWorkspace = await createCaseDir("target-multi"); + + // Create multiple real plugin skill directories + const realSkillA = await createCaseDir("skill-a"); + await writeSkill({ + dir: realSkillA, + name: "browser-automation", + description: "Browser automation skill", + }); + + const realSkillB = await createCaseDir("skill-b"); + await writeSkill({ + dir: realSkillB, + name: "obsidian-vault", + description: "Obsidian vault maintenance skill", + }); + + // Create plugin-skills directory with symlinks + const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); + await fs.mkdir(pluginSkillsDir, { recursive: true }); + + await fs.symlink( + realSkillA, + path.join(pluginSkillsDir, "browser-automation"), + process.platform === "win32" ? "junction" : "dir", + ); + await fs.symlink( + realSkillB, + path.join(pluginSkillsDir, "obsidian-vault"), + process.platform === "win32" ? "junction" : "dir", + ); + + mockResolvePluginSkillDirs.mockReturnValueOnce([realSkillA, realSkillB]); + + await syncWorkspaceSkills({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + pluginSkillsDir, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }); + + // Both skills should be synced + expect( + await pathExists(path.join(targetWorkspace, "skills", "browser-automation", "SKILL.md")), + ).toBe(true); + expect( + await pathExists(path.join(targetWorkspace, "skills", "obsidian-vault", "SKILL.md")), + ).toBe(true); + }); + + it("does not sync plugin skills that escape allowed root", async () => { + const sourceWorkspace = await createCaseDir("source-escape"); + const targetWorkspace = await createCaseDir("target-escape"); + + // Create a skill outside any allowed root + const outsideRoot = await createCaseDir("outside-root"); + const escapedSkillDir = path.join(outsideRoot, "escaped-skill"); + await writeSkill({ + dir: escapedSkillDir, + name: "escaped-skill", + description: "Should not be synced", + }); + + // Create plugin-skills with symlink to escaped skill + const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills"); + await fs.mkdir(pluginSkillsDir, { recursive: true }); + await fs.symlink( + escapedSkillDir, + path.join(pluginSkillsDir, "escaped-skill"), + process.platform === "win32" ? "junction" : "dir", + ); + + // Mock returns an allowed root that doesn't include the escaped skill + const allowedRoot = await createCaseDir("allowed-root"); + mockResolvePluginSkillDirs.mockReturnValueOnce([allowedRoot]); + + await syncWorkspaceSkills({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + pluginSkillsDir, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }); + + // Escaped skill should NOT be synced + expect( + await pathExists(path.join(targetWorkspace, "skills", "escaped-skill", "SKILL.md")), + ).toBe(false); + }); +}); diff --git a/src/skills/loading/workspace-skill-sync.runtime.ts b/src/skills/loading/workspace-skill-sync.runtime.ts new file mode 100644 index 000000000000..68b4ac5f8262 --- /dev/null +++ b/src/skills/loading/workspace-skill-sync.runtime.ts @@ -0,0 +1,278 @@ +// Sandbox workspace skill synchronization is deferred behind the sandbox runtime boundary. +import fs from "node:fs"; +import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { resolveSandboxPath } from "../../agents/sandbox-paths.js"; +import { canonicalizePath } from "../../agents/utils/paths.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { tryReadJson, writeJson } from "../../infra/json-files.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { resolveUserPath } from "../../utils.js"; +import { getSkillsSnapshotVersion } from "../runtime/refresh-state.js"; +import type { + SkillEligibilityContext, + SkillEntry, + SkillSnapshot, + SkillUsagePath, +} from "../types.js"; +import { resolveSkillKey } from "./frontmatter.js"; +import { serializeByKey } from "./serialize.js"; +import { resolveSkillTelemetrySource } from "./source.js"; +import { loadWorkspaceSkills } from "./workspace-skill-loader.js"; + +const fsp = fs.promises; +const skillsLogger = createSubsystemLogger("skills"); + +function resolveUniqueSyncedSkillDirName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base); + return base; + } + for (let index = 2; ; index += 1) { + const candidate = `${base}-${index}`; + if (!used.has(candidate)) { + used.add(candidate); + return candidate; + } + } +} + +const SYNCED_SKILLS_MANIFEST_NAME = ".openclaw-sync.json"; + +type SyncedSkillsManifest = { + entryKeys: string[]; + skillsVersion: number; +}; + +const syncedSkillsUsageCache = new Map< + string, + { + destinations: Map; + manifestKey: string; + skillUsagePaths: SkillUsagePath[]; + } +>(); + +function resolveSyncedSkillIdentity(skillKey: string, skillName: string): string { + return JSON.stringify([skillKey, skillName]); +} + +function parseSyncedSkillsManifest(value: unknown): SyncedSkillsManifest | null { + if ( + !isRecord(value) || + typeof value.skillsVersion !== "number" || + !Number.isFinite(value.skillsVersion) || + !Array.isArray(value.entryKeys) || + !value.entryKeys.every((entry) => typeof entry === "string") + ) { + return null; + } + return { entryKeys: value.entryKeys, skillsVersion: value.skillsVersion } as SyncedSkillsManifest; +} + +function resolveSyncedSkillDestinationPath(params: { + targetSkillsDir: string; + entry: SkillEntry; + usedDirNames: Set; +}): string | null { + const sourceDirName = ( + params.entry.syncDirName ?? path.basename(params.entry.skill.baseDir) + ).trim(); + if (!sourceDirName || sourceDirName === "." || sourceDirName === "..") { + return null; + } + const uniqueDirName = resolveUniqueSyncedSkillDirName(sourceDirName, params.usedDirNames); + return resolveSandboxPath({ + filePath: uniqueDirName, + cwd: params.targetSkillsDir, + root: params.targetSkillsDir, + }).resolved; +} + +async function ensureSyncedSkillsDirectory(targetSkillsDir: string): Promise { + let stats: fs.Stats; + try { + stats = await fsp.lstat(targetSkillsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + await fsp.mkdir(targetSkillsDir, { recursive: true }); + return; + } + + if (!stats.isDirectory() || stats.isSymbolicLink()) { + await fsp.rm(targetSkillsDir, { recursive: true, force: true }); + await fsp.mkdir(targetSkillsDir, { recursive: true }); + } +} + +export async function syncWorkspaceSkills(params: { + sourceWorkspaceDir: string; + targetWorkspaceDir: string; + config?: OpenClawConfig; + skillFilter?: string[]; + agentId?: string; + eligibility?: SkillEligibilityContext; + managedSkillsDir?: string; + bundledSkillsDir?: string; + pluginSkillsDir?: string; + skillsSnapshot?: SkillSnapshot; +}): Promise { + const sourceDir = resolveUserPath(params.sourceWorkspaceDir); + const targetDir = resolveUserPath(params.targetWorkspaceDir); + if (sourceDir === targetDir) { + return []; + } + + return await serializeByKey(`syncSkills:${targetDir}`, async () => { + const targetSkillsDir = path.join(targetDir, "skills"); + const manifestPath = path.join(targetSkillsDir, SYNCED_SKILLS_MANIFEST_NAME); + const skillsVersion = getSkillsSnapshotVersion(sourceDir); + const skillsSnapshot = params.skillsSnapshot; + + await ensureSyncedSkillsDirectory(targetSkillsDir); + const manifest = parseSyncedSkillsManifest(await tryReadJson(manifestPath)); + const expectedManifestKey = + skillsSnapshot?.version === skillsVersion + ? JSON.stringify([ + skillsVersion, + skillsSnapshot.skills + .map((skill) => resolveSyncedSkillIdentity(skill.skillKey ?? skill.name, skill.name)) + .toSorted(), + ]) + : undefined; + const cachedUsage = syncedSkillsUsageCache.get(targetSkillsDir); + const manifestKey = manifest + ? JSON.stringify([manifest.skillsVersion, manifest.entryKeys]) + : undefined; + if ( + expectedManifestKey && + manifestKey === expectedManifestKey && + cachedUsage?.manifestKey === manifestKey + ) { + return cachedUsage.skillUsagePaths.map((entry) => ({ ...entry })); + } + + const entries = loadWorkspaceSkills(sourceDir, { + config: params.config, + skillFilter: params.skillFilter, + agentId: params.agentId, + eligibility: params.eligibility, + managedSkillsDir: params.managedSkillsDir, + bundledSkillsDir: params.bundledSkillsDir, + pluginSkillsDir: params.pluginSkillsDir, + }); + + const usedDirNames = new Set(); + const plans: Array<{ destinationPath?: string; entry: SkillEntry; identity: string }> = []; + for (const entry of entries) { + const identity = resolveSyncedSkillIdentity( + resolveSkillKey(entry.skill, entry), + entry.skill.name, + ); + if (entry.skill.filePath.startsWith("node://")) { + plans.push({ entry, identity }); + continue; + } + let destinationPath: string | null; + try { + destinationPath = resolveSyncedSkillDestinationPath({ + targetSkillsDir, + entry, + usedDirNames, + }); + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + skillsLogger.warn(`Failed to resolve safe destination for ${entry.skill.name}: ${message}`); + continue; + } + if (!destinationPath) { + skillsLogger.warn( + `Failed to resolve safe destination for ${entry.skill.name}: invalid source directory name`, + ); + continue; + } + plans.push({ destinationPath, entry, identity }); + } + + await fsp.rm(manifestPath, { force: true }); + const previousUsage = + manifest?.skillsVersion === skillsVersion && cachedUsage?.manifestKey === manifestKey + ? cachedUsage + : undefined; + syncedSkillsUsageCache.delete(targetSkillsDir); + const preservedDestinations = new Set( + plans.flatMap((plan) => { + const destination = plan.destinationPath ? path.basename(plan.destinationPath) : null; + return previousUsage?.destinations.get(plan.identity) === destination + ? destination + ? [destination] + : [] + : []; + }), + ); + for (const child of await fsp.readdir(targetSkillsDir)) { + if (!preservedDestinations.has(child)) { + await fsp.rm(path.join(targetSkillsDir, child), { recursive: true, force: true }); + } + } + + const skillUsagePaths: SkillUsagePath[] = []; + let copyFailed = false; + for (const plan of plans) { + const { destinationPath, entry } = plan; + if (!destinationPath) { + continue; + } + if (!preservedDestinations.has(path.basename(destinationPath))) { + try { + const syncSourceDir = entry.syncSourceDir ?? entry.skill.baseDir; + await fsp.cp(syncSourceDir, destinationPath, { + recursive: true, + force: true, + filter: (src) => { + const name = path.basename(src); + return !(name === ".git" || name === "node_modules"); + }, + }); + } catch (error) { + copyFailed = true; + const message = error instanceof Error ? error.message : JSON.stringify(error); + skillsLogger.warn(`Failed to copy ${entry.skill.name} to sandbox: ${message}`); + continue; + } + } + skillUsagePaths.push({ + readPath: path.join( + destinationPath, + path.relative(entry.skill.baseDir, entry.skill.filePath), + ), + skillFile: canonicalizePath(entry.skill.filePath), + skillName: entry.skill.name, + skillSource: resolveSkillTelemetrySource(entry.skill), + }); + } + if (!copyFailed) { + const nextManifest: SyncedSkillsManifest = { + entryKeys: plans.map((plan) => plan.identity).toSorted(), + skillsVersion, + }; + await writeJson(manifestPath, nextManifest, { trailingNewline: true }); + syncedSkillsUsageCache.set(targetSkillsDir, { + destinations: new Map( + plans.flatMap((plan) => + plan.destinationPath + ? [[plan.identity, path.basename(plan.destinationPath)] as const] + : [], + ), + ), + manifestKey: JSON.stringify([skillsVersion, nextManifest.entryKeys]), + skillUsagePaths, + }); + pruneMapToMaxSize(syncedSkillsUsageCache, 100); + } + return skillUsagePaths; + }); +} diff --git a/src/skills/loading/workspace.ts b/src/skills/loading/workspace.ts deleted file mode 100644 index 8f4e4d1f0d34..000000000000 --- a/src/skills/loading/workspace.ts +++ /dev/null @@ -1,2103 +0,0 @@ -// Workspace skill loading helpers discover and load skills from workspace directories. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeTrimmedStringList, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { resolveSandboxPath } from "../../agents/sandbox-paths.js"; -import { canonicalizePath } from "../../agents/utils/paths.js"; -import { isDefaultStateDir } from "../../config/paths.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { walkDirectorySync } from "../../infra/fs-safe.js"; -import { resolveOsHomeDir } from "../../infra/home-dir.js"; -import { tryReadJson, writeJson } from "../../infra/json-files.js"; -import { pruneMapToMaxSize } from "../../infra/map-size.js"; -import { isPathInside } from "../../infra/path-guards.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { CONFIG_DIR, resolveConfigDir, resolveUserPath } from "../../utils.js"; -import { - isSessionSkillEnabled, - resolveEffectiveAgentSkillFilter, - resolveEffectiveAgentSkillsLimits, -} from "../discovery/agent-filter.js"; -import { normalizeSkillFilter } from "../discovery/filter.js"; -import { filterPromptVisibleSkillEntries } from "../discovery/skill-index.js"; -import { getSkillsSnapshotVersion } from "../runtime/refresh-state.js"; -import { mergeRemoteNodeSkillEntries } from "../runtime/remote-skills.js"; -import type { - OpenClawSkillMetadata, - ParsedSkillFrontmatter, - SkillEligibilityContext, - SkillEntry, - SkillSnapshot, - SkillUsagePath, -} from "../types.js"; -import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js"; -import { getArchivedSkillFiles } from "../workshop/curator.js"; -import { resolveBundledSkillsDir } from "./bundled-dir.js"; -import { - hasUnavailableSkillSecretOwners, - isSkillSecretOwnerUnavailable, - resolveBundledAllowlist, - shouldIncludeSkill, -} from "./config.js"; -import { - resolveOpenClawMetadata, - resolveSkillInvocationPolicy, - resolveSkillKey, -} from "./frontmatter.js"; -import { - loadSkillsFromDirSafe, - readSkillFrontmatterSafe, - type LocalSkillLoadDiagnostic, -} from "./local-loader.js"; -import { resolvePluginSkillDirs } from "./plugin-skills.js"; -import { serializeByKey } from "./serialize.js"; -import { formatSkillsForPrompt, type Skill } from "./skill-contract.js"; -import { resolveSkillTelemetrySource } from "./source.js"; -import { resolveAllowedSkillSymlinkTargetRealPaths, tryRealpath } from "./symlink-targets.js"; - -const fsp = fs.promises; -const skillsLogger = createSubsystemLogger("skills"); -const SKILL_SOURCE_ORIGIN_RELATIVE_PATH = path.join(".openclaw", "source-origin.json"); -const MAX_SKILL_SOURCE_ORIGIN_BYTES = 16 * 1024; - -/** - * Replace OS home directory prefixes with `~` in skill file paths to - * reduce system prompt token usage while matching host file-tool expansion. - * - * Example: `/Users/alice/.bun/.../skills/github/SKILL.md` - * → `~/.bun/.../skills/github/SKILL.md` - * - * Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total. - */ -function resolveUserHomeDir(): string | undefined { - return resolveOsHomeDir(process.env, os.homedir); -} - -function resolveNativeUserHomeDir(): string | undefined { - try { - return path.resolve(os.homedir()); - } catch { - return undefined; - } -} - -function resolveCompactHomePrefixes(): string[] { - const homes = [resolveUserHomeDir(), resolveNativeUserHomeDir()].filter((home): home is string => - Boolean(home), - ); - const resolvedHomes = homes.map((home) => path.resolve(home)); - const realHomes = resolvedHomes - .map((home) => tryRealpath(home)) - .filter((home): home is string => Boolean(home)); - return uniqueStrings([...resolvedHomes, ...realHomes]).toSorted((a, b) => b.length - a.length); -} - -function compactSkillPaths(skills: Skill[]): Skill[] { - const homes = resolveCompactHomePrefixes(); - if (homes.length === 0) { - return skills; - } - const preservedRoots = resolvePreservedPromptSkillPathRoots(); - const tildeRoots = resolvePromptTildeRoots(); - return skills.map((s) => ({ - ...s, - filePath: shouldPreservePromptSkillPath(s.filePath, preservedRoots, tildeRoots) - ? s.filePath - : compactHomePath(s.filePath, homes), - })); -} - -function resolvePreservedPromptSkillPathRoots(): string[] { - const configDir = resolveConfigDir(); - const promptSkillDirs = [ - path.resolve(configDir, "skills"), - path.resolve(configDir, "plugin-skills"), - ]; - const realPromptSkillDirs = promptSkillDirs - .map((dir) => tryRealpath(dir)) - .filter((dir): dir is string => Boolean(dir)); - return uniqueStrings([...promptSkillDirs, ...realPromptSkillDirs]); -} - -function resolvePromptTildeRoots(): string[] { - const nativeHome = resolveNativeUserHomeDir(); - if (!nativeHome) { - return []; - } - const resolvedNativeHome = path.resolve(nativeHome); - if (isContainerStateHomeWherePromptTildeEscapes(resolvedNativeHome)) { - return []; - } - const realNativeHome = tryRealpath(resolvedNativeHome); - return uniqueStrings([resolvedNativeHome, ...(realNativeHome ? [realNativeHome] : [])]); -} - -function isContainerStateHomeWherePromptTildeEscapes(home: string): boolean { - const configDir = path.resolve(resolveConfigDir()); - return ( - home === "/data" && - (configDir === "/data/.openclaw" || isPathInside("/data/.openclaw", configDir)) - ); -} - -function shouldPreservePromptSkillPath( - filePath: string, - roots: readonly string[], - tildeRoots: readonly string[], -): boolean { - const resolvedFilePath = path.resolve(filePath); - const isManagedPromptSkillPath = roots.some( - (root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath), - ); - if (!isManagedPromptSkillPath) { - return false; - } - return !tildeRoots.some( - (root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath), - ); -} - -function compactHomePath(filePath: string, homes: readonly string[]): string { - for (const home of homes) { - for (const prefix of compactHomePrefixesForHome(home)) { - if (filePath.startsWith(prefix)) { - return "~/" + normalizeCompactedSkillPath(filePath.slice(prefix.length), prefix); - } - } - } - return filePath; -} - -function compactHomePrefixesForHome(home: string): string[] { - const prefixes = [home.endsWith(path.sep) ? home : home + path.sep]; - if (home.includes("\\") && !home.endsWith("\\")) { - prefixes.push(home + "\\"); - } - return prefixes; -} - -function normalizeCompactedSkillPath(filePath: string, matchedHomePrefix: string): string { - return matchedHomePrefix.includes("\\") ? filePath.replace(/\\/g, "/") : filePath; -} - -function compactPathForConsoleMessage(filePath: string): string { - return compactHomePath(filePath, resolveCompactHomePrefixes()); -} - -function warnInvalidSkillFrontmatter(source: string, diagnostic: LocalSkillLoadDiagnostic): void { - skillsLogger.warn("Skipping skill with invalid frontmatter.", { - source, - filePath: diagnostic.path, - error: diagnostic.message, - consoleMessage: - `Skipping skill with invalid frontmatter: ` + - `file=${compactPathForConsoleMessage(diagnostic.path)} error=${diagnostic.message}`, - }); -} - -function filterSkillEntries( - entries: SkillEntry[], - config?: OpenClawConfig, - skillFilter?: string[], - skillOverrides?: Readonly>, - eligibility?: SkillEligibilityContext, -): SkillEntry[] { - const bundledAllowlist = resolveBundledAllowlist(config); - let filtered = entries.filter((entry) => - shouldIncludeSkill({ entry, config, bundledAllowlist, eligibility }), - ); - if (skillFilter !== undefined || skillOverrides !== undefined) { - const normalized = normalizeSkillFilter(skillFilter) ?? []; - const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; - skillsLogger.debug(`Applying skill filter: ${label}`); - const resolvedFilter = skillFilter === undefined ? undefined : normalized; - filtered = filtered.filter((entry) => - isSessionSkillEnabled( - entry.skill.name, - resolvedFilter, - skillOverrides, - resolveSkillKey(entry.skill, entry), - ), - ); - skillsLogger.debug( - `After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`, - ); - } - return filtered; -} - -const DEFAULT_MAX_CANDIDATES_PER_ROOT = 300; -const DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE = 200; -const DEFAULT_MAX_SKILLS_IN_PROMPT = 150; -const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 18_000; -const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000; -const DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN = 1_000; -const DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN = 10_000; -// Match Codex's bounded recursive skills discovery without letting broad -// workspace roots turn into unbounded filesystem walks. -const MAX_GROUPED_SKILL_SCAN_DEPTH = 6; -const MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH = 2; - -type ResolvedSkillsLimits = { - maxCandidatesPerRoot: number; - maxSkillsLoadedPerSource: number; - maxSkillsInPrompt: number; - maxSkillsPromptChars: number; - maxSkillFileBytes: number; -}; - -type LoadedSkillRecord = { - skill: Skill; - frontmatter?: ParsedSkillFrontmatter; - syncSourceDir?: string; - syncDirName?: string; -}; - -type CandidateSkillDir = { - skillDir: string; - skillDirRealPath: string; - name: string; - skillMdRealPath: string; -}; - -type ChildDirectoryScan = { - dirs: string[]; - scannedEntryCount: number; - truncated: boolean; -}; - -type SkillDiscoveryBudget = { - remainingDirectoryScans: number; - remainingRawEntries: number; - truncated: boolean; -}; - -function resolveSkillsLimits(config?: OpenClawConfig, agentId?: string): ResolvedSkillsLimits { - const limits = config?.skills?.limits; - const agentSkillsLimits = resolveEffectiveAgentSkillsLimits(config, agentId); - return { - maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT, - maxSkillsLoadedPerSource: - limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE, - maxSkillsInPrompt: limits?.maxSkillsInPrompt ?? DEFAULT_MAX_SKILLS_IN_PROMPT, - maxSkillsPromptChars: - agentSkillsLimits?.maxSkillsPromptChars ?? - limits?.maxSkillsPromptChars ?? - DEFAULT_MAX_SKILLS_PROMPT_CHARS, - maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES, - }; -} - -function listChildDirectories( - dir: string, - opts?: { - followSymlinks?: boolean; - maxCandidateDirs?: number; - maxRawEntriesToScan?: number; - }, -): ChildDirectoryScan { - const maxRawEntriesToScan = - opts?.maxRawEntriesToScan === undefined - ? resolveRawEntryScanLimit(opts?.maxCandidateDirs) - : Math.max(0, opts.maxRawEntriesToScan); - const scan = walkDirectorySync(dir, { - maxDepth: 1, - maxEntries: maxRawEntriesToScan, - symlinks: opts?.followSymlinks === false ? "skip" : "follow", - include: (entry) => - entry.kind === "directory" && !entry.name.startsWith(".") && entry.name !== "node_modules", - }); - if (scan.scannedEntryCount === 0 && scan.entries.length === 0) { - return { dirs: [], scannedEntryCount: 0, truncated: false }; - } - return { - dirs: scan.entries.map((entry) => entry.name), - scannedEntryCount: scan.scannedEntryCount, - truncated: scan.truncated, - }; -} - -function resolveRawEntryScanLimit(maxCandidateDirs: number | undefined): number { - if (maxCandidateDirs === undefined) { - return Number.POSITIVE_INFINITY; - } - const normalized = Math.max(0, maxCandidateDirs); - if (normalized === 0) { - return 0; - } - return Math.min( - DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN, - Math.max(DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN, normalized * 10), - ); -} - -function createSkillDiscoveryBudget(maxCandidateDirs: number): SkillDiscoveryBudget { - const normalized = Math.max(0, maxCandidateDirs); - return { - remainingDirectoryScans: normalized * MAX_GROUPED_SKILL_SCAN_DEPTH, - remainingRawEntries: resolveRawEntryScanLimit(normalized) * (normalized + 1), - truncated: false, - }; -} - -function listBudgetedChildDirectories( - dir: string, - budget: SkillDiscoveryBudget, - opts: { followSymlinks?: boolean; maxCandidateDirs: number }, -): ChildDirectoryScan { - if (budget.remainingDirectoryScans <= 0 || budget.remainingRawEntries <= 0) { - budget.truncated = true; - return { dirs: [], scannedEntryCount: 0, truncated: false }; - } - - budget.remainingDirectoryScans -= 1; - const maxRawEntriesToScan = Math.min( - resolveRawEntryScanLimit(opts.maxCandidateDirs), - budget.remainingRawEntries, - ); - const scan = listChildDirectories(dir, { - followSymlinks: opts.followSymlinks, - maxCandidateDirs: opts.maxCandidateDirs, - maxRawEntriesToScan, - }); - budget.remainingRawEntries = Math.max(0, budget.remainingRawEntries - scan.scannedEntryCount); - budget.truncated ||= scan.truncated; - return scan; -} - -function containsDiscoverableSkill( - dir: string, - opts: { - maxCandidateDirs: number; - maxSkillFileBytes?: number; - skipTopLevelDirName?: string; - }, -): boolean { - const discoveryBudget = createSkillDiscoveryBudget(opts.maxCandidateDirs); - const queue: Array<{ dir: string; depth: number }> = [{ dir, depth: 0 }]; - for (const candidate of queue) { - if (!candidate) { - continue; - } - if (candidate.depth > 0 && fs.existsSync(path.join(candidate.dir, "SKILL.md"))) { - if (hasLoadableSkillFrontmatter(dir, candidate.dir, opts.maxSkillFileBytes)) { - return true; - } - continue; - } - if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) { - continue; - } - if ( - hasCandidateSymlinkChild( - candidate.dir, - candidate.depth === 0 ? opts.skipTopLevelDirName : undefined, - resolveRawEntryScanLimit(opts.maxCandidateDirs), - ) - ) { - return true; - } - const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, { - followSymlinks: false, - maxCandidateDirs: opts.maxCandidateDirs, - }).dirs; - for (const childDir of childDirs.toSorted().slice(0, opts.maxCandidateDirs)) { - if (candidate.depth === 0 && childDir === opts.skipTopLevelDirName) { - continue; - } - queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 }); - } - } - return false; -} - -function hasCandidateSymlinkChild( - dir: string, - skipName: string | undefined, - maxEntriesToScan: number, -): boolean { - const maxEntries = Math.max(0, maxEntriesToScan); - if (maxEntries === 0) { - return false; - } - let handle: fs.Dir | undefined; - try { - handle = fs.opendirSync(dir); - for (let scanned = 0; scanned < maxEntries; scanned += 1) { - const entry = handle.readSync(); - if (!entry) { - break; - } - if (entry.name === skipName || entry.name.startsWith(".") || entry.name === "node_modules") { - continue; - } - if (entry.isSymbolicLink()) { - return true; - } - } - } catch { - return false; - } finally { - handle?.closeSync(); - } - return false; -} - -function hasLoadableSkillFrontmatter( - rootDir: string, - skillDir: string, - maxSkillFileBytes?: number, -): boolean { - const frontmatter = readSkillFrontmatterSafe({ - rootDir, - filePath: path.join(skillDir, "SKILL.md"), - maxBytes: maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES, - }); - const fallbackName = path.basename(skillDir).trim(); - const name = frontmatter?.name?.trim() || fallbackName; - return Boolean(name) && Boolean(frontmatter?.description?.trim()); -} - -function isSymlinkPath(filePath: string): boolean { - try { - return fs.lstatSync(filePath).isSymbolicLink(); - } catch { - return false; - } -} - -function buildEscapedSkillPathReason(params: { source: string; candidatePath: string }): { - reason: string; - consoleHint: string; -} { - const candidateIsSymlink = isSymlinkPath(params.candidatePath); - if (params.source === "openclaw-bundled" && candidateIsSymlink) { - return { - reason: "bundled-symlink-escape", - consoleHint: - "reason=bundled-symlink-escape hint=likely-stray-local-symlink-or-checkout-mutation", - }; - } - if (candidateIsSymlink) { - return { - reason: "symlink-escape", - consoleHint: "reason=symlink-escape", - }; - } - if (params.source === "openclaw-bundled") { - return { - reason: "bundled-root-escape", - consoleHint: - "reason=bundled-root-escape hint=likely-stray-local-symlink-or-checkout-mutation", - }; - } - return { - reason: "path-escape", - consoleHint: "reason=path-escape", - }; -} - -function warnEscapedSkillPath(params: { - source: string; - rootDir: string; - rootRealPath: string; - candidatePath: string; - candidateRealPath: string; -}) { - const compactRootDir = compactPathForConsoleMessage(params.rootDir); - const compactRootRealPath = compactPathForConsoleMessage(params.rootRealPath); - const compactCandidatePath = compactPathForConsoleMessage(params.candidatePath); - const compactCandidateRealPath = compactPathForConsoleMessage(params.candidateRealPath); - const rootResolved = - path.resolve(params.rootDir) === params.rootRealPath - ? "" - : ` rootResolved=${compactRootRealPath}`; - const escapeReason = buildEscapedSkillPathReason({ - source: params.source, - candidatePath: params.candidatePath, - }); - skillsLogger.warn("Skipping escaped skill path outside its configured root.", { - source: params.source, - rootDir: params.rootDir, - rootRealPath: params.rootRealPath, - path: params.candidatePath, - realPath: params.candidateRealPath, - reason: escapeReason.reason, - consoleMessage: - `Skipping escaped skill path outside its configured root: ` + - `source=${params.source} root=${compactRootDir}${rootResolved} ` + - `${escapeReason.consoleHint} requested=${compactCandidatePath} ` + - `resolved=${compactCandidateRealPath}`, - }); -} - -function resolveContainedSkillPath(params: { - source: string; - rootDir: string; - rootRealPath: string; - candidatePath: string; - allowedSymlinkTargetRealPaths?: readonly string[]; -}): string | null { - const candidateRealPath = tryRealpath(params.candidatePath); - if (!candidateRealPath) { - return null; - } - if ( - isPathInside(params.rootRealPath, candidateRealPath) || - isPathInsideAnyRoot(params.allowedSymlinkTargetRealPaths ?? [], candidateRealPath) - ) { - return candidateRealPath; - } - warnEscapedSkillPath({ - source: params.source, - rootDir: params.rootDir, - rootRealPath: params.rootRealPath, - candidatePath: path.resolve(params.candidatePath), - candidateRealPath, - }); - return null; -} - -function resolveNestedSkillsRoot( - dir: string, - opts?: { - maxEntriesToScan?: number; - maxSkillFileBytes?: number; - }, -): { baseDir: string; note?: string } { - if (hasLoadableSkillFrontmatter(dir, dir, opts?.maxSkillFileBytes)) { - return { baseDir: dir }; - } - const rootSkillMdExists = fs.existsSync(path.join(dir, "SKILL.md")); - const nested = path.join(dir, "skills"); - try { - if (!fs.existsSync(nested) || !fs.statSync(nested).isDirectory()) { - return { baseDir: dir }; - } - } catch { - return { baseDir: dir }; - } - - const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100); - if ( - !rootSkillMdExists && - containsDiscoverableSkill(dir, { - maxCandidateDirs: scanLimit, - maxSkillFileBytes: opts?.maxSkillFileBytes, - skipTopLevelDirName: "skills", - }) - ) { - return { baseDir: dir }; - } - - // Heuristic: if `dir/skills` contains any discoverable SKILL.md within the - // bounded skill depth, treat `dir/skills` as the real root. Use the same - // child-directory filter as discovery so ignored folders cannot re-root. - const discoveryBudget = createSkillDiscoveryBudget(scanLimit); - const queue: Array<{ dir: string; depth: number }> = [{ dir: nested, depth: 0 }]; - for (const candidate of queue) { - if (!candidate) { - continue; - } - if (hasLoadableSkillFrontmatter(nested, candidate.dir, opts?.maxSkillFileBytes)) { - return { baseDir: nested, note: `Detected nested skills root at ${nested}` }; - } - if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) { - continue; - } - const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, { - followSymlinks: false, - maxCandidateDirs: scanLimit, - }).dirs; - for (const childDir of childDirs.toSorted().slice(0, scanLimit)) { - queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 }); - } - } - return { baseDir: dir }; -} - -function unwrapLoadedSkillRecords(loaded: unknown): LoadedSkillRecord[] { - if (Array.isArray(loaded)) { - return (loaded as Skill[]).map((skill) => ({ skill })); - } - if (loaded && typeof loaded === "object" && "skills" in loaded) { - const skills = (loaded as { skills?: unknown }).skills; - if (Array.isArray(skills)) { - const loadedResult = loaded as { frontmatterByFilePath?: unknown }; - const frontmatterByFilePath = - loadedResult.frontmatterByFilePath instanceof Map - ? (loadedResult.frontmatterByFilePath as ReadonlyMap) - : undefined; - return (skills as Skill[]).map((skill) => ({ - skill, - frontmatter: frontmatterByFilePath?.get(skill.filePath), - })); - } - } - return []; -} - -function loadContainedSkillRecords(params: { - skillDir: string; - source: string; - maxSkillFileBytes: number; - canonicalSkillDir?: string; -}): LoadedSkillRecord[] { - const expectedBaseDir = path.resolve(params.skillDir); - const loaded = loadSkillsFromDirSafe({ - dir: params.skillDir, - source: params.source, - maxBytes: params.maxSkillFileBytes, - onDiagnostic: (diagnostic) => warnInvalidSkillFrontmatter(params.source, diagnostic), - }); - const records = unwrapLoadedSkillRecords(loaded).filter( - (record) => path.resolve(record.skill.baseDir) === expectedBaseDir, - ); - const canonicalSkillDir = params.canonicalSkillDir; - return canonicalSkillDir - ? records.map((record) => canonicalizeLoadedSkillRecord(record, canonicalSkillDir)) - : records; -} - -function readSourceInstallSkillKey(skillDir: string): string | undefined { - try { - const sourceOriginPath = path.join(skillDir, SKILL_SOURCE_ORIGIN_RELATIVE_PATH); - const stat = fs.lstatSync(sourceOriginPath); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_SKILL_SOURCE_ORIGIN_BYTES) { - return undefined; - } - const skillDirRealPath = tryRealpath(skillDir); - const sourceOriginRealPath = tryRealpath(sourceOriginPath); - if ( - !skillDirRealPath || - !sourceOriginRealPath || - !isPathInside(skillDirRealPath, sourceOriginRealPath) - ) { - return undefined; - } - const raw = fs.readFileSync(sourceOriginPath, "utf8"); - const parsed = JSON.parse(raw) as { slug?: unknown }; - return normalizeOptionalString(parsed.slug); - } catch { - return undefined; - } -} - -function resolveSkillEntryMetadata(params: { - frontmatter: ParsedSkillFrontmatter; - skillDir: string; -}): OpenClawSkillMetadata | undefined { - const metadata = resolveOpenClawMetadata(params.frontmatter); - if (metadata?.skillKey) { - return metadata; - } - const sourceInstallSkillKey = readSourceInstallSkillKey(params.skillDir); - if (!sourceInstallSkillKey) { - return metadata; - } - return { - ...metadata, - skillKey: sourceInstallSkillKey, - }; -} - -function canonicalizeLoadedSkillRecord( - record: LoadedSkillRecord, - canonicalSkillDir: string, -): LoadedSkillRecord { - const originalBaseDir = path.resolve(record.skill.baseDir); - const canonicalBaseDir = path.resolve(canonicalSkillDir); - if (originalBaseDir === canonicalBaseDir) { - return record; - } - const filePath = path.join( - canonicalBaseDir, - path.relative(originalBaseDir, record.skill.filePath), - ); - return { - ...record, - syncSourceDir: canonicalBaseDir, - syncDirName: path.basename(originalBaseDir), - skill: { - ...record.skill, - filePath, - baseDir: canonicalBaseDir, - sourceInfo: record.skill.sourceInfo - ? { - ...record.skill.sourceInfo, - path: filePath, - baseDir: canonicalBaseDir, - } - : record.skill.sourceInfo, - }, - }; -} - -/** - * Sets only the sync source directory for a skill record, without modifying - * the baseDir or filePath. This is used for plugin skills where the symlink - * path should be preserved for display purposes, but the real path is needed - * for syncing to the sandbox workspace. - */ -function setSyncSourceForPluginSkill( - record: LoadedSkillRecord, - syncSourceDir: string, -): LoadedSkillRecord { - return { - ...record, - syncSourceDir, - syncDirName: path.basename(record.skill.baseDir), - }; -} - -function isPathInsideAnyRoot(rootRealPaths: readonly string[], candidateRealPath: string): boolean { - return rootRealPaths.some((rootRealPath) => isPathInside(rootRealPath, candidateRealPath)); -} - -function shouldEnforceConfiguredSkillRootContainment(source: string): boolean { - return source !== "openclaw-managed" && source !== "agents-skills-personal"; -} - -function shouldUseConfiguredSymlinkTargets(source: string): boolean { - return ( - source === "openclaw-workspace" || - source === "openclaw-extra" || - source === "agents-skills-project" - ); -} - -function resolveSkillRootCandidatePath(params: { - source: string; - rootDir: string; - rootRealPath: string; - candidatePath: string; - allowedSymlinkTargetRealPaths: readonly string[]; -}): string | null { - if (!shouldEnforceConfiguredSkillRootContainment(params.source)) { - return tryRealpath(params.candidatePath); - } - return resolveContainedSkillPath({ - source: params.source, - rootDir: params.rootDir, - rootRealPath: params.rootRealPath, - candidatePath: params.candidatePath, - allowedSymlinkTargetRealPaths: shouldUseConfiguredSymlinkTargets(params.source) - ? params.allowedSymlinkTargetRealPaths - : [], - }); -} - -function canonicalSkillDirForSource(source: string, skillDirRealPath: string): string | undefined { - return shouldEnforceConfiguredSkillRootContainment(source) ? undefined : skillDirRealPath; -} - -function resolveSkillFilePath(params: { - source: string; - skillDir: string; - skillDirRealPath: string; - candidatePath: string; -}): string | null { - return resolveContainedSkillPath({ - source: params.source, - rootDir: params.skillDir, - rootRealPath: params.skillDirRealPath, - candidatePath: params.candidatePath, - }); -} - -function resolvePluginSkillRootRealPaths(pluginSkillDirs: readonly string[]): string[] { - return uniqueStrings( - pluginSkillDirs.map((dir) => tryRealpath(dir)).filter((dir): dir is string => Boolean(dir)), - ); -} - -function loadGeneratedPluginSkillRecords(params: { - pluginSkillsDir: string; - pluginSkillDirs: readonly string[]; - source: string; - limits: ResolvedSkillsLimits; -}): LoadedSkillRecord[] { - const allowedRootRealPaths = resolvePluginSkillRootRealPaths(params.pluginSkillDirs); - if (allowedRootRealPaths.length === 0) { - return []; - } - - const rootDir = path.resolve(params.pluginSkillsDir); - if (!fs.existsSync(rootDir)) { - return []; - } - const rootRealPath = tryRealpath(rootDir) ?? rootDir; - const maxCandidatesPerRoot = Math.max(0, params.limits.maxCandidatesPerRoot); - const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource); - const childDirScan = listChildDirectories(rootDir, { - maxCandidateDirs: maxCandidatesPerRoot, - }); - const childDirs = - maxSkillsLoadedPerSource === 0 - ? [] - : childDirScan.dirs.toSorted().slice(0, maxCandidatesPerRoot); - const loadedSkills: LoadedSkillRecord[] = []; - - for (const name of childDirs) { - const skillDir = path.join(rootDir, name); - if (!isSymlinkPath(skillDir)) { - continue; - } - const skillDirRealPath = tryRealpath(skillDir); - if (!skillDirRealPath || !isPathInsideAnyRoot(allowedRootRealPaths, skillDirRealPath)) { - if (skillDirRealPath) { - warnEscapedSkillPath({ - source: params.source, - rootDir, - rootRealPath, - candidatePath: path.resolve(skillDir), - candidateRealPath: skillDirRealPath, - }); - } - continue; - } - - const skillMd = path.join(skillDir, "SKILL.md"); - let skillMdStat: fs.Stats; - try { - skillMdStat = fs.lstatSync(skillMd); - } catch { - continue; - } - if (!skillMdStat.isFile() || skillMdStat.isSymbolicLink()) { - continue; - } - const skillMdRealPath = tryRealpath(skillMd); - if (!skillMdRealPath || !isPathInside(skillDirRealPath, skillMdRealPath)) { - continue; - } - if (skillMdStat.size > params.limits.maxSkillFileBytes) { - skillsLogger.warn("Skipping skill due to oversized SKILL.md.", { - skill: name, - filePath: skillMd, - size: skillMdStat.size, - maxSkillFileBytes: params.limits.maxSkillFileBytes, - }); - continue; - } - - // Plugin skills live as symlinks under ~/.openclaw/plugin-skills/, so - // skillDir is the symlink path while skillDirRealPath is the real target. - // We set syncSourceDir to the real path so syncSkillsToWorkspace can copy - // the actual skill directory into the sandbox workspace, but we preserve - // the symlink path as baseDir for display purposes. Without this, - // sandboxed agents see host-only symlink paths in and - // every read of the SKILL.md fails with "Path escapes sandbox root". - // skillDirRealPath is safe to use here because it was already validated - // against allowedRootRealPaths above. - const loadedRecords = loadContainedSkillRecords({ - skillDir, - source: params.source, - maxSkillFileBytes: params.limits.maxSkillFileBytes, - }); - loadedSkills.push( - ...loadedRecords.map((record) => setSyncSourceForPluginSkill(record, skillDirRealPath)), - ); - if (loadedSkills.length >= maxSkillsLoadedPerSource) { - break; - } - } - - if (loadedSkills.length > maxSkillsLoadedPerSource) { - return loadedSkills - .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) - .slice(0, maxSkillsLoadedPerSource); - } - return loadedSkills; -} - -function loadSkillEntries( - workspaceDir: string, - opts?: { - config?: OpenClawConfig; - agentId?: string; - managedSkillsDir?: string; - bundledSkillsDir?: string; - pluginSkillsDir?: string; - workspaceOnly?: boolean; - includeArchived?: boolean; - }, -): SkillEntry[] { - const limits = resolveSkillsLimits(opts?.config, opts?.agentId); - const allowedSymlinkTargetRealPaths = resolveAllowedSkillSymlinkTargetRealPaths(opts?.config); - - const loadSkills = (params: { dir: string; source: string }): LoadedSkillRecord[] => { - const rootDir = path.resolve(params.dir); - if (!fs.existsSync(rootDir)) { - return []; - } - const rootRealPath = tryRealpath(rootDir) ?? rootDir; - const resolved = resolveNestedSkillsRoot(params.dir, { - maxEntriesToScan: limits.maxCandidatesPerRoot, - maxSkillFileBytes: limits.maxSkillFileBytes, - }); - const baseDir = resolved.baseDir; - const baseDirRealPath = resolveSkillRootCandidatePath({ - source: params.source, - rootDir, - rootRealPath, - candidatePath: baseDir, - allowedSymlinkTargetRealPaths, - }); - if (!baseDirRealPath) { - return []; - } - - // If the root itself is a skill directory, just load it directly (but enforce size cap). - const rootSkillMd = path.join(baseDir, "SKILL.md"); - if (fs.existsSync(rootSkillMd)) { - const rootSkillRealPath = resolveSkillFilePath({ - source: params.source, - skillDir: baseDir, - skillDirRealPath: baseDirRealPath, - candidatePath: rootSkillMd, - }); - if (!rootSkillRealPath) { - return []; - } - try { - const size = fs.statSync(rootSkillRealPath).size; - if (size > limits.maxSkillFileBytes) { - skillsLogger.warn("Skipping skills root due to oversized SKILL.md.", { - dir: baseDir, - filePath: rootSkillMd, - size, - maxSkillFileBytes: limits.maxSkillFileBytes, - }); - return []; - } - } catch { - return []; - } - - return loadContainedSkillRecords({ - skillDir: baseDir, - source: params.source, - maxSkillFileBytes: limits.maxSkillFileBytes, - canonicalSkillDir: canonicalSkillDirForSource(params.source, baseDirRealPath), - }); - } - - const maxCandidatesPerRoot = Math.max(0, limits.maxCandidatesPerRoot); - const maxSkillsLoadedPerSource = Math.max(0, limits.maxSkillsLoadedPerSource); - const nestedSkillsRootPath = path.resolve(baseDir, "skills"); - const baseDirIsNestedSkillsRoot = path.resolve(baseDir) === path.resolve(rootDir, "skills"); - const baseDirLooksLikeSkillsRoot = path.basename(baseDir) === "skills"; - const discoveryBudget = createSkillDiscoveryBudget(maxCandidatesPerRoot); - const childDirScan = listBudgetedChildDirectories(baseDir, discoveryBudget, { - maxCandidateDirs: maxCandidatesPerRoot, - }); - const childDirs = childDirScan.dirs; - const suspicious = childDirScan.truncated; - const sortedChildDirs = childDirs.toSorted(); - const limitedChildren = - maxSkillsLoadedPerSource === 0 ? [] : sortedChildDirs.slice(0, maxCandidatesPerRoot); - if ( - maxSkillsLoadedPerSource > 0 && - sortedChildDirs.includes("skills") && - !limitedChildren.includes("skills") - ) { - limitedChildren.push("skills"); - } - - if (suspicious) { - skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", { - dir: params.dir, - baseDir, - childDirCount: childDirs.length, - scannedEntryCount: childDirScan.scannedEntryCount, - maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot), - maxCandidatesPerRoot: limits.maxCandidatesPerRoot, - maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource, - }); - } else if (childDirs.length > maxCandidatesPerRoot) { - skillsLogger.warn("Skills root has many entries, truncating discovery.", { - dir: params.dir, - baseDir, - childDirCount: childDirs.length, - maxCandidatesPerRoot: limits.maxCandidatesPerRoot, - maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource, - }); - } - - const loadedSkills: LoadedSkillRecord[] = []; - const loadCandidateSkill = ({ - skillDir, - skillDirRealPath, - name, - skillMdRealPath, - }: CandidateSkillDir) => { - try { - const size = fs.statSync(skillMdRealPath).size; - if (size > limits.maxSkillFileBytes) { - skillsLogger.warn("Skipping skill due to oversized SKILL.md.", { - skill: name, - filePath: path.join(skillDir, "SKILL.md"), - size, - maxSkillFileBytes: limits.maxSkillFileBytes, - }); - return; - } - } catch { - return; - } - - loadedSkills.push( - ...loadContainedSkillRecords({ - skillDir, - source: params.source, - maxSkillFileBytes: limits.maxSkillFileBytes, - canonicalSkillDir: canonicalSkillDirForSource(params.source, skillDirRealPath), - }), - ); - }; - - const skillCandidates: CandidateSkillDir[] = []; - const scanQueue: Array<{ skillDir: string; name: string; depth: number }> = limitedChildren.map( - (name) => ({ - skillDir: path.join(baseDir, name), - name, - depth: name === "skills" && !fs.existsSync(path.join(baseDir, name, "SKILL.md")) ? 0 : 1, - }), - ); - - for (const candidate of scanQueue) { - if (!candidate) { - continue; - } - const skillDirRealPath = resolveSkillRootCandidatePath({ - source: params.source, - rootDir, - rootRealPath: baseDirRealPath, - candidatePath: candidate.skillDir, - allowedSymlinkTargetRealPaths, - }); - if (!skillDirRealPath) { - continue; - } - - const skillMd = path.join(candidate.skillDir, "SKILL.md"); - if (fs.existsSync(skillMd)) { - const skillMdRealPath = resolveSkillFilePath({ - source: params.source, - skillDir: candidate.skillDir, - skillDirRealPath, - candidatePath: skillMd, - }); - if (skillMdRealPath) { - skillCandidates.push({ - skillDir: candidate.skillDir, - skillDirRealPath, - name: candidate.name, - skillMdRealPath, - }); - } - continue; - } - - const candidatePath = path.resolve(candidate.skillDir); - const maxGroupedDepth = - params.source === "openclaw-extra" && - !baseDirIsNestedSkillsRoot && - !baseDirLooksLikeSkillsRoot && - candidatePath !== nestedSkillsRootPath && - !isPathInside(nestedSkillsRootPath, candidatePath) - ? MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH - : MAX_GROUPED_SKILL_SCAN_DEPTH; - if (candidate.depth >= maxGroupedDepth) { - continue; - } - - const nestedChildScan = listBudgetedChildDirectories(candidate.skillDir, discoveryBudget, { - maxCandidateDirs: maxCandidatesPerRoot, - }); - const nestedChildren = nestedChildScan.dirs; - const nestedSuspicious = nestedChildScan.truncated; - if (nestedSuspicious) { - skillsLogger.warn( - "Nested skills directory looks suspiciously large, truncating discovery.", - { - dir: params.dir, - baseDir, - nestedDir: candidate.skillDir, - nestedChildDirCount: nestedChildren.length, - scannedEntryCount: nestedChildScan.scannedEntryCount, - maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot), - maxCandidatesPerRoot: limits.maxCandidatesPerRoot, - maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource, - maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, - }, - ); - } else if (nestedChildren.length > maxCandidatesPerRoot) { - skillsLogger.warn("Nested skills directory has many entries, truncating discovery.", { - dir: params.dir, - baseDir, - nestedDir: candidate.skillDir, - nestedChildDirCount: nestedChildren.length, - maxCandidatesPerRoot: limits.maxCandidatesPerRoot, - maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource, - maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, - }); - } - - for (const nestedName of nestedChildren.toSorted().slice(0, maxCandidatesPerRoot)) { - scanQueue.push({ - skillDir: path.join(candidate.skillDir, nestedName), - name: `${candidate.name}/${nestedName}`, - depth: candidate.depth + 1, - }); - } - } - - for (const candidate of skillCandidates.toSorted((a, b) => a.name.localeCompare(b.name))) { - if (loadedSkills.length >= maxSkillsLoadedPerSource) { - break; - } - loadCandidateSkill(candidate); - } - - if (discoveryBudget.truncated) { - skillsLogger.warn("Skills root hit recursive discovery budget, truncating discovery.", { - dir: params.dir, - baseDir, - maxCandidatesPerRoot: limits.maxCandidatesPerRoot, - maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource, - maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH, - }); - } - - if (loadedSkills.length > maxSkillsLoadedPerSource) { - return loadedSkills - .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) - .slice(0, maxSkillsLoadedPerSource); - } - - return loadedSkills; - }; - - const workspaceOnly = opts?.workspaceOnly === true; - const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); - const workspaceSkillsDir = path.resolve(workspaceDir, "skills"); - const bundledSkillsDir = workspaceOnly - ? undefined - : (opts?.bundledSkillsDir ?? resolveBundledSkillsDir()); - const pluginSkillsDir = opts?.pluginSkillsDir ?? path.join(CONFIG_DIR, "plugin-skills"); - const extraDirsRaw = workspaceOnly ? [] : (opts?.config?.skills?.load?.extraDirs ?? []); - const extraDirs = normalizeTrimmedStringList(extraDirsRaw); - const pluginSkillDirs = workspaceOnly - ? [] - : resolvePluginSkillDirs({ - workspaceDir, - config: opts?.config, - pluginSkillsDir, - }); - const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs]; - - const bundledSkills = bundledSkillsDir - ? loadSkills({ - dir: bundledSkillsDir, - source: "openclaw-bundled", - }) - : []; - const extraSkills = [ - ...mergedExtraDirs.flatMap((dir) => { - const resolved = resolveUserPath(dir); - return loadSkills({ - dir: resolved, - source: "openclaw-extra", - }); - }), - ...loadGeneratedPluginSkillRecords({ - pluginSkillsDir, - pluginSkillDirs, - source: "openclaw-extra", - limits, - }), - ]; - const managedSkills = workspaceOnly - ? [] - : loadSkills({ - dir: managedSkillsDir, - source: "openclaw-managed", - }); - const osHomeDir = resolveUserHomeDir(); - const personalAgentsSkillsDir = osHomeDir - ? path.resolve(osHomeDir, ".agents", "skills") - : path.resolve(".agents", "skills"); - const personalAgentsSkills = - workspaceOnly || !isDefaultStateDir() - ? [] - : loadSkills({ - dir: personalAgentsSkillsDir, - source: "agents-skills-personal", - }); - const projectAgentsSkillsDir = path.resolve(workspaceDir, ".agents", "skills"); - const projectAgentsSkills = workspaceOnly - ? [] - : loadSkills({ - dir: projectAgentsSkillsDir, - source: "agents-skills-project", - }); - const workspaceSkills = loadSkills({ - dir: workspaceSkillsDir, - source: "openclaw-workspace", - }); - - const merged = new Map(); - const archivedSkillFiles = opts?.includeArchived ? null : getArchivedSkillFiles(); - const mergeRecord = (record: LoadedSkillRecord) => { - if (archivedSkillFiles?.has(canonicalizePath(record.skill.filePath))) { - return; - } - merged.set(record.skill.name, record); - }; - // Precedence: extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace - for (const record of extraSkills) { - mergeRecord(record); - } - for (const record of bundledSkills) { - mergeRecord(record); - } - for (const record of managedSkills) { - mergeRecord(record); - } - for (const record of personalAgentsSkills) { - mergeRecord(record); - } - for (const record of projectAgentsSkills) { - mergeRecord(record); - } - for (const record of workspaceSkills) { - mergeRecord(record); - } - - const skillEntries: SkillEntry[] = Array.from(merged.values()) - .toSorted((a, b) => a.skill.name.localeCompare(b.skill.name, "en")) - .map((record) => { - const skill = record.skill; - const frontmatter = - record.frontmatter ?? - readSkillFrontmatterSafe({ - rootDir: skill.baseDir, - filePath: skill.filePath, - maxBytes: limits.maxSkillFileBytes, - }) ?? - ({} as ParsedSkillFrontmatter); - const invocation = resolveSkillInvocationPolicy(frontmatter); - const entry: SkillEntry = { - skill, - frontmatter, - metadata: resolveSkillEntryMetadata({ frontmatter, skillDir: skill.baseDir }), - invocation, - exposure: { - includeInRuntimeRegistry: true, - // Freshly loaded entries preserve the documented disable-model-invocation - // contract, while legacy entries without exposure metadata still use - // the centralized prompt visibility fallback. - includeInAvailableSkillsPrompt: !invocation.disableModelInvocation, - userInvocable: invocation.userInvocable ?? true, - }, - }; - if (record.syncSourceDir !== undefined) { - entry.syncSourceDir = record.syncSourceDir; - } - if (record.syncDirName !== undefined) { - entry.syncDirName = record.syncDirName; - } - return entry; - }); - return skillEntries; -} - -function filterArchivedSkillEntries(entries: SkillEntry[]): SkillEntry[] { - // One discovery-level query covers prompts, commands, runtime entries, and sandbox sync. - const archivedSkillFiles = getArchivedSkillFiles(); - return entries.filter((entry) => !archivedSkillFiles.has(canonicalizePath(entry.skill.filePath))); -} - -function escapeXml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -const COMPACT_DESCRIPTION_MAX_CHARS = 220; -const COMPACT_DESCRIPTION_MIN_CHARS = 4; - -function truncateSkillDescription(description: string, maxChars: number): string { - const normalized = description.replace(/\s+/g, " ").trim(); - if (normalized.length <= maxChars) { - return normalized; - } - if (maxChars <= 3) { - return truncateUtf16Safe(normalized, maxChars); - } - return `${truncateUtf16Safe(normalized, maxChars - 3).trimEnd()}...`; -} - -/** - * Compact skill catalog with descriptions bounded independently from identities. - * A zero description budget preserves the previous name/location-only format. - */ -export function formatSkillsCompact( - skills: Skill[], - opts?: { descriptionMaxChars?: number }, -): string { - if (skills.length === 0) { - return ""; - } - const descriptionMaxChars = Math.max( - 0, - Math.floor(opts?.descriptionMaxChars ?? COMPACT_DESCRIPTION_MAX_CHARS), - ); - const lines = [ - "\n\nThe following skills provide specialized instructions for specific tasks.", - descriptionMaxChars > 0 - ? "Use the read tool to load a skill's file when the task matches its name or description." - : "Use the read tool to load a skill's file when the task matches its name.", - "If a skill's differs from a previous turn, re-read its SKILL.md before using it.", - "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", - "", - "", - ]; - for (const skill of skills) { - lines.push(" "); - lines.push(` ${escapeXml(skill.name)}`); - if (descriptionMaxChars > 0) { - const description = truncateSkillDescription(skill.description, descriptionMaxChars); - if (description) { - lines.push(` ${escapeXml(description)}`); - } - } - lines.push(` ${escapeXml(skill.filePath)}`); - if (skill.locationNote) { - lines.push(` ${escapeXml(skill.locationNote)}`); - } - if (skill.promptVersion) { - lines.push(` ${escapeXml(skill.promptVersion)}`); - } - lines.push(" "); - } - lines.push(""); - return lines.join("\n"); -} - -type SkillsPromptFormat = { kind: "full" } | { kind: "compact"; descriptionMaxChars: number }; - -function buildSkillsLimitNote(params: { - truncated: boolean; - format: SkillsPromptFormat; - included: number; - total: number; -}): string { - if (params.truncated) { - const compactDetails = - params.format.kind === "compact" - ? ` (compact format, ${params.format.descriptionMaxChars > 0 ? "descriptions shortened" : "descriptions omitted"})` - : ""; - return `⚠️ Skills truncated: included ${params.included} of ${params.total}${compactDetails}. Run \`openclaw skills check\` to audit.`; - } - if (params.format.kind === "compact") { - const compactDetails = - params.format.descriptionMaxChars > 0 ? "descriptions shortened" : "descriptions omitted"; - return `⚠️ Skills catalog using compact format (${compactDetails}). Run \`openclaw skills check\` to audit.`; - } - return ""; -} - -function buildRenderedSkillsPrompt(params: { - remoteNote?: string; - skills: Skill[]; - total: number; - format: SkillsPromptFormat; - includeLimitNote?: boolean; -}): string { - // resolveCodeModeSkills in src/agents/code-mode-skills.ts parses this exact format; update both together. - // The production-renderer parity test in src/agents/code-mode.test.ts enforces this coupling. - const truncated = params.skills.length < params.total; - const limitNote = - params.includeLimitNote === false - ? "" - : buildSkillsLimitNote({ - truncated, - format: params.format, - included: params.skills.length, - total: params.total, - }); - const catalog = - params.format.kind === "compact" - ? formatSkillsCompact(params.skills, { - descriptionMaxChars: params.format.descriptionMaxChars, - }) - : formatSkillsForPrompt(params.skills); - return [params.remoteNote, limitNote, catalog].filter(Boolean).join("\n"); -} - -function applySkillsPromptLimits(params: { - skills: Skill[]; - config?: OpenClawConfig; - agentId?: string; - remoteNote?: string; -}): string { - const limits = resolveSkillsLimits(params.config, params.agentId); - const total = params.skills.length; - const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt)); - - let skillsForPrompt = byCount; - - const renderWithinLimit = ( - skills: Skill[], - format: SkillsPromptFormat, - includeLimitNote = true, - ): string | undefined => { - // Optional context must disappear whole; clipping it could corrupt skill guidance or XML. - const remoteNotes = params.remoteNote ? [params.remoteNote, undefined] : [undefined]; - for (const remoteNote of remoteNotes) { - const prompt = buildRenderedSkillsPrompt({ - remoteNote, - skills, - total, - format, - includeLimitNote, - }); - if (prompt.length <= limits.maxSkillsPromptChars) { - return prompt; - } - } - return undefined; - }; - - const fitsFull = (skills: Skill[], includeLimitNote = true): boolean => - renderWithinLimit(skills, { kind: "full" }, includeLimitNote) !== undefined; - - const fitsCompact = ( - skills: Skill[], - descriptionMaxChars: number, - includeLimitNote = true, - ): boolean => - renderWithinLimit(skills, { kind: "compact", descriptionMaxChars }, includeLimitNote) !== - undefined; - - if (!fitsFull(skillsForPrompt)) { - // Identity coverage takes priority over descriptions. Find the same largest - // name/location/version prefix as the previous compact format before using - // any leftover budget for trigger guidance. - if (!fitsCompact(skillsForPrompt, 0)) { - let lo = 0; - let hi = skillsForPrompt.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (fitsCompact(skillsForPrompt.slice(0, mid), 0)) { - lo = mid; - } else { - hi = mid - 1; - } - } - skillsForPrompt = skillsForPrompt.slice(0, lo); - } - - if (skillsForPrompt.length === 0 && byCount.length > 0) { - // Keep complete skill instructions ahead of a notice when only one can fit. - const fullWithoutNotice = renderWithinLimit(byCount, { kind: "full" }, false); - if (fullWithoutNotice !== undefined) { - return fullWithoutNotice; - } - - let lo = 0; - let hi = byCount.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (fitsCompact(byCount.slice(0, mid), 0, false)) { - lo = mid; - } else { - hi = mid - 1; - } - } - if (lo > 0) { - skillsForPrompt = byCount.slice(0, lo); - } - } - - const includeLimitNote = fitsCompact(skillsForPrompt, 0); - let descriptionMaxChars = 0; - if ( - skillsForPrompt.length > 0 && - fitsCompact(skillsForPrompt, COMPACT_DESCRIPTION_MIN_CHARS, includeLimitNote) - ) { - let lo = COMPACT_DESCRIPTION_MIN_CHARS; - let hi = COMPACT_DESCRIPTION_MAX_CHARS; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (fitsCompact(skillsForPrompt, mid, includeLimitNote)) { - lo = mid; - } else { - hi = mid - 1; - } - } - descriptionMaxChars = lo; - } - return ( - renderWithinLimit( - skillsForPrompt, - { kind: "compact", descriptionMaxChars }, - includeLimitNote, - ) ?? "" - ); - } - - return renderWithinLimit(skillsForPrompt, { kind: "full" }) ?? ""; -} - -export function buildWorkspaceSkillSnapshot( - workspaceDir: string, - opts?: WorkspaceSkillBuildOptions & { snapshotVersion?: number }, -): SkillSnapshot { - const { eligible, prompt, resolvedSkills } = resolveWorkspaceSkillPromptState(workspaceDir, opts); - const skillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - return { - prompt, - skills: eligible.map((entry) => ({ - name: entry.skill.name, - skillKey: resolveSkillKey(entry.skill, entry), - primaryEnv: entry.metadata?.primaryEnv, - requiredEnv: entry.metadata?.requires?.env?.slice(), - })), - ...(skillFilter === undefined ? {} : { skillFilter }), - ...(opts?.skillOverrides ? { skillOverrides: opts.skillOverrides } : {}), - ...(opts?.eligibility?.nodeSkills - ? { nodeSkillsEligibility: opts.eligibility.nodeSkills } - : {}), - resolvedSkills, - version: opts?.snapshotVersion, - promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION, - }; -} - -export function buildWorkspaceSkillsPrompt( - workspaceDir: string, - opts?: WorkspaceSkillBuildOptions, -): string { - return resolveWorkspaceSkillPromptState(workspaceDir, opts).prompt; -} - -export const testing = { - compactHomePath, -}; - -type WorkspaceSkillBuildOptions = { - config?: OpenClawConfig; - managedSkillsDir?: string; - bundledSkillsDir?: string; - entries?: SkillEntry[]; - agentId?: string; - /** If provided, only include skills with these names */ - skillFilter?: string[]; - skillOverrides?: Record; - eligibility?: SkillEligibilityContext; -}; - -function resolveEffectiveWorkspaceSkillFilter( - opts?: WorkspaceSkillBuildOptions, -): string[] | undefined { - if (opts?.skillFilter !== undefined) { - return normalizeSkillFilter(opts.skillFilter); - } - if (!opts?.config || !opts.agentId) { - return undefined; - } - return resolveEffectiveAgentSkillFilter(opts.config, opts.agentId); -} - -function resolveWorkspaceSkillPromptState( - workspaceDir: string, - opts?: WorkspaceSkillBuildOptions, -): { - eligible: SkillEntry[]; - prompt: string; - resolvedSkills: Skill[]; -} { - const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - const skillEntries = opts?.entries - ? filterArchivedSkillEntries(opts.entries) - : mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { - canExec: opts?.eligibility?.nodeSkills?.canExec, - node: opts?.eligibility?.nodeSkills?.node, - }); - const eligible = filterSkillEntries( - skillEntries, - opts?.config, - effectiveSkillFilter, - opts?.skillOverrides, - opts?.eligibility, - ); - const promptEntries = filterPromptVisibleSkillEntries(eligible); - const remoteNote = opts?.eligibility?.remote?.note?.trim(); - const resolvedSkills = promptEntries.map((entry) => entry.skill); - // Derive prompt-facing skills with compacted paths (e.g. ~/...) once. - // Budget checks and final render both use this same representation so the - // tier decision is based on the exact strings that end up in the prompt. - // resolvedSkills keeps canonical paths for snapshot / runtime consumers. - const promptSkills = compactSkillPaths(resolvedSkills).toSorted((a, b) => - a.name.localeCompare(b.name, "en"), - ); - const prompt = applySkillsPromptLimits({ - skills: promptSkills, - config: opts?.config, - agentId: opts?.agentId, - remoteNote, - }); - return { eligible, prompt, resolvedSkills }; -} - -export function resolveSkillsPromptForRun(params: { - skillsSnapshot?: SkillSnapshot; - entries?: SkillEntry[]; - config?: OpenClawConfig; - workspaceDir: string; - agentId?: string; - eligibility?: SkillEligibilityContext; -}): string { - const snapshotPrompt = params.skillsSnapshot?.prompt?.trim(); - if (params.skillsSnapshot && !snapshotPrompt) { - return ""; - } - const snapshotHasLegacySkillIdentity = params.skillsSnapshot?.skills.some( - (skill) => !skill.skillKey, - ); - if (snapshotPrompt) { - const snapshotHasUnavailableSkill = - params.skillsSnapshot?.skills.some((skill) => - isSkillSecretOwnerUnavailable(skill.skillKey ?? skill.name), - ) || - (snapshotHasLegacySkillIdentity && hasUnavailableSkillSecretOwners()); - if ( - snapshotHasUnavailableSkill && - params.skillsSnapshot?.promptFormatVersion !== WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION - ) { - return ""; - } - if (snapshotHasLegacySkillIdentity && hasUnavailableSkillSecretOwners()) { - return ""; - } - const unavailableNames = new Set( - params.skillsSnapshot?.skills - .filter( - (skill) => skill.skillKey !== undefined && isSkillSecretOwnerUnavailable(skill.skillKey), - ) - .map((skill) => escapeXml(skill.name)), - ); - if (unavailableNames.size === 0) { - return snapshotPrompt; - } - const catalogOpen = ""; - const catalogClose = ""; - const catalogStart = snapshotPrompt.indexOf(catalogOpen); - const catalogEnd = snapshotPrompt.indexOf(catalogClose, catalogStart + catalogOpen.length); - if ( - catalogStart < 0 || - catalogEnd < 0 || - snapshotPrompt.includes(catalogOpen, catalogStart + catalogOpen.length) || - snapshotPrompt.includes(catalogClose, catalogEnd + catalogClose.length) - ) { - return ""; - } - const bodyStart = catalogStart + catalogOpen.length; - const catalogBody = snapshotPrompt.slice(bodyStart, catalogEnd); - const blockPattern = /\n[ ]{2}\n[\s\S]*?\n[ ]{2}<\/skill>/g; - let cursor = 0; - let filteredBody = ""; - for (const match of catalogBody.matchAll(blockPattern)) { - const gap = catalogBody.slice(cursor, match.index); - const block = match[0]; - const name = /^[ ]{4}(.*)<\/name>$/m.exec(block)?.[1]; - if (gap.trim() || !name) { - return ""; - } - filteredBody += gap; - if (!unavailableNames.has(name)) { - filteredBody += block; - } - cursor = (match.index ?? 0) + block.length; - } - const tail = catalogBody.slice(cursor); - if (tail.trim()) { - return ""; - } - return `${snapshotPrompt.slice(0, bodyStart)}${filteredBody}${tail}${snapshotPrompt.slice(catalogEnd)}`.trim(); - } - if (params.entries && params.entries.length > 0) { - const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, { - entries: params.entries, - config: params.config, - agentId: params.agentId, - eligibility: params.eligibility, - }); - return prompt.trim() ? prompt : ""; - } - return ""; -} - -export function loadWorkspaceSkillEntries( - workspaceDir: string, - opts?: { - config?: OpenClawConfig; - managedSkillsDir?: string; - bundledSkillsDir?: string; - pluginSkillsDir?: string; - skillFilter?: string[]; - skillOverrides?: Record; - agentId?: string; - eligibility?: SkillEligibilityContext; - workspaceOnly?: boolean; - includeArchived?: boolean; - }, -): SkillEntry[] { - const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { - canExec: opts?.eligibility?.nodeSkills?.canExec, - node: opts?.eligibility?.nodeSkills?.node, - }); - const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - if ( - effectiveSkillFilter === undefined && - opts?.skillOverrides === undefined && - opts?.eligibility === undefined - ) { - return entries; - } - return filterSkillEntries( - entries, - opts?.config, - effectiveSkillFilter, - opts?.skillOverrides, - opts?.eligibility, - ); -} - -export function loadVisibleWorkspaceSkillEntries( - workspaceDir: string, - opts?: { - config?: OpenClawConfig; - managedSkillsDir?: string; - bundledSkillsDir?: string; - skillFilter?: string[]; - skillOverrides?: Record; - agentId?: string; - eligibility?: SkillEligibilityContext; - }, -): SkillEntry[] { - const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { - canExec: opts?.eligibility?.nodeSkills?.canExec, - node: opts?.eligibility?.nodeSkills?.node, - }); - const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - return filterSkillEntries( - entries, - opts?.config, - effectiveSkillFilter, - opts?.skillOverrides, - opts?.eligibility, - ); -} - -function resolveUniqueSyncedSkillDirName(base: string, used: Set): string { - if (!used.has(base)) { - used.add(base); - return base; - } - for (let index = 2; index < 10_000; index += 1) { - const candidate = `${base}-${index}`; - if (!used.has(candidate)) { - used.add(candidate); - return candidate; - } - } - let fallbackIndex = 10_000; - let fallback = `${base}-${fallbackIndex}`; - while (used.has(fallback)) { - fallbackIndex += 1; - fallback = `${base}-${fallbackIndex}`; - } - used.add(fallback); - return fallback; -} - -const SYNCED_SKILLS_MANIFEST_NAME = ".openclaw-sync.json"; - -type SyncedSkillsManifest = { - entryKeys: string[]; - skillsVersion: number; -}; - -// Sandbox-writable manifests carry identities only. Safe destinations and read -// paths remain process-owned; a cache miss falls back to a full reconcile. -const syncedSkillsUsageCache = new Map< - string, - { - destinations: Map; - manifestKey: string; - skillUsagePaths: SkillUsagePath[]; - } ->(); - -function resolveSyncedSkillIdentity(skillKey: string, skillName: string): string { - return JSON.stringify([skillKey, skillName]); -} - -function parseSyncedSkillsManifest(value: unknown): SyncedSkillsManifest | null { - if ( - !isRecord(value) || - typeof value.skillsVersion !== "number" || - !Number.isFinite(value.skillsVersion) || - !Array.isArray(value.entryKeys) || - !value.entryKeys.every((entry) => typeof entry === "string") - ) { - return null; - } - return { entryKeys: value.entryKeys, skillsVersion: value.skillsVersion } as SyncedSkillsManifest; -} - -function resolveSyncedSkillDestinationPath(params: { - targetSkillsDir: string; - entry: SkillEntry; - usedDirNames: Set; -}): string | null { - const sourceDirName = ( - params.entry.syncDirName ?? path.basename(params.entry.skill.baseDir) - ).trim(); - if (!sourceDirName || sourceDirName === "." || sourceDirName === "..") { - return null; - } - const uniqueDirName = resolveUniqueSyncedSkillDirName(sourceDirName, params.usedDirNames); - return resolveSandboxPath({ - filePath: uniqueDirName, - cwd: params.targetSkillsDir, - root: params.targetSkillsDir, - }).resolved; -} - -async function ensureSyncedSkillsDirectory(targetSkillsDir: string): Promise { - let stats: fs.Stats; - try { - stats = await fsp.lstat(targetSkillsDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - await fsp.mkdir(targetSkillsDir, { recursive: true }); - return; - } - - if (!stats.isDirectory() || stats.isSymbolicLink()) { - await fsp.rm(targetSkillsDir, { recursive: true, force: true }); - await fsp.mkdir(targetSkillsDir, { recursive: true }); - } -} - -export async function syncSkillsToWorkspace(params: { - sourceWorkspaceDir: string; - targetWorkspaceDir: string; - config?: OpenClawConfig; - skillFilter?: string[]; - agentId?: string; - eligibility?: SkillEligibilityContext; - managedSkillsDir?: string; - bundledSkillsDir?: string; - pluginSkillsDir?: string; - skillsSnapshot?: SkillSnapshot; -}): Promise { - const sourceDir = resolveUserPath(params.sourceWorkspaceDir); - const targetDir = resolveUserPath(params.targetWorkspaceDir); - if (sourceDir === targetDir) { - return []; - } - - return await serializeByKey(`syncSkills:${targetDir}`, async () => { - const targetSkillsDir = path.join(targetDir, "skills"); - const manifestPath = path.join(targetSkillsDir, SYNCED_SKILLS_MANIFEST_NAME); - const skillsVersion = getSkillsSnapshotVersion(sourceDir); - const skillsSnapshot = params.skillsSnapshot; - - await ensureSyncedSkillsDirectory(targetSkillsDir); - const manifest = parseSyncedSkillsManifest(await tryReadJson(manifestPath)); - const expectedManifestKey = - skillsSnapshot?.version === skillsVersion - ? JSON.stringify([ - skillsVersion, - skillsSnapshot.skills - .map((skill) => resolveSyncedSkillIdentity(skill.skillKey ?? skill.name, skill.name)) - .toSorted(), - ]) - : undefined; - const cachedUsage = syncedSkillsUsageCache.get(targetSkillsDir); - const manifestKey = manifest - ? JSON.stringify([manifest.skillsVersion, manifest.entryKeys]) - : undefined; - if ( - expectedManifestKey && - manifestKey === expectedManifestKey && - cachedUsage?.manifestKey === manifestKey - ) { - return cachedUsage.skillUsagePaths.map((entry) => ({ ...entry })); - } - - const entries = loadWorkspaceSkillEntries(sourceDir, { - config: params.config, - skillFilter: params.skillFilter, - agentId: params.agentId, - eligibility: params.eligibility, - managedSkillsDir: params.managedSkillsDir, - bundledSkillsDir: params.bundledSkillsDir, - pluginSkillsDir: params.pluginSkillsDir, - }); - - const usedDirNames = new Set(); - const plans: Array<{ destinationPath?: string; entry: SkillEntry; identity: string }> = []; - for (const entry of entries) { - const identity = resolveSyncedSkillIdentity( - resolveSkillKey(entry.skill, entry), - entry.skill.name, - ); - if (entry.skill.filePath.startsWith("node://")) { - plans.push({ entry, identity }); - continue; - } - let destinationPath: string | null; - try { - destinationPath = resolveSyncedSkillDestinationPath({ - targetSkillsDir, - entry, - usedDirNames, - }); - } catch (error) { - const message = error instanceof Error ? error.message : JSON.stringify(error); - skillsLogger.warn(`Failed to resolve safe destination for ${entry.skill.name}: ${message}`); - continue; - } - if (!destinationPath) { - skillsLogger.warn( - `Failed to resolve safe destination for ${entry.skill.name}: invalid source directory name`, - ); - continue; - } - plans.push({ - destinationPath, - entry, - identity, - }); - } - - // A manifest describes only a fully materialized tree. Remove it before - // reconciliation so interruption can never make a partial refresh reusable. - await fsp.rm(manifestPath, { force: true }); - const previousUsage = - manifest?.skillsVersion === skillsVersion && cachedUsage?.manifestKey === manifestKey - ? cachedUsage - : undefined; - syncedSkillsUsageCache.delete(targetSkillsDir); - const preservedDestinations = new Set( - plans.flatMap((plan) => { - const destination = plan.destinationPath ? path.basename(plan.destinationPath) : null; - return previousUsage?.destinations.get(plan.identity) === destination - ? destination - ? [destination] - : [] - : []; - }), - ); - for (const child of await fsp.readdir(targetSkillsDir)) { - if (!preservedDestinations.has(child)) { - await fsp.rm(path.join(targetSkillsDir, child), { recursive: true, force: true }); - } - } - - const skillUsagePaths: SkillUsagePath[] = []; - let copyFailed = false; - for (const plan of plans) { - const { destinationPath, entry } = plan; - if (!destinationPath) { - continue; - } - if (!preservedDestinations.has(path.basename(destinationPath))) { - try { - const syncSourceDir = entry.syncSourceDir ?? entry.skill.baseDir; - await fsp.cp(syncSourceDir, destinationPath, { - recursive: true, - force: true, - filter: (src) => { - const name = path.basename(src); - return !(name === ".git" || name === "node_modules"); - }, - }); - } catch (error) { - copyFailed = true; - const message = error instanceof Error ? error.message : JSON.stringify(error); - skillsLogger.warn(`Failed to copy ${entry.skill.name} to sandbox: ${message}`); - continue; - } - } - skillUsagePaths.push({ - readPath: path.join( - destinationPath, - path.relative(entry.skill.baseDir, entry.skill.filePath), - ), - skillFile: canonicalizePath(entry.skill.filePath), - skillName: entry.skill.name, - skillSource: resolveSkillTelemetrySource(entry.skill), - }); - } - if (!copyFailed) { - const nextManifest: SyncedSkillsManifest = { - entryKeys: plans.map((plan) => plan.identity).toSorted(), - skillsVersion, - }; - await writeJson(manifestPath, nextManifest, { trailingNewline: true }); - syncedSkillsUsageCache.set(targetSkillsDir, { - destinations: new Map( - plans.flatMap((plan) => - plan.destinationPath - ? [[plan.identity, path.basename(plan.destinationPath)] as const] - : [], - ), - ), - manifestKey: JSON.stringify([skillsVersion, nextManifest.entryKeys]), - skillUsagePaths, - }); - pruneMapToMaxSize(syncedSkillsUsageCache, 100); - } - return skillUsagePaths; - }); -} - -export function filterWorkspaceSkillEntriesWithOptions( - entries: SkillEntry[], - opts?: { - config?: OpenClawConfig; - skillFilter?: string[]; - skillOverrides?: Record; - eligibility?: SkillEligibilityContext; - }, -): SkillEntry[] { - return filterSkillEntries( - entries, - opts?.config, - opts?.skillFilter, - opts?.skillOverrides, - opts?.eligibility, - ); -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/skills/runtime/embedded-run-entries.test.ts b/src/skills/runtime/embedded-run-entries.test.ts index cb862bc51cd0..56f597aaf5e2 100644 --- a/src/skills/runtime/embedded-run-entries.test.ts +++ b/src/skills/runtime/embedded-run-entries.test.ts @@ -5,17 +5,17 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../config/config.js"; -import * as skillsWorkspaceModule from "../loading/workspace.js"; +import * as skillsLoaderModule from "../loading/workspace-skill-loader.js"; import type { SkillSnapshot } from "../types.js"; import { resolveEmbeddedRunSkillEntries } from "./embedded-run-entries.js"; describe("resolveEmbeddedRunSkillEntries", () => { - const loadWorkspaceSkillEntriesSpy = vi.spyOn(skillsWorkspaceModule, "loadWorkspaceSkillEntries"); + const loadWorkspaceSkillsSpy = vi.spyOn(skillsLoaderModule, "loadWorkspaceSkills"); beforeEach(() => { clearRuntimeConfigSnapshot(); - loadWorkspaceSkillEntriesSpy.mockReset(); - loadWorkspaceSkillEntriesSpy.mockReturnValue([]); + loadWorkspaceSkillsSpy.mockReset(); + loadWorkspaceSkillsSpy.mockReturnValue([]); }); it("loads skill entries with config when no resolved snapshot skills exist", () => { @@ -37,8 +37,8 @@ describe("resolveEmbeddedRunSkillEntries", () => { }); expect(result.shouldLoadSkillEntries).toBe(true); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledTimes(1); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { config }); + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledTimes(1); + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledWith("/tmp/workspace", { config }); }); it("threads agentId through live skill loading", () => { @@ -52,7 +52,7 @@ describe("resolveEmbeddedRunSkillEntries", () => { }, }); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledWith("/tmp/workspace", { config: {}, agentId: "writer", }); @@ -79,14 +79,11 @@ describe("resolveEmbeddedRunSkillEntries", () => { workspaceOnly: true, }); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith( - "/tmp/workspace/.openclaw/sandbox-skills", - { - config: {}, - eligibility, - workspaceOnly: true, - }, - ); + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledWith("/tmp/workspace/.openclaw/sandbox-skills", { + config: {}, + eligibility, + workspaceOnly: true, + }); }); it("prefers the active runtime snapshot when caller config still contains SecretRefs", () => { @@ -123,7 +120,7 @@ describe("resolveEmbeddedRunSkillEntries", () => { }, }); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledWith("/tmp/workspace", { config: runtimeConfig, }); }); @@ -163,7 +160,7 @@ describe("resolveEmbeddedRunSkillEntries", () => { }, }); - expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { + expect(loadWorkspaceSkillsSpy).toHaveBeenCalledWith("/tmp/workspace", { config: callerConfig, }); }); @@ -185,6 +182,6 @@ describe("resolveEmbeddedRunSkillEntries", () => { shouldLoadSkillEntries: false, skillEntries: [], }); - expect(loadWorkspaceSkillEntriesSpy).not.toHaveBeenCalled(); + expect(loadWorkspaceSkillsSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/skills/runtime/embedded-run-entries.ts b/src/skills/runtime/embedded-run-entries.ts index e7f3eaf40824..dbdd8b3f1d5a 100644 --- a/src/skills/runtime/embedded-run-entries.ts +++ b/src/skills/runtime/embedded-run-entries.ts @@ -1,7 +1,7 @@ // Embedded run entry helpers serialize runtime skill metadata for agent run records. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveSkillRuntimeConfig } from "../loading/runtime-config.js"; -import { loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js"; /** Resolves skill entries embedded into a run payload into runtime-visible entries. */ @@ -21,7 +21,7 @@ export function resolveEmbeddedRunSkillEntries(params: { return { shouldLoadSkillEntries, skillEntries: shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(params.workspaceDir, { + ? loadWorkspaceSkills(params.workspaceDir, { config, agentId: params.agentId, ...(params.eligibility ? { eligibility: params.eligibility } : {}), diff --git a/src/skills/runtime/remote-skills.test.ts b/src/skills/runtime/remote-skills.test.ts index 7faa830b1eaf..a20c981dfc48 100644 --- a/src/skills/runtime/remote-skills.test.ts +++ b/src/skills/runtime/remote-skills.test.ts @@ -4,7 +4,8 @@ import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; import { loggingState } from "../../logging/state.js"; import { buildWorkspaceSkillCommandSpecs } from "../discovery/command-specs.js"; import { buildWorkspaceSkillStatus } from "../discovery/status.js"; -import { buildWorkspaceSkillSnapshot, loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; +import { buildSkillSnapshot } from "../loading/workspace-skill-prompt.js"; import type { SkillEntry } from "../types.js"; import { getSkillsSnapshotVersion } from "./refresh-state.js"; import { @@ -78,11 +79,11 @@ describe("node-hosted skill snapshots", () => { ], }); - const entries = loadWorkspaceSkillEntries("/workspace", { + const entries = loadWorkspaceSkills("/workspace", { workspaceOnly: true, eligibility: { nodeSkills: { canExec: true } }, }); - const snapshot = buildWorkspaceSkillSnapshot("/workspace", { entries }); + const snapshot = buildSkillSnapshot("/workspace", { entries }); expect(snapshot.skills.map((skill) => skill.name)).toEqual(["release-helper"]); expect(snapshot.prompt).toContain("Build Mac (node-1)"); expect(snapshot.prompt).toContain( diff --git a/src/skills/runtime/remote.ts b/src/skills/runtime/remote.ts index 93aa99baa7cc..955c05625e8d 100644 --- a/src/skills/runtime/remote.ts +++ b/src/skills/runtime/remote.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NodeRegistry, NodeSession } from "../../gateway/node-registry.js"; import { listNodePairing, updatePairedNodeBins } from "../../infra/device-pairing-node.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { loadWorkspaceSkillEntries } from "../loading/workspace.js"; +import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import type { SkillEligibilityContext } from "../types.js"; import { bumpSkillsSnapshotVersion } from "./refresh-state.js"; import { @@ -492,7 +492,7 @@ async function refreshRemoteNodeBinsUncoalesced(params: { const workspaceDirs = listAgentWorkspaceDirs(params.cfg); const requiredBins = new Set(); for (const workspaceDir of workspaceDirs) { - const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); + const entries = loadWorkspaceSkills(workspaceDir, { config: params.cfg }); for (const bin of collectRequiredBins(entries, "darwin")) { requiredBins.add(bin); } diff --git a/src/skills/runtime/session-snapshot.test.ts b/src/skills/runtime/session-snapshot.test.ts index 38cf4d789786..13a9519ffe6b 100644 --- a/src/skills/runtime/session-snapshot.test.ts +++ b/src/skills/runtime/session-snapshot.test.ts @@ -34,8 +34,8 @@ const { ), })); -vi.mock("../loading/workspace.js", () => ({ - buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, +vi.mock("../loading/workspace-skill-prompt.js", () => ({ + buildSkillSnapshot: buildWorkspaceSkillSnapshotMock, })); vi.mock("./refresh.js", () => ({ diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index a8450049e1de..35b7f3fbd534 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -3,7 +3,7 @@ import { stableStringify } from "@openclaw/normalization-core"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { matchesSkillFilter } from "../discovery/filter.js"; -import { buildWorkspaceSkillSnapshot } from "../loading/workspace.js"; +import { buildSkillSnapshot } from "../loading/workspace-skill-prompt.js"; import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js"; import type { SkillEligibilityContext, SkillSnapshot } from "../types.js"; import { getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion } from "./refresh-state.js"; @@ -68,7 +68,7 @@ export function resolveReusableWorkspaceSkillSnapshot( !matchesSkillFilter(params.existingSnapshot?.skillFilter, params.skillFilter) || skillOverridesChanged; const buildSnapshot = () => { - return buildWorkspaceSkillSnapshot(params.workspaceDir, { + return buildSkillSnapshot(params.workspaceDir, { config: params.config, agentId: params.agentId, skillFilter: params.skillFilter,