diff --git a/packages/gateway-protocol/src/channels.schema.test.ts b/packages/gateway-protocol/src/channels.schema.test.ts index f4dec683ad2d..fc64b758b923 100644 --- a/packages/gateway-protocol/src/channels.schema.test.ts +++ b/packages/gateway-protocol/src/channels.schema.test.ts @@ -2,7 +2,14 @@ import { Compile } from "typebox/compile"; import { describe, expect, it } from "vitest"; import { ChannelsStatusResultSchema, WebLoginWaitParamsSchema } from "./schema/channels.js"; +/** + * Channel schema regressions for browser login and status diagnostics. + * These payloads are consumed by dashboard/operator UI, so QR payload bounds + * and event-loop diagnostic shape are part of the public gateway contract. + */ + describe("WebLoginWaitParamsSchema", () => { + /** Compiled validator reused across QR bounds cases. */ const validate = Compile(WebLoginWaitParamsSchema); it("bounds caller-provided QR data URLs", () => { @@ -26,6 +33,7 @@ describe("WebLoginWaitParamsSchema", () => { }); describe("ChannelsStatusResultSchema", () => { + /** Compiled status validator for channel docking diagnostics. */ const validate = Compile(ChannelsStatusResultSchema); it("accepts gateway event-loop diagnostics emitted by channels.status", () => { diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index 16d8cfa3c7ea..15e81c995955 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -1,3 +1,9 @@ +/** + * Shared gateway client identity contract. + * + * These values cross the WebSocket handshake boundary, so additions must stay + * aligned with protocol schemas and server policy checks. + */ function normalizeOptionalLowercaseString(raw?: string | null): string | undefined { if (typeof raw !== "string") { return undefined; @@ -6,6 +12,7 @@ function normalizeOptionalLowercaseString(raw?: string | null): string | undefin return normalized || undefined; } +/** Canonical client ids accepted in gateway hello/connect payloads. */ export const GATEWAY_CLIENT_IDS = { WEBCHAT_UI: "webchat-ui", CONTROL_UI: "openclaw-control-ui", @@ -30,6 +37,7 @@ export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS; /** Compatibility alias for internal callers that still use "name" terminology. */ export type GatewayClientName = GatewayClientId; +/** Coarse modes let policy group clients without matching every product id. */ export const GATEWAY_CLIENT_MODES = { WEBCHAT: "webchat", CLI: "cli", @@ -45,16 +53,25 @@ export type GatewayClientMode = (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEW /** Client metadata sent during gateway connection setup. */ export type GatewayClientInfo = { + /** Stable product/client identifier from `GATEWAY_CLIENT_IDS`. */ id: GatewayClientId; + /** Human-readable label for diagnostics; not used for policy decisions. */ displayName?: string; + /** Client app or package version reported by the connecting process. */ version: string; + /** Runtime platform string, such as `darwin`, `ios`, `android`, or `web`. */ platform: string; + /** Optional device family used by native clients for display and routing hints. */ deviceFamily?: string; + /** Native hardware/model identifier when available. */ modelIdentifier?: string; + /** Coarse category from `GATEWAY_CLIENT_MODES` for policy and diagnostics. */ mode: GatewayClientMode; + /** Per-installation or per-process id used to distinguish same-product clients. */ instanceId?: string; }; +/** Capability flags a client may advertise during the gateway handshake. */ export const GATEWAY_CLIENT_CAPS = { TOOL_EVENTS: "tool-events", } as const; @@ -67,6 +84,8 @@ const GATEWAY_CLIENT_MODE_SET = new Set(Object.values(GATEWAY /** Normalizes untrusted client ids and rejects unknown values. */ export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined { + // Handshake input is intentionally case-insensitive, but policy decisions use + // the canonical lowercase ids from the closed registry above. const normalized = normalizeOptionalLowercaseString(raw); if (!normalized) { return undefined; diff --git a/packages/gateway-protocol/src/connect-error-details.test.ts b/packages/gateway-protocol/src/connect-error-details.test.ts index 36dc4d37c776..811c0dcadc54 100644 --- a/packages/gateway-protocol/src/connect-error-details.test.ts +++ b/packages/gateway-protocol/src/connect-error-details.test.ts @@ -16,6 +16,14 @@ import { resolveAuthConnectErrorDetailCode, } from "./connect-error-details.js"; +/** + * Connect error detail regressions for Gateway/WebSocket clients. + * + * These tests pin structured auth/pairing details, human-readable fallback + * formatting, and request-id sanitization because these strings surface in + * control UI reconnect flows and device pairing diagnostics. + */ + describe("readConnectErrorDetailCode", () => { it("reads structured detail codes", () => { expect(readConnectErrorDetailCode({ code: "AUTH_TOKEN_MISMATCH" })).toBe("AUTH_TOKEN_MISMATCH"); diff --git a/packages/gateway-protocol/src/connect-error-details.ts b/packages/gateway-protocol/src/connect-error-details.ts index 4e681416d27e..9cb4ce44fde3 100644 --- a/packages/gateway-protocol/src/connect-error-details.ts +++ b/packages/gateway-protocol/src/connect-error-details.ts @@ -1,3 +1,9 @@ +/** + * Shared gateway connect-error detail helpers. + * + * These details cross client/server boundaries, so readers normalize untrusted + * payloads before using them in reconnect decisions or user-facing messages. + */ function normalizeOptionalString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -18,6 +24,7 @@ function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undef return values.length > 0 ? values : undefined; } +/** Structured connect-error codes carried in gateway error `details.code`. */ export const ConnectErrorDetailCodes = { AUTH_REQUIRED: "AUTH_REQUIRED", AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED", @@ -53,6 +60,7 @@ export const ConnectErrorDetailCodes = { export type ConnectErrorDetailCode = (typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes]; +/** Pairing-specific reasons clients can display and use for reconnect policy. */ export const ConnectPairingRequiredReasons = { NOT_PAIRED: "not-paired", ROLE_UPGRADE: "role-upgrade", @@ -63,6 +71,7 @@ export const ConnectPairingRequiredReasons = { export type ConnectPairingRequiredReason = (typeof ConnectPairingRequiredReasons)[keyof typeof ConnectPairingRequiredReasons]; +/** Suggested client-side recovery action for structured connect errors. */ export type ConnectRecoveryNextStep = | "retry_with_device_token" | "update_auth_configuration" @@ -70,11 +79,13 @@ export type ConnectRecoveryNextStep = | "wait_then_retry" | "review_auth_configuration"; +/** Optional retry guidance extracted from gateway connect-error details. */ export type ConnectErrorRecoveryAdvice = { canRetryWithDeviceToken?: boolean; recommendedNextStep?: ConnectRecoveryNextStep; }; +/** Full structured details for pairing-required connect failures. */ export type PairingConnectErrorDetails = { code: typeof ConnectErrorDetailCodes.PAIRING_REQUIRED; reason?: ConnectPairingRequiredReason; @@ -90,6 +101,7 @@ export type PairingConnectErrorDetails = { approvedScopes?: string[]; }; +/** Compact pairing-required subset used by reconnect/status surfaces. */ export type ConnectPairingRequiredDetails = Pick< PairingConnectErrorDetails, "reason" | "requestId" @@ -152,6 +164,7 @@ const CONNECT_PAIRING_REQUIRED_MESSAGE_BY_REASON: Readonly< "metadata-upgrade": "device metadata change pending approval", }; +/** Maps internal auth failure reasons to public connect-error detail codes. */ export function resolveAuthConnectErrorDetailCode( reason: string | undefined, ): ConnectErrorDetailCode { @@ -191,6 +204,7 @@ export function resolveAuthConnectErrorDetailCode( } } +/** Maps device-auth verifier reasons to public connect-error detail codes. */ export function resolveDeviceAuthConnectErrorDetailCode( reason: string | undefined, ): ConnectErrorDetailCode { @@ -212,6 +226,7 @@ export function resolveDeviceAuthConnectErrorDetailCode( } } +/** Reads a non-empty detail code from an untrusted error details payload. */ export function readConnectErrorDetailCode(details: unknown): string | null { if (!details || typeof details !== "object" || Array.isArray(details)) { return null; @@ -220,6 +235,7 @@ export function readConnectErrorDetailCode(details: unknown): string | null { return typeof code === "string" && code.trim().length > 0 ? code : null; } +/** Extracts normalized retry advice from untrusted connect-error details. */ export function readConnectErrorRecoveryAdvice(details: unknown): ConnectErrorRecoveryAdvice { if (!details || typeof details !== "object" || Array.isArray(details)) { return {}; @@ -249,6 +265,7 @@ function normalizePairingConnectReason(value: unknown): ConnectPairingRequiredRe : undefined; } +/** Normalizes pairing request ids before echoing them in close reasons or UI text. */ export function normalizePairingConnectRequestId(value: unknown): string | undefined { const normalized = normalizeOptionalString(value); return normalized && PAIRING_CONNECT_REQUEST_ID_PATTERN.test(normalized) ? normalized : undefined; @@ -287,6 +304,7 @@ function createPairingConnectErrorDetails(params: { }; } +/** Human-readable requirement summary for a pairing-required reason. */ export function describePairingConnectRequirement( reason: ConnectPairingRequiredReason | undefined, ): string { @@ -295,6 +313,7 @@ export function describePairingConnectRequirement( : "device approval is required"; } +/** Builds the gateway close/error message for a pairing-required connect failure. */ export function buildPairingConnectErrorMessage( reason: ConnectPairingRequiredReason | undefined, ): string { @@ -311,6 +330,7 @@ function buildPairingConnectRemediationHint( : "Approve the pending device request before retrying."; } +/** Short user-facing recovery title for pairing-required connect failures. */ export function buildPairingConnectRecoveryTitle( reason: ConnectPairingRequiredReason | undefined, ): string { @@ -319,6 +339,7 @@ export function buildPairingConnectRecoveryTitle( : "Gateway pairing approval required."; } +/** Builds sanitized structured details for a pairing-required connect failure. */ export function buildPairingConnectErrorDetails(params: { reason: ConnectPairingRequiredReason | undefined; requestId?: string; @@ -356,6 +377,7 @@ export function buildPairingConnectErrorDetails(params: { }); } +/** Builds a sanitized close reason string for WebSocket pairing rejections. */ export function buildPairingConnectCloseReason(params: { reason: ConnectPairingRequiredReason | undefined; requestId?: string; @@ -365,6 +387,7 @@ export function buildPairingConnectCloseReason(params: { return requestId ? `${message} (requestId: ${requestId})` : message; } +/** Reads and backfills pairing-required details from an untrusted details object. */ export function readPairingConnectErrorDetails( details: unknown, ): PairingConnectErrorDetails | null { @@ -417,6 +440,7 @@ export function readPairingConnectErrorDetails( }); } +/** Reads the compact pairing-required subset from untrusted connect details. */ export function readConnectPairingRequiredDetails( details: unknown, ): ConnectPairingRequiredDetails | null { @@ -430,6 +454,7 @@ export function readConnectPairingRequiredDetails( }; } +/** Parses legacy/string-only pairing-required messages into structured details. */ export function readConnectPairingRequiredMessage( message: string | null | undefined, ): ConnectPairingRequiredDetails | null { @@ -462,6 +487,7 @@ export function readConnectPairingRequiredMessage( }; } +/** Formats pairing-required details into the canonical user-facing message. */ export function formatConnectPairingRequiredMessage(details: unknown): string { const pairing = readPairingConnectErrorDetails(details); const base = @@ -471,6 +497,7 @@ export function formatConnectPairingRequiredMessage(details: unknown): string { return pairing?.requestId ? `${base} (requestId: ${pairing.requestId})` : base; } +/** Formats connect errors using structured details before falling back to raw messages. */ export function formatConnectErrorMessage(params: { message?: string; details?: unknown }): string { if (readConnectErrorDetailCode(params.details) === ConnectErrorDetailCodes.PAIRING_REQUIRED) { return formatConnectPairingRequiredMessage(params.details); diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 6d21296c863c..2e8b276832fc 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -9,6 +9,14 @@ import { validateCronUpdateParams, } from "./index.js"; +/** + * Cron validator regressions for public scheduler RPC payloads. + * + * The cases cover both canonical `id` selectors and legacy `jobId` aliases, + * delivery routing, update clears, and run-log path traversal guards. + */ + +/** Smallest valid cron job create payload shared by add/update variations. */ const minimalAddParams = { name: "daily-summary", schedule: { kind: "every", everyMs: 60_000 }, diff --git a/packages/gateway-protocol/src/exec-approvals-validators.test.ts b/packages/gateway-protocol/src/exec-approvals-validators.test.ts index aa22e2ee8156..63d95050d5e5 100644 --- a/packages/gateway-protocol/src/exec-approvals-validators.test.ts +++ b/packages/gateway-protocol/src/exec-approvals-validators.test.ts @@ -5,6 +5,12 @@ import { validateExecApprovalsSetParams, } from "./index.js"; +/** + * Exec approval validator regressions for gateway and node-scoped policy + * writes. The fixtures pin runtime-owned allowlist metadata and command-span + * bounds because those contracts are consumed by approval UI and replay logic. + */ + describe("exec approvals protocol validators", () => { it("accepts runtime-owned allowlist metadata on gateway and node set payloads", () => { const file = { diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index afac286688d2..f4a16ae65988 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -36,6 +36,15 @@ import { type ValidationError, } from "./index.js"; +/** + * Broad protocol validator smoke tests. + * + * This file exercises exported lazy validators, readable validation errors, and + * representative cross-surface payloads so schema registry changes fail before + * they reach CLI, Gateway, channel, or dashboard consumers. + */ + +/** Builds a validation error fixture while keeping only the field under test noisy. */ const makeError = (overrides: Partial): ValidationError => ({ keyword: "type", instancePath: "", @@ -45,6 +54,7 @@ const makeError = (overrides: Partial): ValidationError => ({ ...overrides, }); +/** Runtime shape shared by all exported lazy protocol validator functions. */ type ProtocolValidator = (value: unknown) => boolean; describe("lazy protocol validators", () => { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index a67a56849d31..c1d0284a22a7 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,3 +1,5 @@ +// Public gateway protocol entrypoint. Keep this barrel aligned with schema.ts +// so clients can import wire types, JSON schemas, and validators from one place. import { Compile, type Validator as TypeBoxValidator } from "typebox/compile"; import { type AgentEvent, @@ -464,11 +466,16 @@ export type ValidationError = { message?: string; }; +/** Runtime validator shape shared by gateway clients and server handlers. */ export type ProtocolValidator = ((data: unknown) => data is T) & { + /** Last validation errors, matching Ajv-style caller expectations. */ errors: ValidationError[] | null; + /** Original schema used by the validator, exposed for diagnostics/tests. */ schema: unknown; }; +// Defer TypeBox compilation until the first validation call. Importing this +// module is common in CLIs/tests, so eager compilation would add startup cost. function lazyCompile(schema: unknown): ProtocolValidator { let compiled: TypeBoxValidator | undefined; let errors: ValidationError[] | null = null; @@ -491,6 +498,7 @@ function lazyCompile(schema: unknown): ProtocolValidator { enumerable: true, get: () => errors, set: (nextErrors: ValidationError[] | null | undefined) => { + // Preserve Ajv-compatible mutability for callers/tests that clear errors. errors = nextErrors ?? null; }, }, @@ -504,6 +512,8 @@ function lazyCompile(schema: unknown): ProtocolValidator { return validate; } +// Public per-method validators. Names intentionally mirror the exported schema +// constants so call sites can pair validation with the wire contract directly. export const validateCommandsListParams = lazyCompile(CommandsListParamsSchema); export const validateConnectParams = lazyCompile(ConnectParamsSchema); export const validateRequestFrame = lazyCompile(RequestFrameSchema); @@ -873,6 +883,7 @@ function firstStringParam(value: unknown): string | undefined { return undefined; } +/** Convert validator errors into compact operator-facing failure text. */ export function formatValidationErrors(errors: ValidationError[] | null | undefined) { if (!errors?.length) { return "unknown validation error"; @@ -907,6 +918,8 @@ export function formatValidationErrors(errors: ValidationError[] | null | undefi const failingKeyword = typeof err?.params?.failingKeyword === "string" ? err.params.failingKeyword : ""; + // TypeBox reports conditional required-property misses through if/then + // keywords, which otherwise hide the actionable missing-property context. const message = keyword === "then" || (keyword === "if" && failingKeyword === "then") ? "must have required conditional properties" @@ -925,6 +938,8 @@ export function formatValidationErrors(errors: ValidationError[] | null | undefi return unique.join("; "); } +// Schema exports stay explicit to make additions/removals reviewable as public +// protocol surface changes. export { ConnectParamsSchema, HelloOkSchema, @@ -1132,6 +1147,7 @@ export { errorShape, }; +// Type exports mirror the schema exports for downstream TypeScript consumers. export type { GatewayFrame, ConnectParams, diff --git a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts index 6feb8f5f8819..67725ef7cfa7 100644 --- a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts +++ b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts @@ -3,6 +3,15 @@ import path from "node:path"; import { describe, it } from "vitest"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "./version.js"; +/** + * Cross-language guard for Gateway protocol version constants. + * + * Native Swift/Kotlin clients and dev smoke scripts cannot derive these values + * from TypeScript at runtime, so this test keeps checked-in generated constants + * and connect payloads aligned with the package source of truth. + */ + +/** Min/max protocol pair expected in every native client surface. */ type ProtocolLevels = { min: number; max: number; @@ -13,10 +22,12 @@ const expectedLevels: ProtocolLevels = { max: PROTOCOL_VERSION, }; +/** Reads a repo-relative source file used by a native protocol guard. */ async function readRepoFile(relativePath: string): Promise { return fs.readFile(path.join(process.cwd(), relativePath), "utf8"); } +/** Extracts one integer constant and reports the owning file on drift. */ function extractInteger( content: string, pattern: RegExp, @@ -32,6 +43,7 @@ function extractInteger( return Number.parseInt(match[1], 10); } +/** Compares native min/max values to the TypeScript version constants. */ function assertLevelsMatch(relativePath: string, actual: ProtocolLevels): void { if (actual.min === expectedLevels.min && actual.max === expectedLevels.max) { return; @@ -41,6 +53,7 @@ function assertLevelsMatch(relativePath: string, actual: ProtocolLevels): void { ); } +/** Asserts a compatibility pattern exists in generated/native source text. */ function assertPattern( content: string, relativePath: string, diff --git a/packages/gateway-protocol/src/primitives.secretref.test.ts b/packages/gateway-protocol/src/primitives.secretref.test.ts index 649e2ff89f66..adebe775d4dd 100644 --- a/packages/gateway-protocol/src/primitives.secretref.test.ts +++ b/packages/gateway-protocol/src/primitives.secretref.test.ts @@ -6,6 +6,11 @@ import { } from "../../../src/test-utils/secret-ref-test-vectors.js"; import { SecretInputSchema, SecretRefSchema } from "./schema/primitives.js"; +/** + * SecretRef schema regressions shared with core secret-ref test vectors. + * Exec-backed ids have stricter character rules than env/file refs, so these + * checks keep provider config payloads aligned with runtime secret resolution. + */ describe("gateway protocol SecretRef schema", () => { const validateSecretRef = Compile(SecretRefSchema); const validateSecretInput = Compile(SecretInputSchema); diff --git a/packages/gateway-protocol/src/push.test.ts b/packages/gateway-protocol/src/push.test.ts index f47c3a24c6ec..e479271a5543 100644 --- a/packages/gateway-protocol/src/push.test.ts +++ b/packages/gateway-protocol/src/push.test.ts @@ -2,6 +2,11 @@ import { Compile } from "typebox/compile"; import { describe, expect, it } from "vitest"; import { PushTestResultSchema } from "./schema/push.js"; +/** + * Push protocol schema regression for APNS test results. + * The transport field tells operators whether delivery used direct APNS or the + * relay path, so it is part of the public result contract. + */ describe("gateway protocol push schema", () => { const validatePushTestResult = Compile(PushTestResultSchema); diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index a14ea64a0af4..3eb6c9919125 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -1,3 +1,9 @@ +/** + * Public schema barrel for the gateway protocol package. + * + * Runtime validators import canonical TypeBox schemas from their owning modules; + * this barrel gives package consumers one stable path for schema-level imports. + */ export * from "./schema/primitives.js"; export * from "./schema/agent.js"; export * from "./schema/agents-models-skills.js"; diff --git a/packages/gateway-protocol/src/schema/agent.test.ts b/packages/gateway-protocol/src/schema/agent.test.ts index dac19a785fdf..1dbb7ea1ca86 100644 --- a/packages/gateway-protocol/src/schema/agent.test.ts +++ b/packages/gateway-protocol/src/schema/agent.test.ts @@ -2,6 +2,11 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { AgentParamsSchema } from "./agent.js"; +/** + * Regression coverage for agent-run schema payloads that carry internal + * completion events. These events are produced by child automation and consumed + * by parent agent runs, so the fixture mirrors the cross-runtime boundary. + */ type AgentInternalEvent = { type: "task_completion"; source: string; @@ -17,6 +22,7 @@ type AgentInternalEvent = { replyInstruction?: string; }; +/** Builds the smallest valid agent request that embeds one internal event. */ function makeAgentParamsWithInternalEvent(event: AgentInternalEvent) { return { message: "A music generation task finished. Process the completion update now.", @@ -26,6 +32,7 @@ function makeAgentParamsWithInternalEvent(event: AgentInternalEvent) { }; } +/** Representative generated-media completion event from a child task. */ const musicCompletionEvent: AgentInternalEvent = { type: "task_completion", source: "music_generation", diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index 768893147b36..7af4dc4625c0 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -1,6 +1,13 @@ import { Type } from "typebox"; import { InputProvenanceSchema, NonEmptyString, SessionLabelString } from "./primitives.js"; +/** + * Agent and channel-action gateway schemas. + * + * These payloads sit on the boundary between external channel adapters, gateway + * RPC callers, and the agent runtime. Keep public request fields documented + * because older CLI/channel clients may continue sending them across releases. + */ const AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION = "task_completion"; const AGENT_INTERNAL_EVENT_SOURCES = [ "subagent", @@ -11,6 +18,7 @@ const AGENT_INTERNAL_EVENT_SOURCES = [ ] as const; const AGENT_INTERNAL_EVENT_STATUSES = ["ok", "timeout", "error", "unknown"] as const; +/** Generated media/file attachment metadata carried by internal agent events. */ export const AgentGeneratedAttachmentSchema = Type.Object( { type: Type.Optional(Type.String({ enum: ["image", "audio", "video", "file"] })), @@ -24,6 +32,7 @@ export const AgentGeneratedAttachmentSchema = Type.Object( { additionalProperties: false }, ); +/** Internal completion event surfaced when child automation reports back to a parent run. */ export const AgentInternalEventSchema = Type.Object( { type: Type.Literal(AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION), @@ -43,6 +52,7 @@ export const AgentInternalEventSchema = Type.Object( { additionalProperties: false }, ); +/** Stream event emitted by the agent runtime over the gateway protocol. */ export const AgentEventSchema = Type.Object( { runId: NonEmptyString, @@ -56,6 +66,7 @@ export const AgentEventSchema = Type.Object( { additionalProperties: false }, ); +/** Channel context injected into message actions so tools can reply in-place. */ export const MessageActionToolContextSchema = Type.Object( { currentChannelId: Type.Optional(Type.String()), @@ -84,6 +95,7 @@ export const MessageActionToolContextSchema = Type.Object( { additionalProperties: false }, ); +/** Request to execute a channel message action through a configured adapter. */ export const MessageActionParamsSchema = Type.Object( { channel: NonEmptyString, @@ -106,6 +118,7 @@ export const MessageActionParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Outbound send request shared by channel adapters. */ export const SendParamsSchema = Type.Object( { to: NonEmptyString, @@ -135,6 +148,7 @@ export const SendParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Poll creation request for adapters that support native polls. */ export const PollParamsSchema = Type.Object( { to: NonEmptyString, @@ -157,6 +171,7 @@ export const PollParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Main agent-run request accepted by the gateway. */ export const AgentParamsSchema = Type.Object( { message: NonEmptyString, @@ -212,6 +227,7 @@ export const AgentParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Identity lookup request for the current or selected agent/session. */ export const AgentIdentityParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -220,6 +236,7 @@ export const AgentIdentityParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Public display identity returned for an agent. */ export const AgentIdentityResultSchema = Type.Object( { agentId: NonEmptyString, @@ -233,6 +250,7 @@ export const AgentIdentityResultSchema = Type.Object( { additionalProperties: false }, ); +/** Waits for a submitted agent run to complete or time out. */ export const AgentWaitParamsSchema = Type.Object( { runId: NonEmptyString, @@ -241,6 +259,7 @@ export const AgentWaitParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Wake request from external schedulers or devices into an agent session. */ export const WakeParamsSchema = Type.Object( { mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index b74d1bc54287..90a2303426da 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -6,6 +6,13 @@ import { ToolsEffectiveResultSchema, } from "./agents-models-skills.js"; +/** + * Schema regression tests for agent metadata, skill proposals, and effective + * tool catalogs. These payloads are UI-facing but also consumed by runtime + * guards, so the fixtures exercise strictness at the public gateway boundary. + */ + +/** Minimal effective-tools result used by strict notice tests. */ function toolsEffectiveResult() { return { agentId: "main", diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 5ca6052fb8d2..ea606ce6d130 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -1,6 +1,16 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Agent, model, skill, and tool catalog schemas. + * + * These contracts back dashboard selectors, agent management, model catalogs, + * skill upload/install flows, skill workshop proposals, and effective tool + * discovery. Keep public request/result schemas documented because they are + * shared by gateway RPC, CLI, and UI clients. + */ + +/** Model option shown in selectors and model catalog results. */ export const ModelChoiceSchema = Type.Object( { id: NonEmptyString, @@ -13,6 +23,7 @@ export const ModelChoiceSchema = Type.Object( { additionalProperties: false }, ); +/** Condensed agent record returned by list APIs. */ export const AgentSummarySchema = Type.Object( { id: NonEmptyString, @@ -73,8 +84,10 @@ export const AgentSummarySchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for listing configured agents. */ export const AgentsListParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Agent list result including the default agent and session scoping mode. */ export const AgentsListResultSchema = Type.Object( { defaultId: NonEmptyString, @@ -85,6 +98,7 @@ export const AgentsListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Creates a configured agent with workspace, identity, and optional model. */ export const AgentsCreateParamsSchema = Type.Object( { name: NonEmptyString, @@ -96,6 +110,7 @@ export const AgentsCreateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after creating an agent. */ export const AgentsCreateResultSchema = Type.Object( { ok: Type.Literal(true), @@ -107,6 +122,7 @@ export const AgentsCreateResultSchema = Type.Object( { additionalProperties: false }, ); +/** Updates mutable agent identity, workspace, and model fields. */ export const AgentsUpdateParamsSchema = Type.Object( { agentId: NonEmptyString, @@ -119,6 +135,7 @@ export const AgentsUpdateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after updating an agent. */ export const AgentsUpdateResultSchema = Type.Object( { ok: Type.Literal(true), @@ -127,6 +144,7 @@ export const AgentsUpdateResultSchema = Type.Object( { additionalProperties: false }, ); +/** Deletes an agent and optionally its workspace/config files. */ export const AgentsDeleteParamsSchema = Type.Object( { agentId: NonEmptyString, @@ -135,6 +153,7 @@ export const AgentsDeleteParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after deleting an agent and unbinding sessions. */ export const AgentsDeleteResultSchema = Type.Object( { ok: Type.Literal(true), @@ -144,6 +163,7 @@ export const AgentsDeleteResultSchema = Type.Object( { additionalProperties: false }, ); +/** File metadata and optional content for agent-local editable files. */ export const AgentsFileEntrySchema = Type.Object( { name: NonEmptyString, @@ -156,6 +176,7 @@ export const AgentsFileEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Lists editable files for one agent. */ export const AgentsFilesListParamsSchema = Type.Object( { agentId: NonEmptyString, @@ -163,6 +184,7 @@ export const AgentsFilesListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Editable file list for an agent workspace. */ export const AgentsFilesListResultSchema = Type.Object( { agentId: NonEmptyString, @@ -172,6 +194,7 @@ export const AgentsFilesListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Reads one editable agent file by name. */ export const AgentsFilesGetParamsSchema = Type.Object( { agentId: NonEmptyString, @@ -180,6 +203,7 @@ export const AgentsFilesGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result for reading one editable agent file. */ export const AgentsFilesGetResultSchema = Type.Object( { agentId: NonEmptyString, @@ -189,6 +213,7 @@ export const AgentsFilesGetResultSchema = Type.Object( { additionalProperties: false }, ); +/** Writes one editable agent file. */ export const AgentsFilesSetParamsSchema = Type.Object( { agentId: NonEmptyString, @@ -198,6 +223,7 @@ export const AgentsFilesSetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after writing an editable agent file. */ export const AgentsFilesSetResultSchema = Type.Object( { ok: Type.Literal(true), @@ -208,6 +234,7 @@ export const AgentsFilesSetResultSchema = Type.Object( { additionalProperties: false }, ); +/** Model catalog request with optional visibility scope. */ export const ModelsListParamsSchema = Type.Object( { view: Type.Optional( @@ -217,6 +244,7 @@ export const ModelsListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Model catalog result. */ export const ModelsListResultSchema = Type.Object( { models: Type.Array(ModelChoiceSchema), @@ -224,6 +252,7 @@ export const ModelsListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Reads installed skill status, optionally for a selected agent. */ export const SkillsStatusParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -231,8 +260,10 @@ export const SkillsStatusParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for listing available skill bins. */ export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Skill bin names available to the gateway. */ export const SkillsBinsResultSchema = Type.Object( { bins: Type.Array(NonEmptyString), @@ -254,6 +285,7 @@ const SkillUploadDataBase64String = Type.String({ maxLength: 5_592_408, }); +/** Starts a chunked skill archive upload. */ export const SkillsUploadBeginParamsSchema = Type.Object( { kind: Type.Literal("skill-archive"), @@ -266,6 +298,7 @@ export const SkillsUploadBeginParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Uploads one base64-encoded chunk for a skill archive. */ export const SkillsUploadChunkParamsSchema = Type.Object( { uploadId: NonEmptyString, @@ -275,6 +308,7 @@ export const SkillsUploadChunkParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Commits a completed skill archive upload. */ export const SkillsUploadCommitParamsSchema = Type.Object( { uploadId: NonEmptyString, @@ -283,6 +317,7 @@ export const SkillsUploadCommitParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Installs a skill from legacy install id, ClawHub, or uploaded archive. */ export const SkillsInstallParamsSchema = Type.Union([ Type.Object( { @@ -322,6 +357,7 @@ export const SkillsInstallParamsSchema = Type.Union([ ), ]); +/** Updates installed skill settings or refreshes ClawHub-installed skills. */ export const SkillsUpdateParamsSchema = Type.Union([ Type.Object( { @@ -342,6 +378,7 @@ export const SkillsUpdateParamsSchema = Type.Union([ ), ]); +/** Searches the skill registry. */ export const SkillsSearchParamsSchema = Type.Object( { query: Type.Optional(NonEmptyString), @@ -350,6 +387,7 @@ export const SkillsSearchParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Ranked skill registry search results. */ export const SkillsSearchResultSchema = Type.Object( { results: Type.Array( @@ -369,6 +407,7 @@ export const SkillsSearchResultSchema = Type.Object( { additionalProperties: false }, ); +/** Reads registry detail for one skill slug. */ export const SkillsDetailParamsSchema = Type.Object( { slug: NonEmptyString, @@ -376,6 +415,7 @@ export const SkillsDetailParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reads current security verdicts for configured skills. */ export const SkillsSecurityVerdictsParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -383,6 +423,7 @@ export const SkillsSecurityVerdictsParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Skill registry detail, latest version, metadata, and owner info. */ export const SkillsDetailResultSchema = Type.Object( { skill: Type.Union([ @@ -441,6 +482,7 @@ export const SkillsDetailResultSchema = Type.Object( { additionalProperties: false }, ); +/** Security verdict report for installed/requested skills. */ export const SkillsSecurityVerdictsResultSchema = Type.Object( { schema: Type.Literal("openclaw.skills.security-verdicts.v1"), @@ -481,6 +523,7 @@ export const SkillsSecurityVerdictsResultSchema = Type.Object( { additionalProperties: false }, ); +/** Reads the rendered skill card for one installed skill. */ export const SkillsSkillCardParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -489,6 +532,7 @@ export const SkillsSkillCardParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Rendered skill card content and file metadata. */ export const SkillsSkillCardResultSchema = Type.Object( { schema: Type.Literal("openclaw.skills.skill-card.v1"), @@ -507,19 +551,23 @@ const SkillProposalStatusSchema = Type.Union([ Type.Literal("quarantined"), Type.Literal("stale"), ]); +/** Skill proposal operation type: new skill or update to an existing skill. */ const SkillProposalKindSchema = Type.Union([Type.Literal("create"), Type.Literal("update")]); +/** Scan state for proposed skill content before it can be applied. */ const SkillProposalScanStateSchema = Type.Union([ Type.Literal("pending"), Type.Literal("clean"), Type.Literal("failed"), Type.Literal("quarantined"), ]); +/** Source that created the skill proposal record. */ const SkillProposalSourceSchema = Type.Union([ Type.Literal("skill-workshop"), Type.Literal("cli"), Type.Literal("gateway"), ]); const SkillProposalContentString = Type.String({ minLength: 1, maxLength: 1_048_576 }); +/** Support file payload accepted from proposal create/revise requests. */ const SkillProposalSupportFileInputSchema = Type.Object( { path: NonEmptyString, @@ -527,6 +575,7 @@ const SkillProposalSupportFileInputSchema = Type.Object( }, { additionalProperties: false }, ); +/** Stored support file metadata, including target conflict hashes for updates. */ const SkillProposalSupportFileSchema = Type.Object( { path: NonEmptyString, @@ -538,6 +587,7 @@ const SkillProposalSupportFileSchema = Type.Object( { additionalProperties: false }, ); +/** One static-scan finding against proposed skill content. */ const SkillProposalFindingSchema = Type.Object( { ruleId: NonEmptyString, @@ -550,6 +600,7 @@ const SkillProposalFindingSchema = Type.Object( { additionalProperties: false }, ); +/** Aggregated scan report attached to a proposal record. */ const SkillProposalScanSchema = Type.Object( { state: SkillProposalScanStateSchema, @@ -562,6 +613,7 @@ const SkillProposalScanSchema = Type.Object( { additionalProperties: false }, ); +/** Skill file target that a proposal creates or updates. */ const SkillProposalTargetSchema = Type.Object( { skillName: NonEmptyString, @@ -574,6 +626,7 @@ const SkillProposalTargetSchema = Type.Object( { additionalProperties: false }, ); +/** Optional runtime origin tying a proposal back to an agent turn. */ const SkillProposalOriginSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -584,6 +637,7 @@ const SkillProposalOriginSchema = Type.Object( { additionalProperties: false }, ); +/** Full persisted skill proposal record. */ const SkillProposalRecordSchema = Type.Object( { schema: Type.Literal("openclaw.skill-workshop.proposal.v1"), @@ -613,6 +667,7 @@ const SkillProposalRecordSchema = Type.Object( { additionalProperties: false }, ); +/** Condensed proposal manifest entry for list views. */ const SkillProposalManifestEntrySchema = Type.Object( { id: NonEmptyString, @@ -629,6 +684,7 @@ const SkillProposalManifestEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Lists skill-workshop proposals for the selected agent scope. */ export const SkillsProposalsListParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -636,6 +692,7 @@ export const SkillsProposalsListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Proposal manifest response for dashboard/workshop list views. */ export const SkillsProposalsListResultSchema = Type.Object( { schema: Type.Literal("openclaw.skill-workshop.proposals-manifest.v1"), @@ -645,6 +702,7 @@ export const SkillsProposalsListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Reads a proposal record plus editable draft/support content. */ export const SkillsProposalInspectParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -653,6 +711,7 @@ export const SkillsProposalInspectParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Full proposal inspection result used before apply/revise decisions. */ export const SkillsProposalInspectResultSchema = Type.Object( { record: SkillProposalRecordSchema, @@ -662,6 +721,7 @@ export const SkillsProposalInspectResultSchema = Type.Object( { additionalProperties: false }, ); +/** Creates a proposal for a new skill. */ export const SkillsProposalCreateParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -675,6 +735,7 @@ export const SkillsProposalCreateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Creates a proposal to update an existing skill. */ export const SkillsProposalUpdateParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -688,6 +749,7 @@ export const SkillsProposalUpdateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Replaces draft content/support files for an existing proposal. */ export const SkillsProposalReviseParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -701,6 +763,7 @@ export const SkillsProposalReviseParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Shared approve/reject/quarantine action payload for one proposal. */ export const SkillsProposalActionParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -710,6 +773,7 @@ export const SkillsProposalActionParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after applying a skill proposal to disk. */ export const SkillsProposalApplyResultSchema = Type.Object( { record: SkillProposalRecordSchema, @@ -718,8 +782,10 @@ export const SkillsProposalApplyResultSchema = Type.Object( { additionalProperties: false }, ); +/** Proposal record result returned after non-apply proposal actions. */ export const SkillsProposalRecordResultSchema = SkillProposalRecordSchema; +/** Reads the configured tool catalog for an agent. */ export const ToolsCatalogParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -728,6 +794,7 @@ export const ToolsCatalogParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reads the effective tool set for one session. */ export const ToolsEffectiveParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -736,6 +803,7 @@ export const ToolsEffectiveParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Invokes one tool through the gateway tool dispatcher. */ export const ToolsInvokeParamsSchema = Type.Object( { name: NonEmptyString, @@ -748,6 +816,7 @@ export const ToolsInvokeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Tool profile shown in catalog views. */ export const ToolCatalogProfileSchema = Type.Object( { id: Type.Union([ @@ -761,6 +830,7 @@ export const ToolCatalogProfileSchema = Type.Object( { additionalProperties: false }, ); +/** Tool catalog entry before session-specific filtering is applied. */ export const ToolCatalogEntrySchema = Type.Object( { id: NonEmptyString, @@ -785,6 +855,7 @@ export const ToolCatalogEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Group of related catalog tools from core or a plugin. */ export const ToolCatalogGroupSchema = Type.Object( { id: NonEmptyString, @@ -796,6 +867,7 @@ export const ToolCatalogGroupSchema = Type.Object( { additionalProperties: false }, ); +/** Tool catalog result for agent configuration UI. */ export const ToolsCatalogResultSchema = Type.Object( { agentId: NonEmptyString, @@ -805,6 +877,7 @@ export const ToolsCatalogResultSchema = Type.Object( { additionalProperties: false }, ); +/** Effective tool entry after session/profile/channel/plugin filtering. */ export const ToolsEffectiveEntrySchema = Type.Object( { id: NonEmptyString, @@ -827,6 +900,7 @@ export const ToolsEffectiveEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Effective tool group shown to runtime/session callers. */ export const ToolsEffectiveGroupSchema = Type.Object( { id: Type.Union([ @@ -847,6 +921,7 @@ export const ToolsEffectiveGroupSchema = Type.Object( { additionalProperties: false }, ); +/** Notice explaining runtime filtering such as quarantined tool schemas. */ export const ToolsEffectiveNoticeSchema = Type.Object( { id: NonEmptyString, @@ -856,6 +931,7 @@ export const ToolsEffectiveNoticeSchema = Type.Object( { additionalProperties: false }, ); +/** Effective tool set for a session, including profile and filtering notices. */ export const ToolsEffectiveResultSchema = Type.Object( { agentId: NonEmptyString, @@ -866,6 +942,7 @@ export const ToolsEffectiveResultSchema = Type.Object( { additionalProperties: false }, ); +/** Normalized error shape for tool invocation failures. */ export const ToolsInvokeErrorSchema = Type.Object( { code: NonEmptyString, @@ -875,6 +952,7 @@ export const ToolsInvokeErrorSchema = Type.Object( { additionalProperties: false }, ); +/** Tool invocation result, including approval handoff when required. */ export const ToolsInvokeResultSchema = Type.Object( { ok: Type.Boolean(), diff --git a/packages/gateway-protocol/src/schema/artifacts.ts b/packages/gateway-protocol/src/schema/artifacts.ts index 6be3ab63dc8d..35a08f84604f 100644 --- a/packages/gateway-protocol/src/schema/artifacts.ts +++ b/packages/gateway-protocol/src/schema/artifacts.ts @@ -1,6 +1,12 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Artifact lookup and download protocol schemas. + * + * Artifacts are files or payloads produced by sessions, runs, tasks, or agents; + * these schemas keep lookup filters explicit and download results transport-safe. + */ const ArtifactQueryParamsProperties = { sessionKey: Type.Optional(NonEmptyString), runId: Type.Optional(NonEmptyString), @@ -8,10 +14,12 @@ const ArtifactQueryParamsProperties = { agentId: Type.Optional(NonEmptyString), }; +/** Shared artifact filter payload used by list-style requests. */ export const ArtifactQueryParamsSchema = Type.Object(ArtifactQueryParamsProperties, { additionalProperties: false, }); +/** Artifact lookup payload with a required artifact id plus optional scope filters. */ export const ArtifactGetParamsSchema = Type.Object( { ...ArtifactQueryParamsProperties, @@ -20,6 +28,7 @@ export const ArtifactGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Public artifact metadata returned before or alongside download data. */ export const ArtifactSummarySchema = Type.Object( { id: NonEmptyString, @@ -42,8 +51,10 @@ export const ArtifactSummarySchema = Type.Object( { additionalProperties: false }, ); +/** List request payload for artifacts visible in the selected scope. */ export const ArtifactsListParamsSchema = ArtifactQueryParamsSchema; +/** List response containing artifact summaries only. */ export const ArtifactsListResultSchema = Type.Object( { artifacts: Type.Array(ArtifactSummarySchema), @@ -51,8 +62,10 @@ export const ArtifactsListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Get request payload for one artifact summary. */ export const ArtifactsGetParamsSchema = ArtifactGetParamsSchema; +/** Get response containing one artifact summary. */ export const ArtifactsGetResultSchema = Type.Object( { artifact: ArtifactSummarySchema, @@ -60,8 +73,10 @@ export const ArtifactsGetResultSchema = Type.Object( { additionalProperties: false }, ); +/** Download request payload for one artifact. */ export const ArtifactsDownloadParamsSchema = ArtifactGetParamsSchema; +/** Download response, either inline base64 bytes, URL, or metadata for unsupported modes. */ export const ArtifactsDownloadResultSchema = Type.Object( { artifact: ArtifactSummarySchema, diff --git a/packages/gateway-protocol/src/schema/channels.ts b/packages/gateway-protocol/src/schema/channels.ts index 62b070a30065..78fa6020d284 100644 --- a/packages/gateway-protocol/src/schema/channels.ts +++ b/packages/gateway-protocol/src/schema/channels.ts @@ -1,6 +1,15 @@ import { Type } from "typebox"; import { NonEmptyString, SecretInputSchema } from "./primitives.js"; +/** + * Channel and Talk protocol schemas. + * + * Talk schemas are consumed by browser realtime clients, gateway relay sessions, + * and channel adapters, so the mode/transport/brain unions below are shared + * API vocabulary rather than provider-local implementation details. + */ + +/** Toggles Talk mode for the gateway, with an optional rollout phase marker. */ export const TalkModeParamsSchema = Type.Object( { enabled: Type.Boolean(), @@ -9,6 +18,7 @@ export const TalkModeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reads Talk configuration; secrets are included only for trusted callers. */ export const TalkConfigParamsSchema = Type.Object( { includeSecrets: Type.Optional(Type.Boolean()), @@ -16,6 +26,7 @@ export const TalkConfigParamsSchema = Type.Object( { additionalProperties: false }, ); +/** One-shot text-to-speech request with provider-specific voice tuning knobs. */ export const TalkSpeakParamsSchema = Type.Object( { text: NonEmptyString, @@ -36,12 +47,14 @@ export const TalkSpeakParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Supported Talk session shapes exposed to clients and providers. */ const TalkModeSchema = Type.Union([ Type.Literal("realtime"), Type.Literal("stt-tts"), Type.Literal("transcription"), ]); +/** Transport families; browser clients branch on this value to choose setup flow. */ const TalkTransportSchema = Type.Union([ Type.Literal("webrtc"), Type.Literal("provider-websocket"), @@ -49,12 +62,14 @@ const TalkTransportSchema = Type.Union([ Type.Literal("managed-room"), ]); +/** How a Talk session delegates reasoning/tool use to the agent runtime. */ const TalkBrainSchema = Type.Union([ Type.Literal("agent-consult"), Type.Literal("direct-tools"), Type.Literal("none"), ]); +/** Agent control actions accepted from Talk clients and managed rooms. */ const TalkAgentControlModeSchema = Type.Union([ Type.Literal("status"), Type.Literal("steer"), @@ -62,6 +77,7 @@ const TalkAgentControlModeSchema = Type.Union([ Type.Literal("followup"), ]); +/** Stable event names emitted by Talk sessions across providers/transports. */ const TalkEventTypeSchema = Type.Union([ Type.Literal("session.started"), Type.Literal("session.ready"), @@ -93,6 +109,7 @@ const TalkEventTypeSchema = Type.Union([ Type.Literal("health.changed"), ]); +/** Event types that must carry a turn id for client-side stream correlation. */ const TURN_SCOPED_TALK_EVENT_TYPES = [ "turn.started", "turn.ended", @@ -112,6 +129,7 @@ const TURN_SCOPED_TALK_EVENT_TYPES = [ "tool.error", ]; +/** Capture lifecycle events must include capture id to avoid cross-turn ambiguity. */ const CAPTURE_SCOPED_TALK_EVENT_TYPES = [ "capture.started", "capture.stopped", @@ -119,11 +137,13 @@ const CAPTURE_SCOPED_TALK_EVENT_TYPES = [ "capture.once", ]; +/** Builds JSON Schema conditional requirements while avoiding reserved word syntax. */ function requireJsonSchemaProperties(properties: string[]): Record { const conditionalRequirementKey = ["th", "en"].join(""); return Object.fromEntries([[conditionalRequirementKey, { required: properties }]]); } +/** Canonical Talk event envelope emitted to browser, relay, and channel consumers. */ export const TalkEventSchema = Type.Object( { id: NonEmptyString, @@ -164,6 +184,7 @@ export const TalkEventSchema = Type.Object( }, ); +/** Creates a browser-facing Talk client session. */ export const TalkClientCreateParamsSchema = Type.Object( { sessionKey: Type.Optional(Type.String()), @@ -181,6 +202,7 @@ export const TalkClientCreateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Tool-call request from a browser/client session back into the agent runtime. */ export const TalkClientToolCallParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -192,6 +214,7 @@ export const TalkClientToolCallParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Agent run identity returned after accepting a Talk client tool call. */ export const TalkClientToolCallResultSchema = Type.Object( { runId: NonEmptyString, @@ -200,6 +223,7 @@ export const TalkClientToolCallResultSchema = Type.Object( { additionalProperties: false }, ); +/** Text steering request for a Talk session bound to an agent turn. */ export const TalkClientSteerParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -209,6 +233,7 @@ export const TalkClientSteerParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result of applying agent control to an embedded or reply-backed Talk run. */ export const TalkAgentControlResultSchema = Type.Object( { ok: Type.Boolean(), @@ -239,6 +264,7 @@ export const TalkAgentControlResultSchema = Type.Object( { additionalProperties: false }, ); +/** Joins an existing managed-room Talk session. */ export const TalkSessionJoinParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -247,6 +273,7 @@ export const TalkSessionJoinParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Creates a gateway-managed Talk session for realtime, transcription, or relay use. */ export const TalkSessionCreateParamsSchema = Type.Object( { sessionKey: Type.Optional(Type.String()), @@ -266,6 +293,7 @@ export const TalkSessionCreateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Appends base64 audio to an active Talk session. */ export const TalkSessionAppendAudioParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -275,6 +303,7 @@ export const TalkSessionAppendAudioParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Starts or advances a Talk turn within a session. */ export const TalkSessionTurnParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -283,6 +312,7 @@ export const TalkSessionTurnParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Cancels the active or named Talk turn. */ export const TalkSessionCancelTurnParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -292,6 +322,7 @@ export const TalkSessionCancelTurnParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Cancels currently streaming Talk output without necessarily ending the turn. */ export const TalkSessionCancelOutputParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -301,6 +332,7 @@ export const TalkSessionCancelOutputParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Submits a tool result back to a Talk provider session. */ export const TalkSessionSubmitToolResultParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -319,6 +351,7 @@ export const TalkSessionSubmitToolResultParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Steers a managed Talk session by session id rather than transcript key. */ export const TalkSessionSteerParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -329,6 +362,7 @@ export const TalkSessionSteerParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Closes a gateway-managed Talk session. */ export const TalkSessionCloseParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -336,6 +370,7 @@ export const TalkSessionCloseParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Mutable room state returned when a client joins a managed Talk room. */ const TalkSessionManagedRoomStateSchema = Type.Object( { activeClientId: Type.Optional(Type.String()), @@ -345,6 +380,7 @@ const TalkSessionManagedRoomStateSchema = Type.Object( { additionalProperties: false }, ); +/** Managed-room session record shared with browser clients. */ const TalkSessionManagedRoomRecordSchema = Type.Object( { id: NonEmptyString, @@ -367,8 +403,10 @@ const TalkSessionManagedRoomRecordSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for reading configured Talk provider capabilities. */ export const TalkCatalogParamsSchema = Type.Object({}, { additionalProperties: false }); +/** One provider entry in the Talk capability catalog. */ const TalkCatalogProviderSchema = Type.Object( { id: NonEmptyString, @@ -413,6 +451,7 @@ const TalkCatalogProviderSchema = Type.Object( { additionalProperties: false }, ); +/** Active provider plus all candidates for a Talk capability family. */ const TalkCatalogProviderGroupSchema = Type.Object( { activeProvider: Type.Optional(Type.String()), @@ -421,6 +460,7 @@ const TalkCatalogProviderGroupSchema = Type.Object( { additionalProperties: false }, ); +/** Provider, mode, transport, and audio-format catalog returned to clients. */ export const TalkCatalogResultSchema = Type.Object( { modes: Type.Array(TalkModeSchema), @@ -433,6 +473,7 @@ export const TalkCatalogResultSchema = Type.Object( { additionalProperties: false }, ); +/** Audio format contract for realtime browser sessions. */ const BrowserRealtimeAudioContractSchema = Type.Object( { inputEncoding: Type.Union([Type.Literal("pcm16"), Type.Literal("g711_ulaw")]), @@ -443,6 +484,7 @@ const BrowserRealtimeAudioContractSchema = Type.Object( { additionalProperties: false }, ); +/** Session creation result with transport-specific ids and credentials. */ export const TalkSessionCreateResultSchema = Type.Object( { sessionId: NonEmptyString, @@ -464,6 +506,7 @@ export const TalkSessionCreateResultSchema = Type.Object( { additionalProperties: false }, ); +/** Result for a Talk turn request, optionally including emitted events. */ export const TalkSessionTurnResultSchema = Type.Object( { ok: Type.Boolean(), @@ -473,8 +516,10 @@ export const TalkSessionTurnResultSchema = Type.Object( { additionalProperties: false }, ); +/** Managed-room record returned to clients after joining an existing Talk session. */ export const TalkSessionJoinResultSchema = TalkSessionManagedRoomRecordSchema; +/** Generic success result for Talk session lifecycle calls. */ export const TalkSessionOkResultSchema = Type.Object( { ok: Type.Boolean(), @@ -482,6 +527,7 @@ export const TalkSessionOkResultSchema = Type.Object( { additionalProperties: false }, ); +/** Browser WebRTC setup payload using provider SDP exchange. */ const BrowserRealtimeWebRtcSdpSessionSchema = Type.Object( { provider: NonEmptyString, @@ -496,6 +542,7 @@ const BrowserRealtimeWebRtcSdpSessionSchema = Type.Object( { additionalProperties: false }, ); +/** Browser websocket setup payload with JSON/PCM audio contract. */ const BrowserRealtimeJsonPcmWebSocketSessionSchema = Type.Object( { provider: NonEmptyString, @@ -512,6 +559,7 @@ const BrowserRealtimeJsonPcmWebSocketSessionSchema = Type.Object( { additionalProperties: false }, ); +/** Browser setup payload for gateway-relayed realtime audio. */ const BrowserRealtimeGatewayRelaySessionSchema = Type.Object( { provider: NonEmptyString, @@ -525,6 +573,7 @@ const BrowserRealtimeGatewayRelaySessionSchema = Type.Object( { additionalProperties: false }, ); +/** Browser setup payload for managed-room Talk sessions. */ const BrowserRealtimeManagedRoomSessionSchema = Type.Object( { provider: NonEmptyString, @@ -538,6 +587,7 @@ const BrowserRealtimeManagedRoomSessionSchema = Type.Object( { additionalProperties: false }, ); +/** Union of all browser Talk session setup payloads. */ export const TalkClientCreateResultSchema = Type.Union([ BrowserRealtimeWebRtcSdpSessionSchema, BrowserRealtimeJsonPcmWebSocketSessionSchema, @@ -545,14 +595,17 @@ export const TalkClientCreateResultSchema = Type.Union([ BrowserRealtimeManagedRoomSessionSchema, ]); +/** Secret-bearing provider fields; extra provider options remain provider-owned. */ const talkProviderFieldSchemas = { apiKey: Type.Optional(SecretInputSchema), }; +/** Per-provider Talk config bag. */ const TalkProviderConfigSchema = Type.Object(talkProviderFieldSchemas, { additionalProperties: true, }); +/** Realtime Talk defaults and provider selection stored in config. */ const TalkRealtimeConfigSchema = Type.Object( { provider: Type.Optional(Type.String()), @@ -569,6 +622,7 @@ const TalkRealtimeConfigSchema = Type.Object( { additionalProperties: false }, ); +/** Resolved active Talk provider plus its normalized provider config. */ const ResolvedTalkConfigSchema = Type.Object( { provider: Type.String(), @@ -577,6 +631,7 @@ const ResolvedTalkConfigSchema = Type.Object( { additionalProperties: false }, ); +/** Talk config subtree returned through gateway config APIs. */ const TalkConfigSchema = Type.Object( { provider: Type.Optional(Type.String()), @@ -592,6 +647,7 @@ const TalkConfigSchema = Type.Object( { additionalProperties: false }, ); +/** Full Talk config read result, including related session/UI context. */ export const TalkConfigResultSchema = Type.Object( { config: Type.Object( @@ -620,6 +676,7 @@ export const TalkConfigResultSchema = Type.Object( { additionalProperties: false }, ); +/** Text-to-speech result with encoded audio and provider output metadata. */ export const TalkSpeakResultSchema = Type.Object( { audioBase64: NonEmptyString, @@ -632,6 +689,7 @@ export const TalkSpeakResultSchema = Type.Object( { additionalProperties: false }, ); +/** Channel status request, optionally probing one channel before returning. */ export const ChannelsStatusParamsSchema = Type.Object( { probe: Type.Optional(Type.Boolean()), @@ -641,8 +699,12 @@ export const ChannelsStatusParamsSchema = Type.Object( { additionalProperties: false }, ); -// Channel docking: channels.status is intentionally schema-light so new -// channels can ship without protocol updates. +/** + * Per-account status snapshot for channel docking. + * + * This is intentionally schema-light so new channel-specific metadata can ship + * without a gateway protocol update; known fields stay documented for UI use. + */ export const ChannelAccountSnapshotSchema = Type.Object( { accountId: NonEmptyString, @@ -683,6 +745,7 @@ export const ChannelAccountSnapshotSchema = Type.Object( { additionalProperties: true }, ); +/** UI label and icon metadata for one channel. */ export const ChannelUiMetaSchema = Type.Object( { id: NonEmptyString, @@ -693,6 +756,7 @@ export const ChannelUiMetaSchema = Type.Object( { additionalProperties: false }, ); +/** Event-loop health snapshot included with channel status responses. */ export const ChannelEventLoopHealthSchema = Type.Object( { degraded: Type.Boolean(), @@ -712,6 +776,7 @@ export const ChannelEventLoopHealthSchema = Type.Object( { additionalProperties: false }, ); +/** Full channel status result for dashboard and operator diagnostics. */ export const ChannelsStatusResultSchema = Type.Object( { ts: Type.Integer({ minimum: 0 }), @@ -730,6 +795,7 @@ export const ChannelsStatusResultSchema = Type.Object( { additionalProperties: false }, ); +/** Logs out one channel account. */ export const ChannelsLogoutParamsSchema = Type.Object( { channel: NonEmptyString, @@ -738,6 +804,7 @@ export const ChannelsLogoutParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Stops one channel account runtime. */ export const ChannelsStopParamsSchema = Type.Object( { channel: NonEmptyString, @@ -746,6 +813,7 @@ export const ChannelsStopParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Starts one channel account runtime. */ export const ChannelsStartParamsSchema = Type.Object( { channel: NonEmptyString, @@ -754,6 +822,7 @@ export const ChannelsStartParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Starts browser/web login for a channel account. */ export const WebLoginStartParamsSchema = Type.Object( { force: Type.Optional(Type.Boolean()), @@ -769,6 +838,7 @@ const QrDataUrlSchema = Type.String({ pattern: "^data:image/png;base64,", }); +/** Waits for web login completion or the next QR code. */ export const WebLoginWaitParamsSchema = Type.Object( { timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), diff --git a/packages/gateway-protocol/src/schema/commands.ts b/packages/gateway-protocol/src/schema/commands.ts index 2c0be369d6b6..b63572059d38 100644 --- a/packages/gateway-protocol/src/schema/commands.ts +++ b/packages/gateway-protocol/src/schema/commands.ts @@ -1,31 +1,50 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Command catalog protocol schemas. + * + * Command entries describe native, skill, and plugin commands that clients can + * render or route; limits keep command catalogs bounded for UI and transport. + */ +/** Maximum command display/name length accepted in catalog entries. */ export const COMMAND_NAME_MAX_LENGTH = 200; +/** Maximum command description length accepted in catalog entries. */ export const COMMAND_DESCRIPTION_MAX_LENGTH = 2_000; +/** Maximum text aliases advertised for one command. */ export const COMMAND_ALIAS_MAX_ITEMS = 20; +/** Maximum declared arguments advertised for one command. */ export const COMMAND_ARGS_MAX_ITEMS = 20; +/** Maximum argument name length accepted in catalog entries. */ export const COMMAND_ARG_NAME_MAX_LENGTH = 200; +/** Maximum argument description length accepted in catalog entries. */ export const COMMAND_ARG_DESCRIPTION_MAX_LENGTH = 500; +/** Maximum static choices advertised for one argument. */ export const COMMAND_ARG_CHOICES_MAX_ITEMS = 50; +/** Maximum machine-readable choice value length. */ export const COMMAND_CHOICE_VALUE_MAX_LENGTH = 200; +/** Maximum user-facing choice label length. */ export const COMMAND_CHOICE_LABEL_MAX_LENGTH = 200; +/** Maximum commands returned by one catalog response. */ export const COMMAND_LIST_MAX_ITEMS = 500; const BoundedNonEmptyString = (maxLength: number) => Type.String({ minLength: 1, maxLength }); +/** Source system that contributed a command. */ export const CommandSourceSchema = Type.Union([ Type.Literal("native"), Type.Literal("skill"), Type.Literal("plugin"), ]); +/** Surfaces where a command may be invoked. */ export const CommandScopeSchema = Type.Union([ Type.Literal("text"), Type.Literal("native"), Type.Literal("both"), ]); +/** Coarse UI grouping for command catalog display. */ export const CommandCategorySchema = Type.Union([ Type.Literal("session"), Type.Literal("options"), @@ -36,6 +55,7 @@ export const CommandCategorySchema = Type.Union([ Type.Literal("docks"), ]); +/** Static argument choice shown to clients. */ export const CommandArgChoiceSchema = Type.Object( { value: Type.String({ maxLength: COMMAND_CHOICE_VALUE_MAX_LENGTH }), @@ -44,6 +64,7 @@ export const CommandArgChoiceSchema = Type.Object( { additionalProperties: false }, ); +/** One typed argument advertised for a command. */ export const CommandArgSchema = Type.Object( { name: BoundedNonEmptyString(COMMAND_ARG_NAME_MAX_LENGTH), @@ -58,6 +79,7 @@ export const CommandArgSchema = Type.Object( { additionalProperties: false }, ); +/** One command catalog entry visible to clients. */ export const CommandEntrySchema = Type.Object( { name: BoundedNonEmptyString(COMMAND_NAME_MAX_LENGTH), @@ -77,6 +99,7 @@ export const CommandEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Command catalog request filters. */ export const CommandsListParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -87,6 +110,7 @@ export const CommandsListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Bounded command catalog response. */ export const CommandsListResultSchema = Type.Object( { commands: Type.Array(CommandEntrySchema, { maxItems: COMMAND_LIST_MAX_ITEMS }), diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index 1fad7df920e3..b234b4fc76d1 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -1,6 +1,12 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Gateway config and update protocol schemas. + * + * These payloads carry raw config text plus optional delivery context so the + * gateway can report edits/restarts back to the originating channel. + */ const ConfigSchemaLookupPathString = Type.String({ minLength: 1, maxLength: 1024, @@ -17,8 +23,10 @@ const ConfigDeliveryContextSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for reading the current raw config. */ export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Full raw config replacement request with optional base hash guard. */ export const ConfigSetParamsSchema = Type.Object( { raw: NonEmptyString, @@ -27,6 +35,7 @@ export const ConfigSetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Shared config apply/patch payload with optional restart notification context. */ const ConfigApplyLikeParamsSchema = Type.Object( { raw: NonEmptyString, @@ -39,11 +48,15 @@ const ConfigApplyLikeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Raw config apply request that may schedule a restart. */ export const ConfigApplyParamsSchema = ConfigApplyLikeParamsSchema; +/** Raw config patch request that may schedule a restart. */ export const ConfigPatchParamsSchema = ConfigApplyLikeParamsSchema; +/** Empty request payload for fetching the generated config schema. */ export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Schema lookup request for one config path. */ export const ConfigSchemaLookupParamsSchema = Type.Object( { path: ConfigSchemaLookupPathString, @@ -51,8 +64,10 @@ export const ConfigSchemaLookupParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for checking update/restart status. */ export const UpdateStatusParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Request payload for running an update/restart flow with optional channel delivery context. */ export const UpdateRunParamsSchema = Type.Object( { sessionKey: Type.Optional(Type.String()), @@ -65,6 +80,7 @@ export const UpdateRunParamsSchema = Type.Object( { additionalProperties: false }, ); +/** UI metadata attached to config schema paths. */ export const ConfigUiHintSchema = Type.Object( { label: Type.Optional(Type.String()), @@ -80,6 +96,7 @@ export const ConfigUiHintSchema = Type.Object( { additionalProperties: false }, ); +/** Full generated config schema response. */ export const ConfigSchemaResponseSchema = Type.Object( { schema: Type.Unknown(), @@ -90,6 +107,7 @@ export const ConfigSchemaResponseSchema = Type.Object( { additionalProperties: false }, ); +/** Child entry returned when looking up a config schema path. */ export const ConfigSchemaLookupChildSchema = Type.Object( { key: NonEmptyString, @@ -106,6 +124,7 @@ export const ConfigSchemaLookupChildSchema = Type.Object( { additionalProperties: false }, ); +/** Schema lookup response for one config path and its immediate children. */ export const ConfigSchemaLookupResultSchema = Type.Object( { path: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 0c0096aa681e..06051102b482 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -1,6 +1,14 @@ import { Type, type TSchema } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Cron scheduler protocol schemas. + * + * These contracts describe scheduled agent turns, system events, delivery + * routing, run history, and mutable job state shared by gateway RPC clients. + */ + +/** Builds create/patch payload variants while preserving per-call field optionality. */ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSchema }) { return Type.Object( { @@ -18,13 +26,16 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSch ); } +/** Session target accepted by cron jobs. */ const CronSessionTargetSchema = Type.Union([ Type.Literal("main"), Type.Literal("isolated"), Type.Literal("current"), Type.String({ pattern: "^session:.+" }), ]); +/** Whether a cron job waits for heartbeat processing or wakes immediately. */ const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]); +/** Run status factory reused for the active field and deprecated alias metadata. */ function cronRunStatusSchema(options: Record = {}) { return Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")], options); } @@ -159,6 +170,7 @@ const CronRunLogJobIdSchema = Type.String({ pattern: "^[^/\\\\]+$", }); +/** Schedule expression for one-time, interval, or cron-expression jobs. */ export const CronScheduleSchema = Type.Union([ Type.Object( { @@ -186,6 +198,7 @@ export const CronScheduleSchema = Type.Union([ ), ]); +/** Full cron payload for new jobs. */ export const CronPayloadSchema = Type.Union([ Type.Object( { @@ -200,6 +213,7 @@ export const CronPayloadSchema = Type.Union([ }), ]); +/** Partial cron payload for job updates. */ export const CronPayloadPatchSchema = Type.Union([ Type.Object( { @@ -214,6 +228,7 @@ export const CronPayloadPatchSchema = Type.Union([ }), ]); +/** Failure alert policy for repeated cron run failures. */ export const CronFailureAlertSchema = Type.Object( { after: Type.Optional(Type.Integer({ minimum: 1 })), @@ -227,6 +242,7 @@ export const CronFailureAlertSchema = Type.Object( { additionalProperties: false }, ); +/** Delivery destination used when failure alerts need a separate target. */ export const CronFailureDestinationSchema = Type.Object( { channel: Type.Optional(CronAnnounceChannelSchema), @@ -301,12 +317,14 @@ const CronDeliveryWebhookSchema = Type.Object( { additionalProperties: false }, ); +/** Delivery policy for cron run output. */ export const CronDeliverySchema = Type.Union([ CronDeliveryNoopSchema, CronDeliveryAnnounceSchema, CronDeliveryWebhookSchema, ]); +/** Patch shape for cron delivery policy updates. */ export const CronDeliveryPatchSchema = Type.Object( { mode: Type.Optional( @@ -330,6 +348,7 @@ const CronFailureNotificationDeliverySchema = Type.Object( { additionalProperties: false }, ); +/** Scheduler-maintained state for the latest run/delivery outcome. */ export const CronJobStateSchema = Type.Object( { nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), @@ -378,6 +397,7 @@ const CronJobStatePatchSchema = Type.Object( { additionalProperties: false }, ); +/** Persisted cron job definition returned by scheduler list/get APIs. */ export const CronJobSchema = Type.Object( { id: NonEmptyString, @@ -400,6 +420,7 @@ export const CronJobSchema = Type.Object( { additionalProperties: false }, ); +/** Query params for listing cron jobs with filters and pagination. */ export const CronListParamsSchema = Type.Object( { includeDisabled: Type.Optional(Type.Boolean()), @@ -416,10 +437,13 @@ export const CronListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for scheduler status. */ export const CronStatusParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Looks up a job by stable id or legacy jobId alias. */ export const CronGetParamsSchema = cronIdOrJobIdParams({}); +/** Creates a scheduled job with schedule, target, payload, and delivery policy. */ export const CronAddParamsSchema = Type.Object( { name: NonEmptyString, @@ -434,6 +458,7 @@ export const CronAddParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Mutable cron job fields accepted by update APIs. */ export const CronJobPatchSchema = Type.Object( { name: Type.Optional(NonEmptyString), @@ -449,16 +474,20 @@ export const CronJobPatchSchema = Type.Object( { additionalProperties: false }, ); +/** Updates a cron job by id or legacy jobId alias. */ export const CronUpdateParamsSchema = cronIdOrJobIdParams({ patch: CronJobPatchSchema, }); +/** Removes a cron job by id or legacy jobId alias. */ export const CronRemoveParamsSchema = cronIdOrJobIdParams({}); +/** Runs a cron job immediately or only if due. */ export const CronRunParamsSchema = cronIdOrJobIdParams({ mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])), }); +/** Query params for cron run history. */ export const CronRunsParamsSchema = Type.Object( { scope: Type.Optional(Type.Union([Type.Literal("job"), Type.Literal("all")])), @@ -479,6 +508,7 @@ export const CronRunsParamsSchema = Type.Object( { additionalProperties: false }, ); +/** One persisted cron run history entry. */ export const CronRunLogEntrySchema = Type.Object( { ts: Type.Integer({ minimum: 0 }), diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 0fd35ab07b61..017f1ab503f6 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -1,23 +1,34 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Device pairing and token-management protocol schemas. + * + * These payloads cross the gateway approval boundary, so request ids and device + * ids stay explicit and feature handlers own the authorization checks. + */ +/** Lists pending and approved device pairing records. */ export const DevicePairListParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Approves a pending pairing request by request id. */ export const DevicePairApproveParamsSchema = Type.Object( { requestId: NonEmptyString }, { additionalProperties: false }, ); +/** Rejects a pending pairing request by request id. */ export const DevicePairRejectParamsSchema = Type.Object( { requestId: NonEmptyString }, { additionalProperties: false }, ); +/** Removes an approved or remembered device by device id. */ export const DevicePairRemoveParamsSchema = Type.Object( { deviceId: NonEmptyString }, { additionalProperties: false }, ); +/** Rotates or issues a device token for a specific role/scope grant. */ export const DeviceTokenRotateParamsSchema = Type.Object( { deviceId: NonEmptyString, @@ -27,6 +38,7 @@ export const DeviceTokenRotateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Revokes one role-bound device token grant. */ export const DeviceTokenRevokeParamsSchema = Type.Object( { deviceId: NonEmptyString, @@ -35,6 +47,7 @@ export const DeviceTokenRevokeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Event emitted when a client opens or refreshes a pairing request. */ export const DevicePairRequestedEventSchema = Type.Object( { requestId: NonEmptyString, @@ -56,6 +69,7 @@ export const DevicePairRequestedEventSchema = Type.Object( { additionalProperties: false }, ); +/** Event emitted after a pairing request is approved, rejected, or otherwise resolved. */ export const DevicePairResolvedEventSchema = Type.Object( { requestId: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index 6025c7c2a9eb..3bfe0de4873f 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -1,6 +1,13 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Environment inventory protocol schemas. + * + * Environments are runtime targets such as local hosts, VMs, or remote workers; + * this schema layer only describes their gateway-visible status summary. + */ +/** Runtime availability state for an environment target. */ export const EnvironmentStatusSchema = Type.String({ enum: ["available", "unavailable", "starting", "stopping", "error"], }); @@ -18,10 +25,13 @@ function createEnvironmentSummarySchema() { ); } +/** Public environment summary shown in listings and status responses. */ export const EnvironmentSummarySchema = createEnvironmentSummarySchema(); +/** Empty request payload for listing known environments. */ export const EnvironmentsListParamsSchema = Type.Object({}, { additionalProperties: false }); +/** List response containing all gateway-visible environment summaries. */ export const EnvironmentsListResultSchema = Type.Object( { environments: Type.Array(EnvironmentSummarySchema), @@ -29,9 +39,11 @@ export const EnvironmentsListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Status lookup request for one environment id. */ export const EnvironmentsStatusParamsSchema = Type.Object( { environmentId: NonEmptyString }, { additionalProperties: false }, ); +/** Status lookup result for one environment id. */ export const EnvironmentsStatusResultSchema = createEnvironmentSummarySchema(); diff --git a/packages/gateway-protocol/src/schema/error-codes.ts b/packages/gateway-protocol/src/schema/error-codes.ts index 2e282990f83e..619c0fa730d8 100644 --- a/packages/gateway-protocol/src/schema/error-codes.ts +++ b/packages/gateway-protocol/src/schema/error-codes.ts @@ -1,16 +1,25 @@ import type { ErrorShape } from "./types.js"; +/** Gateway JSON-RPC style error codes shared by clients and server handlers. */ export const ErrorCodes = { + /** Client has not completed account/device linking for this gateway. */ NOT_LINKED: "NOT_LINKED", + /** Device exists but still needs an explicit pairing approval. */ NOT_PAIRED: "NOT_PAIRED", + /** Agent turn exceeded the gateway wait window. */ AGENT_TIMEOUT: "AGENT_TIMEOUT", + /** Request payload failed protocol validation or method preconditions. */ INVALID_REQUEST: "INVALID_REQUEST", + /** Approval resolution referenced a missing or expired approval request. */ APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND", + /** Gateway service or required backend is temporarily unavailable. */ UNAVAILABLE: "UNAVAILABLE", } as const; +/** Closed set of canonical gateway error code strings. */ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; +/** Builds the canonical gateway error payload while preserving optional retry metadata. */ export function errorShape( code: ErrorCode, message: string, diff --git a/packages/gateway-protocol/src/schema/exec-approvals.ts b/packages/gateway-protocol/src/schema/exec-approvals.ts index ce328984aaa6..c7133ebe8196 100644 --- a/packages/gateway-protocol/src/schema/exec-approvals.ts +++ b/packages/gateway-protocol/src/schema/exec-approvals.ts @@ -1,6 +1,13 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Exec approval protocol schemas. + * + * These payloads cross the security-review boundary for command execution, so + * persisted policy, request snapshots, and resolve decisions stay explicit. + */ +/** One persisted allowlist entry for a command pattern or resolved executable. */ export const ExecApprovalsAllowlistEntrySchema = Type.Object( { id: Type.Optional(NonEmptyString), @@ -22,10 +29,12 @@ const ExecApprovalsPolicyFields = { autoAllowSkills: Type.Optional(Type.Boolean()), }; +/** Default exec approval policy shared by all agents unless overridden. */ export const ExecApprovalsDefaultsSchema = Type.Object(ExecApprovalsPolicyFields, { additionalProperties: false, }); +/** Agent-specific exec approval policy and allowlist. */ export const ExecApprovalsAgentSchema = Type.Object( { ...ExecApprovalsPolicyFields, @@ -34,6 +43,7 @@ export const ExecApprovalsAgentSchema = Type.Object( { additionalProperties: false }, ); +/** Versioned exec approvals config file edited through gateway APIs. */ export const ExecApprovalsFileSchema = Type.Object( { version: Type.Literal(1), @@ -52,6 +62,7 @@ export const ExecApprovalsFileSchema = Type.Object( { additionalProperties: false }, ); +/** Read snapshot with path/hash metadata for optimistic writes. */ export const ExecApprovalsSnapshotSchema = Type.Object( { path: NonEmptyString, @@ -62,8 +73,10 @@ export const ExecApprovalsSnapshotSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for reading local exec approval policy. */ export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Local exec approval policy write request with optional base hash guard. */ export const ExecApprovalsSetParamsSchema = Type.Object( { file: ExecApprovalsFileSchema, @@ -72,6 +85,7 @@ export const ExecApprovalsSetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Node-scoped request payload for reading exec approval policy. */ export const ExecApprovalsNodeGetParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -79,6 +93,7 @@ export const ExecApprovalsNodeGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Node-scoped exec approval policy write request with optional base hash guard. */ export const ExecApprovalsNodeSetParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -88,6 +103,7 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Lookup request for one pending exec approval by id. */ export const ExecApprovalGetParamsSchema = Type.Object( { id: NonEmptyString, @@ -95,6 +111,7 @@ export const ExecApprovalGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Pending command execution approval request shown to reviewers. */ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), @@ -166,6 +183,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reviewer decision payload for one pending exec approval. */ export const ExecApprovalResolveParamsSchema = Type.Object( { id: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 043036192ef3..5c5e2b8d3e66 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -2,6 +2,13 @@ import { Type } from "typebox"; import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js"; import { SnapshotSchema, StateVersionSchema } from "./snapshot.js"; +/** + * Top-level gateway frame schemas. + * + * These are the WebSocket envelope contracts; method/event payload schemas live + * in feature-specific modules and are referenced by runtime validators. + */ +/** Periodic server heartbeat event payload. */ export const TickEventSchema = Type.Object( { ts: Type.Integer({ minimum: 0 }), @@ -9,6 +16,7 @@ export const TickEventSchema = Type.Object( { additionalProperties: false }, ); +/** Server shutdown notice event payload. */ export const ShutdownEventSchema = Type.Object( { reason: NonEmptyString, @@ -17,6 +25,7 @@ export const ShutdownEventSchema = Type.Object( { additionalProperties: false }, ); +/** Initial client hello/connect payload sent before the gateway accepts frames. */ export const ConnectParamsSchema = Type.Object( { minProtocol: Type.Integer({ minimum: 1 }), @@ -70,6 +79,7 @@ export const ConnectParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Successful gateway hello response with negotiated protocol and initial state. */ export const HelloOkSchema = Type.Object( { type: Type.Literal("hello-ok"), @@ -124,6 +134,7 @@ export const HelloOkSchema = Type.Object( { additionalProperties: false }, ); +/** Standard structured error shape used in response frames and connect failures. */ export const ErrorShapeSchema = Type.Object( { code: NonEmptyString, @@ -135,6 +146,7 @@ export const ErrorShapeSchema = Type.Object( { additionalProperties: false }, ); +/** Client request frame envelope; `method` selects the payload validator. */ export const RequestFrameSchema = Type.Object( { type: Type.Literal("req"), @@ -145,6 +157,7 @@ export const RequestFrameSchema = Type.Object( { additionalProperties: false }, ); +/** Server response frame envelope paired with a prior request id. */ export const ResponseFrameSchema = Type.Object( { type: Type.Literal("res"), @@ -156,6 +169,7 @@ export const ResponseFrameSchema = Type.Object( { additionalProperties: false }, ); +/** Server event frame envelope; `event` selects the payload validator. */ export const EventFrameSchema = Type.Object( { type: Type.Literal("event"), diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index 67dea809afd0..234c9d28e360 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -2,6 +2,7 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { ChatSendSessionKeyString, InputProvenanceSchema, NonEmptyString } from "./primitives.js"; +/** Cursor-based request for the gateway log tail endpoint. */ export const LogsTailParamsSchema = Type.Object( { cursor: Type.Optional(Type.Integer({ minimum: 0 })), @@ -11,6 +12,7 @@ export const LogsTailParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Gateway log tail payload returned to dashboard clients. */ export const LogsTailResultSchema = Type.Object( { file: NonEmptyString, @@ -23,7 +25,7 @@ export const LogsTailResultSchema = Type.Object( { additionalProperties: false }, ); -// WebChat/WebSocket-native chat methods +/** Session-scoped history request used by WebChat and native WebSocket clients. */ export const ChatHistoryParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -34,6 +36,7 @@ export const ChatHistoryParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Lightweight chat metadata request; optional agent scope keeps selector state explicit. */ export const ChatMetadataParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), @@ -41,6 +44,7 @@ export const ChatMetadataParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Fetches one stored chat message without forcing history callers to request huge payloads. */ export const ChatMessageGetParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -51,6 +55,7 @@ export const ChatMessageGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result envelope for single-message lookup, including the stable miss/visibility reason. */ export const ChatMessageGetResultSchema = Type.Object( { ok: Type.Boolean(), @@ -65,8 +70,10 @@ export const ChatMessageGetResultSchema = Type.Object( }, { additionalProperties: false }, ); +/** Typed result shape for callers that branch on message availability. */ export type ChatMessageGetResult = Static; +/** User-to-agent send request; idempotency key lets clients safely retry transport failures. */ export const ChatSendParamsSchema = Type.Object( { sessionKey: ChatSendSessionKeyString, @@ -89,6 +96,7 @@ export const ChatSendParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Cancels the active or named run for a chat session. */ export const ChatAbortParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -98,6 +106,7 @@ export const ChatAbortParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Inserts an operator-visible synthetic message into an existing chat transcript. */ export const ChatInjectParamsSchema = Type.Object( { sessionKey: NonEmptyString, @@ -108,6 +117,7 @@ export const ChatInjectParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Shared event fields preserve stream ordering and route events to the right session. */ const ChatEventBaseSchema = { runId: NonEmptyString, sessionKey: NonEmptyString, @@ -116,6 +126,7 @@ const ChatEventBaseSchema = { seq: Type.Integer({ minimum: 0 }), }; +/** Stable error categories exposed over the chat stream. */ const ChatEventErrorKindSchema = Type.Union([ Type.Literal("refusal"), Type.Literal("timeout"), @@ -124,6 +135,7 @@ const ChatEventErrorKindSchema = Type.Union([ Type.Literal("unknown"), ]); +/** Incremental assistant output event; `replace` marks full-content refresh deltas. */ export const ChatDeltaEventSchema = Type.Object( { ...ChatEventBaseSchema, @@ -136,6 +148,7 @@ export const ChatDeltaEventSchema = Type.Object( { additionalProperties: false }, ); +/** Successful terminal event for a completed chat run. */ export const ChatFinalEventSchema = Type.Object( { ...ChatEventBaseSchema, @@ -147,6 +160,7 @@ export const ChatFinalEventSchema = Type.Object( { additionalProperties: false }, ); +/** Terminal event for user-initiated or coordinator-initiated cancellation. */ export const ChatAbortedEventSchema = Type.Object( { ...ChatEventBaseSchema, @@ -157,6 +171,7 @@ export const ChatAbortedEventSchema = Type.Object( { additionalProperties: false }, ); +/** Terminal event for failed chat runs with an optional normalized failure kind. */ export const ChatErrorEventSchema = Type.Object( { ...ChatEventBaseSchema, @@ -170,6 +185,7 @@ export const ChatErrorEventSchema = Type.Object( { additionalProperties: false }, ); +/** Public chat stream event union consumed by gateway protocol validators. */ export const ChatEventSchema = Type.Union([ ChatDeltaEventSchema, ChatFinalEventSchema, diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index 5cd535fc714b..053f85798b9f 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -1,14 +1,17 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** Pending node work classes that the gateway may queue for paired devices. */ const NodePendingWorkTypeSchema = Type.String({ enum: ["status.request", "location.request"], }); +/** Queue priority accepted when operators enqueue node work. */ const NodePendingWorkPrioritySchema = Type.String({ enum: ["normal", "high"], }); +/** Reasons a node can report itself alive without implying an operator action. */ export const NodePresenceAliveReasonSchema = Type.String({ enum: [ "background", @@ -20,6 +23,7 @@ export const NodePresenceAliveReasonSchema = Type.String({ ], }); +/** Presence heartbeat payload sent by remote nodes to refresh gateway state. */ export const NodePresenceAlivePayloadSchema = Type.Object( { trigger: NodePresenceAliveReasonSchema, @@ -34,6 +38,7 @@ export const NodePresenceAlivePayloadSchema = Type.Object( { additionalProperties: false }, ); +/** Normalized result for node-originated events after gateway dispatch. */ export const NodeEventResultSchema = Type.Object( { ok: Type.Boolean(), @@ -44,6 +49,7 @@ export const NodeEventResultSchema = Type.Object( { additionalProperties: false }, ); +/** Pairing request metadata advertised by a node before trust is granted. */ export const NodePairRequestParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -63,35 +69,43 @@ export const NodePairRequestParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Lists pending node-pairing requests. */ export const NodePairListParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Approves a pending node-pairing request by request id. */ export const NodePairApproveParamsSchema = Type.Object( { requestId: NonEmptyString }, { additionalProperties: false }, ); +/** Rejects a pending node-pairing request by request id. */ export const NodePairRejectParamsSchema = Type.Object( { requestId: NonEmptyString }, { additionalProperties: false }, ); +/** Removes an already paired node from the gateway trust set. */ export const NodePairRemoveParamsSchema = Type.Object( { nodeId: NonEmptyString }, { additionalProperties: false }, ); +/** Verifies node ownership with a short-lived pairing token. */ export const NodePairVerifyParamsSchema = Type.Object( { nodeId: NonEmptyString, token: NonEmptyString }, { additionalProperties: false }, ); +/** Renames a paired node while preserving its stable node id. */ export const NodeRenameParamsSchema = Type.Object( { nodeId: NonEmptyString, displayName: NonEmptyString }, { additionalProperties: false }, ); +/** Lists paired nodes known to the gateway. */ export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Acknowledges queued node work that the node has consumed. */ export const NodePendingAckParamsSchema = Type.Object( { ids: Type.Array(NonEmptyString, { minItems: 1 }), @@ -99,11 +113,13 @@ export const NodePendingAckParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Requests detailed metadata for one paired node. */ export const NodeDescribeParamsSchema = Type.Object( { nodeId: NonEmptyString }, { additionalProperties: false }, ); +/** Invokes a command on a paired node; idempotency allows safe retries. */ export const NodeInvokeParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -115,6 +131,7 @@ export const NodeInvokeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result callback payload for a node command invocation. */ export const NodeInvokeResultParamsSchema = Type.Object( { id: NonEmptyString, @@ -135,6 +152,7 @@ export const NodeInvokeResultParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Generic node event envelope accepted by the gateway. */ export const NodeEventParamsSchema = Type.Object( { event: NonEmptyString, @@ -144,6 +162,7 @@ export const NodeEventParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Request for a bounded batch of queued work assigned to the calling node. */ export const NodePendingDrainParamsSchema = Type.Object( { maxItems: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), @@ -151,6 +170,7 @@ export const NodePendingDrainParamsSchema = Type.Object( { additionalProperties: false }, ); +/** One queued node-work item returned by pending-work drain calls. */ export const NodePendingDrainItemSchema = Type.Object( { id: NonEmptyString, @@ -163,6 +183,7 @@ export const NodePendingDrainItemSchema = Type.Object( { additionalProperties: false }, ); +/** Drain response with a revision marker for node queue state. */ export const NodePendingDrainResultSchema = Type.Object( { nodeId: NonEmptyString, @@ -173,6 +194,7 @@ export const NodePendingDrainResultSchema = Type.Object( { additionalProperties: false }, ); +/** Enqueues gateway-initiated work for a paired node. */ export const NodePendingEnqueueParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -184,6 +206,7 @@ export const NodePendingEnqueueParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Enqueue result echoes queue revision and whether wake delivery was attempted. */ export const NodePendingEnqueueResultSchema = Type.Object( { nodeId: NonEmptyString, @@ -194,6 +217,7 @@ export const NodePendingEnqueueResultSchema = Type.Object( { additionalProperties: false }, ); +/** Event payload used by the gateway to ask a node to run a command. */ export const NodeInvokeRequestEventSchema = Type.Object( { id: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/plugin-approvals.ts b/packages/gateway-protocol/src/schema/plugin-approvals.ts index 1f8212335429..a11562227625 100644 --- a/packages/gateway-protocol/src/schema/plugin-approvals.ts +++ b/packages/gateway-protocol/src/schema/plugin-approvals.ts @@ -1,10 +1,18 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Plugin approval schemas. + * + * These payloads cross from plugin/tool execution into reviewer-facing UI, so + * title, description, decision set, and timeout limits are part of the public + * gateway contract. + */ const MAX_PLUGIN_APPROVAL_TIMEOUT_MS = 600_000; const PLUGIN_APPROVAL_TITLE_MAX_LENGTH = 80; const PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH = 256; +/** Approval request raised by a plugin before a sensitive tool action proceeds. */ export const PluginApprovalRequestParamsSchema = Type.Object( { pluginId: Type.Optional(NonEmptyString), @@ -31,6 +39,7 @@ export const PluginApprovalRequestParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reviewer decision payload resolving one pending plugin approval request. */ export const PluginApprovalResolveParamsSchema = Type.Object( { id: NonEmptyString, diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 994559402568..7d4d6c978208 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -1,8 +1,16 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Plugin control-surface protocol schemas. + * + * These payloads let the gateway expose plugin-provided UI actions without + * baking plugin-specific payload shapes into the core protocol. + */ +/** Arbitrary plugin-owned JSON payload carried opaquely through the gateway. */ export const PluginJsonValueSchema = Type.Unknown(); +/** Descriptor for one plugin-provided control UI action or surface. */ export const PluginControlUiDescriptorSchema = Type.Object( { id: NonEmptyString, @@ -23,8 +31,10 @@ export const PluginControlUiDescriptorSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for listing plugin UI descriptors. */ export const PluginsUiDescriptorsParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Response payload containing all plugin UI descriptors visible to the client. */ export const PluginsUiDescriptorsResultSchema = Type.Object( { ok: Type.Literal(true), @@ -33,6 +43,7 @@ export const PluginsUiDescriptorsResultSchema = Type.Object( { additionalProperties: false }, ); +/** Request payload for invoking one plugin-owned session action. */ export const PluginsSessionActionParamsSchema = Type.Object( { pluginId: NonEmptyString, @@ -43,6 +54,7 @@ export const PluginsSessionActionParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Successful plugin action result, optionally continuing the agent turn. */ export const PluginsSessionActionSuccessResultSchema = Type.Object( { ok: Type.Literal(true), @@ -53,6 +65,7 @@ export const PluginsSessionActionSuccessResultSchema = Type.Object( { additionalProperties: false }, ); +/** Failed plugin action result with plugin-owned detail payload. */ export const PluginsSessionActionFailureResultSchema = Type.Object( { ok: Type.Literal(false), @@ -63,6 +76,7 @@ export const PluginsSessionActionFailureResultSchema = Type.Object( { additionalProperties: false }, ); +/** Discriminated plugin action result returned to gateway clients. */ export const PluginsSessionActionResultSchema = Type.Union([ PluginsSessionActionSuccessResultSchema, PluginsSessionActionFailureResultSchema, diff --git a/packages/gateway-protocol/src/schema/primitives.ts b/packages/gateway-protocol/src/schema/primitives.ts index e6bcf33a1f78..20ef68c12afc 100644 --- a/packages/gateway-protocol/src/schema/primitives.ts +++ b/packages/gateway-protocol/src/schema/primitives.ts @@ -8,20 +8,31 @@ import { SINGLE_VALUE_FILE_REF_ID, } from "../secret-ref-contract.js"; +/** + * Shared schema primitives reused by gateway protocol request/result schemas. + * + * Keep these schemas small and transport-oriented; feature-specific validation + * belongs in the owning schema module or runtime handler. + */ const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; const INPUT_PROVENANCE_KIND_VALUES = ["external_user", "inter_session", "internal_system"] as const; const SESSION_LABEL_MAX_LENGTH = 512; +/** Non-empty string primitive for protocol fields that reject blank values. */ export const NonEmptyString = Type.String({ minLength: 1 }); +/** Maximum stable session key length accepted by chat-send protocol requests. */ export const CHAT_SEND_SESSION_KEY_MAX_LENGTH = 512; +/** Chat-send session key string primitive with bounded length. */ export const ChatSendSessionKeyString = Type.String({ minLength: 1, maxLength: CHAT_SEND_SESSION_KEY_MAX_LENGTH, }); +/** Human-readable session label primitive with bounded display length. */ export const SessionLabelString = Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH, }); +/** Provenance marker for content copied from another user/session/system source. */ export const InputProvenanceSchema = Type.Object( { kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }), @@ -33,10 +44,13 @@ export const InputProvenanceSchema = Type.Object( { additionalProperties: false }, ); +/** Closed gateway client id schema aligned with `GATEWAY_CLIENT_IDS`. */ export const GatewayClientIdSchema = Type.Enum(GATEWAY_CLIENT_IDS); +/** Closed gateway client mode schema aligned with `GATEWAY_CLIENT_MODES`. */ export const GatewayClientModeSchema = Type.Enum(GATEWAY_CLIENT_MODES); +/** Supported secret reference backing stores for protocol SecretRef payloads. */ export const SecretRefSourceSchema = Type.Union([ Type.Literal("env"), Type.Literal("file"), @@ -87,10 +101,12 @@ const ExecSecretRefSchema = Type.Object( { additionalProperties: false }, ); +/** Structured secret reference accepted by config and channel protocol payloads. */ export const SecretRefSchema = Type.Union([ EnvSecretRefSchema, FileSecretRefSchema, ExecSecretRefSchema, ]); +/** Secret input value: either an inline string or a structured SecretRef. */ export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]); diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 124b19f1190f..119b02903511 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -1,3 +1,10 @@ +/** + * Central registry for every gateway protocol schema. + * + * The keys in this object are the public schema names used by validators, + * generated static types, and protocol tooling. Add new entries here only after + * the owning schema module exports the canonical TypeBox schema. + */ import type { TSchema } from "typebox"; import { AgentEventSchema, @@ -292,7 +299,9 @@ import { WizardStepSchema, } from "./wizard.js"; +/** Public schema registry keyed by stable protocol schema name. */ export const ProtocolSchemas = { + // Handshake, transport frames, state snapshots, and shared error envelopes. ConnectParams: ConnectParamsSchema, HelloOk: HelloOkSchema, RequestFrame: RequestFrameSchema, @@ -303,6 +312,8 @@ export const ProtocolSchemas = { StateVersion: StateVersionSchema, Snapshot: SnapshotSchema, ErrorShape: ErrorShapeSchema, + + // Environment and agent-facing control RPC payloads. EnvironmentStatus: EnvironmentStatusSchema, EnvironmentSummary: EnvironmentSummarySchema, EnvironmentsListParams: EnvironmentsListParamsSchema, @@ -318,6 +329,8 @@ export const ProtocolSchemas = { AgentIdentityResult: AgentIdentityResultSchema, AgentWaitParams: AgentWaitParamsSchema, WakeParams: WakeParamsSchema, + + // Node pairing, invocation, presence, and pending-queue payloads. NodePairRequestParams: NodePairRequestParamsSchema, NodePairListParams: NodePairListParamsSchema, NodePairApproveParams: NodePairApproveParamsSchema, @@ -339,12 +352,16 @@ export const ProtocolSchemas = { NodePendingEnqueueParams: NodePendingEnqueueParamsSchema, NodePendingEnqueueResult: NodePendingEnqueueResultSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, + + // Push and secret-resolution payloads used by mobile/control integrations. PushTestParams: PushTestParamsSchema, PushTestResult: PushTestResultSchema, SecretsReloadParams: SecretsReloadParamsSchema, SecretsResolveParams: SecretsResolveParamsSchema, SecretsResolveAssignment: SecretsResolveAssignmentSchema, SecretsResolveResult: SecretsResolveResultSchema, + + // Session lifecycle, message routing, compaction, and usage accounting. SessionsListParams: SessionsListParamsSchema, SessionsCleanupParams: SessionsCleanupParamsSchema, SessionsPreviewParams: SessionsPreviewParamsSchema, @@ -372,6 +389,8 @@ export const ProtocolSchemas = { SessionsDeleteParams: SessionsDeleteParamsSchema, SessionsCompactParams: SessionsCompactParamsSchema, SessionsUsageParams: SessionsUsageParamsSchema, + + // Task ledger and config/wizard setup payloads. TaskSummary: TaskSummarySchema, TasksListParams: TasksListParamsSchema, TasksListResult: TasksListResultSchema, @@ -395,6 +414,8 @@ export const ProtocolSchemas = { WizardNextResult: WizardNextResultSchema, WizardStartResult: WizardStartResultSchema, WizardStatusResult: WizardStatusResultSchema, + + // Realtime Talk client/session events and channel control payloads. TalkModeParams: TalkModeParamsSchema, TalkEvent: TalkEventSchema, TalkCatalogParams: TalkCatalogParamsSchema, @@ -429,6 +450,8 @@ export const ProtocolSchemas = { ChannelsLogoutParams: ChannelsLogoutParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema, + + // Agent files, artifacts, model catalogs, commands, tools, and skill workshop. AgentSummary: AgentSummarySchema, AgentsCreateParams: AgentsCreateParamsSchema, AgentsCreateResult: AgentsCreateResultSchema, @@ -497,6 +520,8 @@ export const ProtocolSchemas = { SkillsUploadCommitParams: SkillsUploadCommitParamsSchema, SkillsInstallParams: SkillsInstallParamsSchema, SkillsUpdateParams: SkillsUpdateParamsSchema, + + // Scheduler, logs, approval, plugin control, device, chat, and lifecycle events. CronJob: CronJobSchema, CronListParams: CronListParamsSchema, CronStatusParams: CronStatusParamsSchema, diff --git a/packages/gateway-protocol/src/schema/push.ts b/packages/gateway-protocol/src/schema/push.ts index c42761664455..a2d2be4db898 100644 --- a/packages/gateway-protocol/src/schema/push.ts +++ b/packages/gateway-protocol/src/schema/push.ts @@ -1,8 +1,15 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Push-notification protocol schemas. + * + * APNS test schemas exercise native push routing; Web Push schemas describe the + * browser subscription lifecycle exposed by the gateway. + */ const ApnsEnvironmentSchema = Type.String({ enum: ["sandbox", "production"] }); +/** Request payload for sending a test APNS notification to one node. */ export const PushTestParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -13,6 +20,7 @@ export const PushTestParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result payload from an APNS push test, including provider status and transport. */ export const PushTestResultSchema = Type.Object( { ok: Type.Boolean(), @@ -37,8 +45,10 @@ const WebPushKeysSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request payload for fetching the Web Push VAPID public key. */ export const WebPushVapidPublicKeyParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Browser Web Push subscription payload registered with the gateway. */ export const WebPushSubscribeParamsSchema = Type.Object( { endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), @@ -47,6 +57,7 @@ export const WebPushSubscribeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Browser Web Push endpoint removal payload. */ export const WebPushUnsubscribeParamsSchema = Type.Object( { endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), @@ -54,6 +65,7 @@ export const WebPushUnsubscribeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Request payload for sending a test Web Push notification to current subscriptions. */ export const WebPushTestParamsSchema = Type.Object( { title: Type.Optional(Type.String()), @@ -62,14 +74,18 @@ export const WebPushTestParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Empty request type for fetching the Web Push VAPID public key. */ export type WebPushVapidPublicKeyParams = Record; +/** Browser PushSubscription subset persisted by the gateway. */ export type WebPushSubscribeParams = { endpoint: string; keys: { p256dh: string; auth: string }; }; +/** Browser PushSubscription endpoint removal request. */ export type WebPushUnsubscribeParams = { endpoint: string; }; +/** Optional title/body overrides for a Web Push test notification. */ export type WebPushTestParams = { title?: string; body?: string; diff --git a/packages/gateway-protocol/src/schema/secrets.ts b/packages/gateway-protocol/src/schema/secrets.ts index e27f7835f7c3..84896065f9ab 100644 --- a/packages/gateway-protocol/src/schema/secrets.ts +++ b/packages/gateway-protocol/src/schema/secrets.ts @@ -1,8 +1,16 @@ import { Type, type Static } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Secret-provider protocol schemas. + * + * These payloads request secret materialization from the gateway while keeping + * caller scope, allowed paths, and provider overrides explicit. + */ +/** Empty request payload for reloading configured secret providers. */ export const SecretsReloadParamsSchema = Type.Object({}, { additionalProperties: false }); +/** Request payload for resolving the secrets needed by one command invocation. */ export const SecretsResolveParamsSchema = Type.Object( { commandName: NonEmptyString, @@ -23,8 +31,10 @@ export const SecretsResolveParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Static type for secret resolution requests. */ export type SecretsResolveParams = Static; +/** One resolved secret assignment path plus its provider-owned value. */ export const SecretsResolveAssignmentSchema = Type.Object( { path: Type.Optional(NonEmptyString), @@ -34,6 +44,7 @@ export const SecretsResolveAssignmentSchema = Type.Object( { additionalProperties: false }, ); +/** Secret resolution response with assignments and safe diagnostics. */ export const SecretsResolveResultSchema = Type.Object( { ok: Type.Optional(Type.Boolean()), @@ -44,4 +55,5 @@ export const SecretsResolveResultSchema = Type.Object( { additionalProperties: false }, ); +/** Static type for secret resolution responses. */ export type SecretsResolveResult = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 1ee640782d68..31e21a0a1bba 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -2,6 +2,15 @@ import { Type } from "typebox"; import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; +/** + * Session protocol schemas. + * + * These requests and results cover transcript discovery, lifecycle control, + * compaction checkpoints, per-session plugin state, and usage reporting. The + * schemas are shared by dashboard, CLI, ACP, and gateway RPC callers. + */ + +/** Reason a compaction checkpoint was created. */ export const SessionCompactionCheckpointReasonSchema = Type.Union([ Type.Literal("manual"), Type.Literal("auto-threshold"), @@ -9,6 +18,7 @@ export const SessionCompactionCheckpointReasonSchema = Type.Union([ Type.Literal("timeout-retry"), ]); +/** Start/end event emitted while a session compaction operation runs. */ export const SessionOperationEventSchema = Type.Object( { operationId: NonEmptyString, @@ -23,6 +33,7 @@ export const SessionOperationEventSchema = Type.Object( { additionalProperties: false }, ); +/** Reference to the transcript location before or after compaction. */ export const SessionCompactionTranscriptReferenceSchema = Type.Object( { sessionId: NonEmptyString, @@ -33,6 +44,7 @@ export const SessionCompactionTranscriptReferenceSchema = Type.Object( { additionalProperties: false }, ); +/** Stored compaction checkpoint metadata for branching or restoring a session. */ export const SessionCompactionCheckpointSchema = Type.Object( { checkpointId: NonEmptyString, @@ -50,6 +62,7 @@ export const SessionCompactionCheckpointSchema = Type.Object( { additionalProperties: false }, ); +/** Lists sessions with optional scope, activity, label, and preview filters. */ export const SessionsListParamsSchema = Type.Object( { /** @@ -84,6 +97,7 @@ export const SessionsListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Repairs or removes invalid session records from the selected agent scope. */ export const SessionsCleanupParamsSchema = Type.Object( { agent: Type.Optional(NonEmptyString), @@ -96,6 +110,7 @@ export const SessionsCleanupParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reads short previews for selected session keys. */ export const SessionsPreviewParamsSchema = Type.Object( { keys: Type.Array(NonEmptyString, { minItems: 1 }), @@ -105,6 +120,7 @@ export const SessionsPreviewParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Describes one session and optional derived title/last-message previews. */ export const SessionsDescribeParamsSchema = Type.Object( { key: NonEmptyString, @@ -114,6 +130,7 @@ export const SessionsDescribeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Resolves a session by key, raw session id, label, or parent/agent scope. */ export const SessionsResolveParamsSchema = Type.Object( { key: Type.Optional(NonEmptyString), @@ -127,6 +144,7 @@ export const SessionsResolveParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Creates or adopts a session with optional model, label, and parent linkage. */ export const SessionsCreateParamsSchema = Type.Object( { key: Type.Optional(NonEmptyString), @@ -141,6 +159,7 @@ export const SessionsCreateParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Sends one message into an existing session. */ export const SessionsSendParamsSchema = Type.Object( { key: NonEmptyString, @@ -154,6 +173,7 @@ export const SessionsSendParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Subscribes a client to live message updates for one session. */ export const SessionsMessagesSubscribeParamsSchema = Type.Object( { key: NonEmptyString, @@ -162,6 +182,7 @@ export const SessionsMessagesSubscribeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Removes a live message subscription for one session. */ export const SessionsMessagesUnsubscribeParamsSchema = Type.Object( { key: NonEmptyString, @@ -170,6 +191,7 @@ export const SessionsMessagesUnsubscribeParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Aborts the active or named run for a session. */ export const SessionsAbortParamsSchema = Type.Object( { key: Type.Optional(NonEmptyString), @@ -179,6 +201,7 @@ export const SessionsAbortParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Mutable per-session preferences and routing metadata. */ export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, @@ -227,6 +250,7 @@ export const SessionsPatchParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Updates or clears one plugin namespace value on a session record. */ export const SessionsPluginPatchParamsSchema = Type.Object( { key: NonEmptyString, @@ -238,6 +262,7 @@ export const SessionsPluginPatchParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Result returned after patching session plugin state. */ export const SessionsPluginPatchResultSchema = Type.Object( { ok: Type.Literal(true), @@ -247,6 +272,7 @@ export const SessionsPluginPatchResultSchema = Type.Object( { additionalProperties: false }, ); +/** Resets a session to a new or reset transcript state. */ export const SessionsResetParamsSchema = Type.Object( { key: NonEmptyString, @@ -256,6 +282,7 @@ export const SessionsResetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Deletes a session record and optionally its transcript. */ export const SessionsDeleteParamsSchema = Type.Object( { key: NonEmptyString, @@ -267,6 +294,7 @@ export const SessionsDeleteParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Requests manual compaction for a session transcript. */ export const SessionsCompactParamsSchema = Type.Object( { key: NonEmptyString, @@ -276,6 +304,7 @@ export const SessionsCompactParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Lists compaction checkpoints for one session. */ export const SessionsCompactionListParamsSchema = Type.Object( { key: NonEmptyString, @@ -284,6 +313,7 @@ export const SessionsCompactionListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Reads one compaction checkpoint by id. */ export const SessionsCompactionGetParamsSchema = Type.Object( { key: NonEmptyString, @@ -293,6 +323,7 @@ export const SessionsCompactionGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Creates a new branch from a compaction checkpoint. */ export const SessionsCompactionBranchParamsSchema = Type.Object( { key: NonEmptyString, @@ -302,6 +333,7 @@ export const SessionsCompactionBranchParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Restores an existing session to a compaction checkpoint. */ export const SessionsCompactionRestoreParamsSchema = Type.Object( { key: NonEmptyString, @@ -311,6 +343,7 @@ export const SessionsCompactionRestoreParamsSchema = Type.Object( { additionalProperties: false }, ); +/** List response for session compaction checkpoints. */ export const SessionsCompactionListResultSchema = Type.Object( { ok: Type.Literal(true), @@ -320,6 +353,7 @@ export const SessionsCompactionListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Get response for a single compaction checkpoint. */ export const SessionsCompactionGetResultSchema = Type.Object( { ok: Type.Literal(true), @@ -329,6 +363,7 @@ export const SessionsCompactionGetResultSchema = Type.Object( { additionalProperties: false }, ); +/** Branch response with the newly created session key and entry metadata. */ export const SessionsCompactionBranchResultSchema = Type.Object( { ok: Type.Literal(true), @@ -347,6 +382,7 @@ export const SessionsCompactionBranchResultSchema = Type.Object( { additionalProperties: false }, ); +/** Restore response with updated session entry metadata. */ export const SessionsCompactionRestoreResultSchema = Type.Object( { ok: Type.Literal(true), @@ -364,6 +400,7 @@ export const SessionsCompactionRestoreResultSchema = Type.Object( { additionalProperties: false }, ); +/** Usage report query across one session, one agent, or all agent sessions. */ export const SessionsUsageParamsSchema = Type.Object( { /** Specific session key to analyze; if omitted returns sessions for the effective agent. */ diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index 824246df6f19..25e7375550a2 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -1,6 +1,13 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Gateway state snapshot schemas. + * + * Snapshots are sent during hello and later event streams; they summarize node + * presence, health, session defaults, and version counters for clients. + */ +/** One gateway-visible presence record for a node/client/runtime. */ export const PresenceEntrySchema = Type.Object( { host: Type.Optional(NonEmptyString), @@ -23,8 +30,10 @@ export const PresenceEntrySchema = Type.Object( { additionalProperties: false }, ); +/** Health snapshot is intentionally opaque because providers contribute nested shapes. */ export const HealthSnapshotSchema = Type.Any(); +/** Default session routing keys included in initial gateway snapshots. */ export const SessionDefaultsSchema = Type.Object( { defaultAgentId: NonEmptyString, @@ -35,6 +44,7 @@ export const SessionDefaultsSchema = Type.Object( { additionalProperties: false }, ); +/** Monotonic version counters for snapshot subtrees. */ export const StateVersionSchema = Type.Object( { presence: Type.Integer({ minimum: 0 }), @@ -43,6 +53,7 @@ export const StateVersionSchema = Type.Object( { additionalProperties: false }, ); +/** Initial and incremental gateway state snapshot payload. */ export const SnapshotSchema = Type.Object( { presence: Type.Array(PresenceEntrySchema), diff --git a/packages/gateway-protocol/src/schema/tasks.ts b/packages/gateway-protocol/src/schema/tasks.ts index c30001e16393..2073319a5822 100644 --- a/packages/gateway-protocol/src/schema/tasks.ts +++ b/packages/gateway-protocol/src/schema/tasks.ts @@ -1,6 +1,13 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** + * Task ledger protocol schemas. + * + * Tasks represent long-running SDK/agent operations exposed through the gateway; + * these schemas keep list/get/cancel payloads bounded and status values closed. + */ +/** Closed task lifecycle statuses visible in the gateway task ledger. */ export const TaskLedgerStatusSchema = Type.Union([ Type.Literal("queued"), Type.Literal("running"), @@ -12,6 +19,7 @@ export const TaskLedgerStatusSchema = Type.Union([ const TimestampSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); +/** Public task summary returned by task list/get/cancel responses. */ export const TaskSummarySchema = Type.Object( { id: NonEmptyString, @@ -39,6 +47,7 @@ export const TaskSummarySchema = Type.Object( { additionalProperties: false }, ); +/** Task list filters with bounded pagination. */ export const TasksListParamsSchema = Type.Object( { status: Type.Optional(Type.Union([TaskLedgerStatusSchema, Type.Array(TaskLedgerStatusSchema)])), @@ -50,6 +59,7 @@ export const TasksListParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Task list page response. */ export const TasksListResultSchema = Type.Object( { tasks: Type.Array(TaskSummarySchema), @@ -58,6 +68,7 @@ export const TasksListResultSchema = Type.Object( { additionalProperties: false }, ); +/** Lookup request for one task id. */ export const TasksGetParamsSchema = Type.Object( { taskId: NonEmptyString, @@ -65,6 +76,7 @@ export const TasksGetParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Lookup result for one task summary. */ export const TasksGetResultSchema = Type.Object( { task: TaskSummarySchema, @@ -72,6 +84,7 @@ export const TasksGetResultSchema = Type.Object( { additionalProperties: false }, ); +/** Cancel request for one task id with optional operator reason. */ export const TasksCancelParamsSchema = Type.Object( { taskId: NonEmptyString, @@ -80,6 +93,7 @@ export const TasksCancelParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Cancel result, including the task snapshot when it was found. */ export const TasksCancelResultSchema = Type.Object( { found: Type.Boolean(), diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index b54a1ade28b5..b1d68f9410a1 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -1,9 +1,18 @@ +/** + * Static TypeScript types derived from the canonical gateway protocol schemas. + * + * Keep aliases wired through `ProtocolSchemas` so validators, runtime schemas, + * and exported compile-time types cannot drift apart. + */ import type { Static } from "typebox"; import { ProtocolSchemas } from "./protocol-schemas.js"; +/** Stable schema names registered in the protocol schema registry. */ type ProtocolSchemaName = keyof typeof ProtocolSchemas; +/** Inferred TypeScript type for a named TypeBox protocol schema. */ type SchemaType = Static<(typeof ProtocolSchemas)[TName]>; +/** Connection handshake, envelope, snapshot, and shared error wire types. */ export type ConnectParams = SchemaType<"ConnectParams">; export type HelloOk = SchemaType<"HelloOk">; export type RequestFrame = SchemaType<"RequestFrame">; @@ -14,12 +23,16 @@ export type Snapshot = SchemaType<"Snapshot">; export type PresenceEntry = SchemaType<"PresenceEntry">; export type ErrorShape = SchemaType<"ErrorShape">; export type StateVersion = SchemaType<"StateVersion">; + +/** Environment status RPC payloads used by CLI and Control UI surfaces. */ export type EnvironmentStatus = SchemaType<"EnvironmentStatus">; export type EnvironmentSummary = SchemaType<"EnvironmentSummary">; export type EnvironmentsListParams = SchemaType<"EnvironmentsListParams">; export type EnvironmentsListResult = SchemaType<"EnvironmentsListResult">; export type EnvironmentsStatusParams = SchemaType<"EnvironmentsStatusParams">; export type EnvironmentsStatusResult = SchemaType<"EnvironmentsStatusResult">; + +/** Agent activity, identity, send, poll, wait, and wake protocol payloads. */ export type AgentEvent = SchemaType<"AgentEvent">; export type AgentIdentityParams = SchemaType<"AgentIdentityParams">; export type AgentIdentityResult = SchemaType<"AgentIdentityResult">; @@ -27,6 +40,8 @@ export type MessageActionParams = SchemaType<"MessageActionParams">; export type PollParams = SchemaType<"PollParams">; export type AgentWaitParams = SchemaType<"AgentWaitParams">; export type WakeParams = SchemaType<"WakeParams">; + +/** Node pairing, presence, invoke, and pending-queue protocol payloads. */ export type NodePairRequestParams = SchemaType<"NodePairRequestParams">; export type NodePairListParams = SchemaType<"NodePairListParams">; export type NodePairApproveParams = SchemaType<"NodePairApproveParams">; @@ -47,8 +62,12 @@ export type NodePendingDrainParams = SchemaType<"NodePendingDrainParams">; export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">; export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">; export type NodePendingEnqueueResult = SchemaType<"NodePendingEnqueueResult">; + +/** Push notification test result contracts exposed through gateway RPC. */ export type PushTestParams = SchemaType<"PushTestParams">; export type PushTestResult = SchemaType<"PushTestResult">; + +/** Session lifecycle, message routing, compaction, patch, and usage payloads. */ export type SessionsListParams = SchemaType<"SessionsListParams">; export type SessionsCleanupParams = SchemaType<"SessionsCleanupParams">; export type SessionsPreviewParams = SchemaType<"SessionsPreviewParams">; @@ -76,6 +95,8 @@ export type SessionsResetParams = SchemaType<"SessionsResetParams">; export type SessionsDeleteParams = SchemaType<"SessionsDeleteParams">; export type SessionsCompactParams = SchemaType<"SessionsCompactParams">; export type SessionsUsageParams = SchemaType<"SessionsUsageParams">; + +/** Task ledger query and cancellation payloads. */ export type TaskSummary = SchemaType<"TaskSummary">; export type TasksListParams = SchemaType<"TasksListParams">; export type TasksListResult = SchemaType<"TasksListResult">; @@ -83,6 +104,8 @@ export type TasksGetParams = SchemaType<"TasksGetParams">; export type TasksGetResult = SchemaType<"TasksGetResult">; export type TasksCancelParams = SchemaType<"TasksCancelParams">; export type TasksCancelResult = SchemaType<"TasksCancelResult">; + +/** Config read/write/schema payloads plus update status and run controls. */ export type ConfigGetParams = SchemaType<"ConfigGetParams">; export type ConfigSetParams = SchemaType<"ConfigSetParams">; export type ConfigApplyParams = SchemaType<"ConfigApplyParams">; @@ -92,6 +115,8 @@ export type ConfigSchemaLookupParams = SchemaType<"ConfigSchemaLookupParams">; export type ConfigSchemaResponse = SchemaType<"ConfigSchemaResponse">; export type ConfigSchemaLookupResult = SchemaType<"ConfigSchemaLookupResult">; export type UpdateStatusParams = SchemaType<"UpdateStatusParams">; + +/** Wizard setup flow payloads exchanged by CLI, UI, and gateway. */ export type WizardStartParams = SchemaType<"WizardStartParams">; export type WizardNextParams = SchemaType<"WizardNextParams">; export type WizardCancelParams = SchemaType<"WizardCancelParams">; @@ -100,6 +125,8 @@ export type WizardStep = SchemaType<"WizardStep">; export type WizardNextResult = SchemaType<"WizardNextResult">; export type WizardStartResult = SchemaType<"WizardStartResult">; export type WizardStatusResult = SchemaType<"WizardStatusResult">; + +/** Realtime Talk client/session/event payloads. */ export type TalkEvent = SchemaType<"TalkEvent">; export type TalkModeParams = SchemaType<"TalkModeParams">; export type TalkCatalogParams = SchemaType<"TalkCatalogParams">; @@ -127,6 +154,8 @@ export type TalkSessionCloseParams = SchemaType<"TalkSessionCloseParams">; export type TalkSessionOkResult = SchemaType<"TalkSessionOkResult">; export type TalkSpeakParams = SchemaType<"TalkSpeakParams">; export type TalkSpeakResult = SchemaType<"TalkSpeakResult">; + +/** Channel control and web-login payloads. */ export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">; export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">; export type ChannelsStartParams = SchemaType<"ChannelsStartParams">; @@ -134,6 +163,8 @@ export type ChannelsStopParams = SchemaType<"ChannelsStopParams">; export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">; export type WebLoginStartParams = SchemaType<"WebLoginStartParams">; export type WebLoginWaitParams = SchemaType<"WebLoginWaitParams">; + +/** Agent config-file CRUD and artifact download/list payloads. */ export type AgentSummary = SchemaType<"AgentSummary">; export type AgentsFileEntry = SchemaType<"AgentsFileEntry">; export type AgentsCreateParams = SchemaType<"AgentsCreateParams">; @@ -155,6 +186,8 @@ export type ArtifactsGetParams = SchemaType<"ArtifactsGetParams">; export type ArtifactsGetResult = SchemaType<"ArtifactsGetResult">; export type ArtifactsDownloadParams = SchemaType<"ArtifactsDownloadParams">; export type ArtifactsDownloadResult = SchemaType<"ArtifactsDownloadResult">; + +/** Model, command, plugin UI action, tool catalog, and skill workshop payloads. */ export type AgentsListParams = SchemaType<"AgentsListParams">; export type AgentsListResult = SchemaType<"AgentsListResult">; export type ModelChoice = SchemaType<"ModelChoice">; @@ -207,6 +240,8 @@ export type SkillsUploadChunkParams = SchemaType<"SkillsUploadChunkParams">; export type SkillsUploadCommitParams = SchemaType<"SkillsUploadCommitParams">; export type SkillsInstallParams = SchemaType<"SkillsInstallParams">; export type SkillsUpdateParams = SchemaType<"SkillsUpdateParams">; + +/** Cron scheduler and run-log payloads. */ export type CronJob = SchemaType<"CronJob">; export type CronListParams = SchemaType<"CronListParams">; export type CronStatusParams = SchemaType<"CronStatusParams">; @@ -217,6 +252,8 @@ export type CronRemoveParams = SchemaType<"CronRemoveParams">; export type CronRunParams = SchemaType<"CronRunParams">; export type CronRunsParams = SchemaType<"CronRunsParams">; export type CronRunLogEntry = SchemaType<"CronRunLogEntry">; + +/** Logs and approval payloads for chat, exec commands, plugins, and devices. */ export type LogsTailParams = SchemaType<"LogsTailParams">; export type LogsTailResult = SchemaType<"LogsTailResult">; export type ExecApprovalsGetParams = SchemaType<"ExecApprovalsGetParams">; @@ -238,6 +275,8 @@ export type DeviceTokenRevokeParams = SchemaType<"DeviceTokenRevokeParams">; export type ChatAbortParams = SchemaType<"ChatAbortParams">; export type ChatInjectParams = SchemaType<"ChatInjectParams">; export type ChatEvent = SchemaType<"ChatEvent">; + +/** Gateway update and process lifecycle event payloads. */ export type UpdateRunParams = SchemaType<"UpdateRunParams">; export type TickEvent = SchemaType<"TickEvent">; export type ShutdownEvent = SchemaType<"ShutdownEvent">; diff --git a/packages/gateway-protocol/src/schema/wizard.ts b/packages/gateway-protocol/src/schema/wizard.ts index 50b72c51f5b9..8b82b61faacc 100644 --- a/packages/gateway-protocol/src/schema/wizard.ts +++ b/packages/gateway-protocol/src/schema/wizard.ts @@ -1,6 +1,7 @@ import { Type } from "typebox"; import { NonEmptyString } from "./primitives.js"; +/** Runtime state reported for gateway-driven setup wizard sessions. */ const WizardRunStatusSchema = Type.Union([ Type.Literal("running"), Type.Literal("done"), @@ -8,6 +9,7 @@ const WizardRunStatusSchema = Type.Union([ Type.Literal("error"), ]); +/** Starts a setup wizard, optionally scoped to a local or remote workspace. */ export const WizardStartParamsSchema = Type.Object( { mode: Type.Optional(Type.Union([Type.Literal("local"), Type.Literal("remote")])), @@ -16,6 +18,7 @@ export const WizardStartParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Client answer payload for the current wizard step. */ export const WizardAnswerSchema = Type.Object( { stepId: NonEmptyString, @@ -24,6 +27,7 @@ export const WizardAnswerSchema = Type.Object( { additionalProperties: false }, ); +/** Advances a wizard session, with an answer when the previous step requested input. */ export const WizardNextParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -32,6 +36,7 @@ export const WizardNextParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Shared session-id-only params for cancel and status requests. */ const WizardSessionIdParamsSchema = Type.Object( { sessionId: NonEmptyString, @@ -39,10 +44,13 @@ const WizardSessionIdParamsSchema = Type.Object( { additionalProperties: false }, ); +/** Cancels an active wizard session. */ export const WizardCancelParamsSchema = WizardSessionIdParamsSchema; +/** Reads status for an active or recently completed wizard session. */ export const WizardStatusParamsSchema = WizardSessionIdParamsSchema; +/** Selectable value shown in a choice-based wizard step. */ export const WizardStepOptionSchema = Type.Object( { value: Type.Unknown(), @@ -52,6 +60,7 @@ export const WizardStepOptionSchema = Type.Object( { additionalProperties: false }, ); +/** UI contract for one wizard step rendered by gateway clients. */ export const WizardStepSchema = Type.Object( { id: NonEmptyString, @@ -76,6 +85,7 @@ export const WizardStepSchema = Type.Object( { additionalProperties: false }, ); +/** Common response fields for start and next calls. */ const WizardResultFields = { done: Type.Boolean(), step: Type.Optional(WizardStepSchema), @@ -83,10 +93,12 @@ const WizardResultFields = { error: Type.Optional(Type.String()), }; +/** Result after advancing a wizard session. */ export const WizardNextResultSchema = Type.Object(WizardResultFields, { additionalProperties: false, }); +/** Result returned when a wizard session is created. */ export const WizardStartResultSchema = Type.Object( { sessionId: NonEmptyString, @@ -95,6 +107,7 @@ export const WizardStartResultSchema = Type.Object( { additionalProperties: false }, ); +/** Minimal status poll result used when the client does not need the next step. */ export const WizardStatusResultSchema = Type.Object( { status: WizardRunStatusSchema, diff --git a/packages/gateway-protocol/src/talk-config.contract.test.ts b/packages/gateway-protocol/src/talk-config.contract.test.ts index 123e70cb26f3..ea4551a8122e 100644 --- a/packages/gateway-protocol/src/talk-config.contract.test.ts +++ b/packages/gateway-protocol/src/talk-config.contract.test.ts @@ -3,6 +3,13 @@ import { describe, expect, it } from "vitest"; import { buildTalkConfigResponse } from "../../../src/config/talk.js"; import { validateTalkConfigResult } from "./index.js"; +/** + * Talk config contract tests shared between config normalization and gateway + * protocol validation. Fixtures capture provider selection and timeout behavior + * so config changes cannot silently diverge from the public RPC response shape. + */ + +/** Expected resolved provider/config selection for one fixture case. */ type ExpectedSelection = { provider: string; normalizedPayload: boolean; @@ -10,6 +17,7 @@ type ExpectedSelection = { apiKey?: string; }; +/** Fixture row that validates normalized Talk provider selection. */ type SelectionContractCase = { id: string; defaultProvider: string; @@ -18,6 +26,7 @@ type SelectionContractCase = { talk: Record; }; +/** Fixture row that validates Talk silence-timeout normalization. */ type TimeoutContractCase = { id: string; fallback: number; @@ -25,11 +34,13 @@ type TimeoutContractCase = { talk: Record; }; +/** JSON fixture file shape used by this contract test. */ type TalkConfigContractFixture = { selectionCases: SelectionContractCase[]; timeoutCases: TimeoutContractCase[]; }; +/** External fixture keeps the matrix readable and reusable across config edits. */ const fixturePath = new URL("../../../test/fixtures/talk-config-contract.json", import.meta.url); const fixtures = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as TalkConfigContractFixture; diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts index e618aaf4a42c..191aa86d9ff0 100644 --- a/packages/llm-core/src/index.ts +++ b/packages/llm-core/src/index.ts @@ -1,3 +1,4 @@ +/** Public LLM core contracts shared by providers, plugin SDK wrappers, and tests. */ export * from "./types.js"; export * from "./utils/diagnostics.js"; export * from "./utils/event-stream.js"; diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts index 93f2b01bf644..40879fe200a9 100644 --- a/packages/llm-core/src/types.ts +++ b/packages/llm-core/src/types.ts @@ -1,6 +1,7 @@ export type { AssistantMessageDiagnostic, DiagnosticErrorInfo } from "./utils/diagnostics.js"; import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js"; +/** Provider API families with first-class request/stream adapters in OpenClaw. */ export type KnownApi = | "openai-completions" | "mistral-conversations" @@ -12,20 +13,29 @@ export type KnownApi = | "google-generative-ai" | "google-vertex"; +/** Provider API id; custom providers can use ids outside the built-in set. */ export type Api = KnownApi | (string & {}); +/** Image-generation API families with first-class adapters in OpenClaw. */ export type KnownImagesApi = "openrouter-images"; +/** Image API id; custom image providers can use ids outside the built-in set. */ export type ImagesApi = KnownImagesApi | (string & {}); +/** Provider id used for routing, diagnostics, and config lookups. */ export type Provider = string; +/** Image provider ids with first-class adapters in OpenClaw. */ export type KnownImagesProvider = "openrouter"; +/** Image provider id used for routing, diagnostics, and config lookups. */ export type ImagesProvider = string; +/** Normalized reasoning-effort levels shared across provider-specific knobs. */ export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; +/** Model thinking setting including explicit disabled state. */ export type ModelThinkingLevel = "off" | ThinkingLevel; +/** Provider-specific values for normalized thinking levels. */ export type ThinkingLevelMap = Partial>; /** Token budgets for each thinking level (token-based providers only) */ @@ -37,18 +47,22 @@ export interface ThinkingBudgets { max?: number; } -// Base options all providers share +/** Prompt-cache retention preference shared by providers that expose cache controls. */ export type CacheRetention = "none" | "short" | "long"; +/** Streaming transport preference for providers that support multiple transports. */ export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; +/** Helper for hooks that may be synchronous or asynchronous. */ export type MaybePromise = T | Promise; +/** Minimal HTTP response metadata surfaced through provider hooks. */ export interface ProviderResponse { status: number; headers: Record; } +/** Request options shared by text streaming providers. */ export interface StreamOptions { temperature?: number; maxTokens?: number; @@ -125,6 +139,7 @@ export interface StreamOptions { export type ProviderStreamOptions = StreamOptions & Record; +/** Request options shared by image-generation providers. */ export interface ImagesOptions { signal?: AbortSignal; apiKey?: string; @@ -167,7 +182,7 @@ export interface ImagesOptions { export type ProviderImagesOptions = ImagesOptions & Record; -// Unified options with reasoning passed to streamSimple() and completeSimple() +/** Unified text options used by simple completion helpers. */ export interface SimpleStreamOptions extends StreamOptions { reasoning?: ThinkingLevel; /** Custom token budgets for thinking levels (token-based providers only) */ @@ -206,12 +221,14 @@ export interface TextSignatureV1 { phase?: "commentary" | "final_answer"; } +/** Plain assistant/user text content block. */ export interface TextContent { type: "text"; text: string; textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON) } +/** Provider reasoning/thinking content block, including opaque replay signatures. */ export interface ThinkingContent { type: "thinking"; thinking: string; @@ -222,12 +239,14 @@ export interface ThinkingContent { redacted?: boolean; } +/** Base64 image content block with MIME type metadata. */ export interface ImageContent { type: "image"; data: string; // base64 encoded image data mimeType: string; // e.g., "image/jpeg", "image/png" } +/** Normalized assistant tool call emitted by providers or repaired from text. */ export interface ToolCall { type: "toolCall"; id: string; @@ -237,6 +256,7 @@ export interface ToolCall { executionMode?: "sequential" | "parallel"; } +/** Normalized token and cost accounting for a provider response. */ export interface Usage { input: number; output: number; @@ -252,14 +272,17 @@ export interface Usage { }; } +/** Normalized assistant stop reasons across text providers. */ export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; +/** User turn in a text-model conversation. */ export interface UserMessage { role: "user"; content: string | (TextContent | ImageContent)[]; timestamp: number; // Unix timestamp in milliseconds } +/** Assistant turn, including provider identity and final stop state. */ export interface AssistantMessage { role: "assistant"; content: (TextContent | ThinkingContent | ToolCall)[]; @@ -275,6 +298,7 @@ export interface AssistantMessage { timestamp: number; // Unix timestamp in milliseconds } +/** Tool result turn that answers a prior assistant tool call. */ export interface ToolResultMessage { role: "toolResult"; toolCallId: string; @@ -285,17 +309,23 @@ export interface ToolResultMessage { timestamp: number; // Unix timestamp in milliseconds } +/** Any text-model conversation message supported by LLM core. */ export type Message = UserMessage | AssistantMessage | ToolResultMessage; +/** Image request input content accepted by image providers. */ export type ImagesInputContent = TextContent | ImageContent; +/** Image response output content returned by image providers. */ export type ImagesOutputContent = TextContent | ImageContent; +/** Image-generation request context. */ export interface ImagesContext { input: ImagesInputContent[]; } +/** Normalized image-generation stop reasons. */ export type ImagesStopReason = "stop" | "error" | "aborted"; +/** Final image-generation response shape. */ export interface AssistantImages { api: ImagesApi; provider: ImagesProvider; @@ -310,12 +340,14 @@ export interface AssistantImages { import type { TSchema } from "typebox"; +/** Provider tool declaration with a TypeBox/JSON-schema parameter object. */ export interface Tool { name: string; description: string; parameters: TParameters; } +/** Text-model request context shared by provider adapters. */ export interface Context { systemPrompt?: string; messages: Message[]; @@ -349,11 +381,15 @@ export type AssistantMessageEvent = | { type: "error"; reason: Extract; error: AssistantMessage }; export interface AssistantMessageEventStreamContract extends AsyncIterable { + /** Queue one stream event for consumers. */ push(event: AssistantMessageEvent): void; + /** Complete the stream and optionally resolve the final message. */ end(result?: AssistantMessage): void; + /** Final assistant message produced by the stream. */ result(): Promise; } +/** Read-only stream contract accepted by consumers that do not need to push events. */ export interface AssistantMessageEventStreamLike extends AsyncIterable { result(): Promise; } diff --git a/packages/llm-runtime/src/api-registry.ts b/packages/llm-runtime/src/api-registry.ts index b6783f1708ad..9b386ee79425 100644 --- a/packages/llm-runtime/src/api-registry.ts +++ b/packages/llm-runtime/src/api-registry.ts @@ -27,8 +27,11 @@ export interface ApiProvider< TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions, > { + /** Model API id this provider handles. */ api: TApi; + /** Full streaming adapter for callers that already own structured options. */ stream: StreamFunction; + /** Simple streaming adapter used by agent and plugin runtime defaults. */ streamSimple: StreamFunction; } @@ -72,6 +75,7 @@ function wrapStreamSimple( /** Registers or replaces the provider implementation for an API id. */ export function registerApiProvider( provider: ApiProvider, + /** Optional source id used to unregister all providers owned by one plugin/runtime. */ sourceId?: string, ): void { apiProviderRegistry.set(provider.api, { diff --git a/packages/markdown-core/src/chunk-text.ts b/packages/markdown-core/src/chunk-text.ts index 71bfa94a3ca9..612f4b916a43 100644 --- a/packages/markdown-core/src/chunk-text.ts +++ b/packages/markdown-core/src/chunk-text.ts @@ -18,6 +18,8 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi for (let i = 0; i < text.length; i++) { const char = text[i]; + // Parenthesized spans often contain rewritten links or file references; + // avoid splitting them unless the window has no safer outside break. if (char === "(") { depth += 1; continue; @@ -39,7 +41,11 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi return { lastNewline, lastWhitespace }; } -/** Splits plain text at readable boundaries while avoiding breaks inside parentheses. */ +/** + * Splits plain text into size-bounded chunks at readable boundaries. + * + * Returns the original text as one chunk when the limit is non-positive. + */ export function chunkText(text: string, limit: number): string[] { const early = resolveChunkEarlyReturn(text, limit); if (early) { @@ -56,6 +62,8 @@ export function chunkText(text: string, limit: number): string[] { const windowEnd = Math.min(text.length, cursor + limit); const window = text.slice(cursor, windowEnd); const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window); + // Prefer block boundaries, then spaces, then a hard size cut when no + // readable breakpoint exists inside this window. const breakOffset = lastNewline > 0 ? lastNewline : lastWhitespace; const end = breakOffset > 0 ? cursor + breakOffset : windowEnd; chunks.push(text.slice(cursor, end)); diff --git a/packages/markdown-core/src/code-spans.ts b/packages/markdown-core/src/code-spans.ts index 1f2f14b48c96..9af8102d96d3 100644 --- a/packages/markdown-core/src/code-spans.ts +++ b/packages/markdown-core/src/code-spans.ts @@ -1,7 +1,10 @@ import { scanFenceSpans, type FenceScanState, type FenceSpan } from "./fences.js"; +/** Incremental inline-code scanner state carried across chunk boundaries. */ export type InlineCodeState = { + /** Whether the current scan is inside an unterminated inline code span. */ open: boolean; + /** Backtick run length required to close the current inline code span. */ ticks: number; }; @@ -16,8 +19,11 @@ type InlineCodeSpansResult = { }; type CodeSpanIndex = { + /** Inline-code state to carry into the next streamed chunk. */ inlineState: InlineCodeState; + /** Fenced-code state to carry into the next streamed chunk. */ fenceState: FenceScanState; + /** True when an offset is inside fenced code or inline code. */ isInside: (index: number) => boolean; }; diff --git a/packages/markdown-core/src/index.ts b/packages/markdown-core/src/index.ts index db0af7c67680..e5fbf9ac3533 100644 --- a/packages/markdown-core/src/index.ts +++ b/packages/markdown-core/src/index.ts @@ -1,3 +1,4 @@ +/** Public Markdown parsing, rendering, chunking, and table-conversion utilities. */ export * from "./code-spans.js"; export * from "./fences.js"; export * from "./frontmatter.js"; diff --git a/packages/markdown-core/src/render-aware-chunking.ts b/packages/markdown-core/src/render-aware-chunking.ts index 2fad6e2a3f83..c4b9b5cf7bca 100644 --- a/packages/markdown-core/src/render-aware-chunking.ts +++ b/packages/markdown-core/src/render-aware-chunking.ts @@ -8,15 +8,21 @@ import { /** A rendered chunk paired with the Markdown IR slice that produced it. */ export type RenderedMarkdownChunk = { + /** Rendered payload for this chunk after caller-specific escaping/link rewriting. */ rendered: TRendered; + /** Source IR slice used to produce the rendered payload. */ source: MarkdownIR; }; /** Inputs for chunking Markdown IR against the final rendered payload size. */ export type RenderMarkdownIRChunksWithinLimitOptions = { + /** Parsed Markdown IR to split. */ ir: MarkdownIR; + /** Maximum measured size for each rendered chunk. */ limit: number; + /** Returns the size unit enforced by the target transport. */ measureRendered: (rendered: TRendered) => number; + /** Renders a candidate IR slice for measuring and final output. */ renderChunk: (ir: MarkdownIR) => TRendered; }; @@ -139,6 +145,8 @@ function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: n for (let index = start; index < maxEnd; index += 1) { const char = text[index]; + // Parenthesized text often carries rewritten file/link references; prefer + // keeping it intact unless no outside break exists in the current window. if (char === "(") { sawNonWhitespace = true; parenDepth += 1; @@ -157,6 +165,7 @@ function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: n continue; } if (char === "\n") { + // Newlines preserve markdown block structure better than other spaces. lastAnyNewlineBreak = index + 1; if (parenDepth === 0) { lastOutsideParenNewlineBreak = index + 1; diff --git a/packages/markdown-core/src/render.ts b/packages/markdown-core/src/render.ts index 2db4385bbb7c..a3bb9ba9465c 100644 --- a/packages/markdown-core/src/render.ts +++ b/packages/markdown-core/src/render.ts @@ -1,12 +1,15 @@ import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js"; +/** Marker pair used to wrap a styled Markdown span in the target renderer. */ export type RenderStyleMarker = { open: string | ((span: MarkdownStyleSpan) => string); close: string; }; +/** Optional marker map; omitted styles are emitted as plain escaped text. */ export type RenderStyleMap = Partial>; +/** Link wrapper boundaries after a renderer has accepted or rewritten a link span. */ export type RenderLink = { start: number; end: number; @@ -14,6 +17,7 @@ export type RenderLink = { close: string; }; +/** Renderer hooks for converting Markdown IR into a marker-based target format. */ export type RenderOptions = { styleMarkers: RenderStyleMap; escapeText: (text: string) => string; @@ -46,6 +50,7 @@ function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] { }); } +/** Renders Markdown IR by nesting configured style markers and optional link markers. */ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string { const text = ir.text ?? ""; if (!text) { @@ -104,7 +109,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const points = [...boundaries].toSorted((a, b) => a - b); - // Unified stack for both styles and links, tracking close string and end position + // Links and styles share one stack so equal-end spans close in exact reverse open order. const stack: { close: string; end: number }[] = []; type OpeningItem = | { end: number; open: string; close: string; kind: "link"; index: number } @@ -121,7 +126,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions for (let i = 0; i < points.length; i += 1) { const pos = points[i]; - // Close ALL elements (styles and links) in LIFO order at this position + // Close all elements at this boundary before opening replacements at the same offset. while (stack.length && stack[stack.length - 1]?.end === pos) { const item = stack.pop(); if (item) { diff --git a/packages/markdown-core/src/types.ts b/packages/markdown-core/src/types.ts index bc789010d9b8..8c3be3633629 100644 --- a/packages/markdown-core/src/types.ts +++ b/packages/markdown-core/src/types.ts @@ -1 +1,2 @@ +/** Table rendering modes used when markdown tables need plaintext-safe output. */ export type MarkdownTableMode = "off" | "bullets" | "code" | "block"; diff --git a/packages/media-core/src/base64.ts b/packages/media-core/src/base64.ts index a42c8075a318..2c8ee622637f 100644 --- a/packages/media-core/src/base64.ts +++ b/packages/media-core/src/base64.ts @@ -1,3 +1,4 @@ +/** Estimates decoded bytes without allocating a cleaned copy of the base64 payload. */ export function estimateBase64DecodedBytes(base64: string): number { // Avoid `trim()`/`replace()` here: they allocate a second (potentially huge) string. // We only need a conservative decoded-size estimate to enforce budgets before Buffer.from(..., "base64"). @@ -47,8 +48,8 @@ function isBase64DataChar(code: number): boolean { } /** - * Normalize and validate a base64 string. - * Returns canonical base64 (no whitespace) or undefined when invalid. + * Normalizes and validates a base64 string, returning canonical no-whitespace + * base64 only when the input has valid alphabet, padding, and length. */ export function canonicalizeBase64(base64: string): string | undefined { let cleaned = ""; diff --git a/packages/media-core/src/constants.ts b/packages/media-core/src/constants.ts index d87dafebc3c9..ff508f8b77d3 100644 --- a/packages/media-core/src/constants.ts +++ b/packages/media-core/src/constants.ts @@ -1,10 +1,16 @@ +/** Default outbound image payload cap shared by media loaders and adapters. */ export const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6MB +/** Default outbound audio payload cap shared by media loaders and adapters. */ export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB +/** Default outbound video payload cap shared by media loaders and adapters. */ export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB +/** Default outbound document payload cap shared by media loaders and adapters. */ export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB +/** Media families that share size-policy and MIME-classification behavior. */ export type MediaKind = "image" | "audio" | "video" | "document"; +/** Maps a MIME type to the media family used for size limits and routing. */ export function mediaKindFromMime(mime?: string | null): MediaKind | undefined { if (!mime) { return undefined; @@ -30,6 +36,7 @@ export function mediaKindFromMime(mime?: string | null): MediaKind | undefined { return undefined; } +/** Returns the default byte cap for a classified media family. */ export function maxBytesForKind(kind: MediaKind): number { switch (kind) { case "image": diff --git a/packages/media-core/src/content-length.ts b/packages/media-core/src/content-length.ts index 75aee95973ae..98b21a5d34b7 100644 --- a/packages/media-core/src/content-length.ts +++ b/packages/media-core/src/content-length.ts @@ -1,3 +1,4 @@ +/** Parses a Content-Length header as a safe integer or rejects malformed values. */ export function parseMediaContentLength(raw: string | null): number | null { if (raw === null) { return null; diff --git a/packages/media-core/src/file-name.ts b/packages/media-core/src/file-name.ts index 0b0360f6e682..afc083f96290 100644 --- a/packages/media-core/src/file-name.ts +++ b/packages/media-core/src/file-name.ts @@ -1,13 +1,16 @@ import path from "node:path"; +/** Returns the final filename segment for either POSIX or Windows-style paths. */ export function basenameFromAnyPath(value: string): string { return path.win32.basename(path.posix.basename(value)); } +/** Returns the extension from the final filename segment of any path flavor. */ export function extnameFromAnyPath(value: string): string { return path.extname(basenameFromAnyPath(value)); } +/** Returns the extensionless filename from the final segment of any path flavor. */ export function nameFromAnyPath(value: string): string { const base = basenameFromAnyPath(value); const ext = path.extname(base); diff --git a/packages/media-core/src/inbound-path-policy.ts b/packages/media-core/src/inbound-path-policy.ts index 33d9bb2e6050..c229eecdf57c 100644 --- a/packages/media-core/src/inbound-path-policy.ts +++ b/packages/media-core/src/inbound-path-policy.ts @@ -9,6 +9,8 @@ function normalizePosixAbsolutePath(value: string): string | undefined { if (!trimmed || trimmed.includes("\0")) { return undefined; } + // Compare all roots as POSIX-style absolute paths so channel configs can use + // stable patterns even when a source reports Windows separators. const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")); const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized); if (!isAbsolute || normalized === "/") { @@ -44,6 +46,7 @@ function matchesRootPattern(params: { candidatePath: string; rootPattern: string return true; } +/** Validates an absolute inbound root pattern with whole-segment wildcards only. */ export function isValidInboundPathRootPattern(value: string): boolean { const normalized = normalizePosixAbsolutePath(value); if (!normalized) { @@ -56,6 +59,7 @@ export function isValidInboundPathRootPattern(value: string): boolean { return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*")); } +/** Normalizes configured inbound attachment roots, dropping invalid or duplicate patterns. */ export function normalizeInboundPathRoots(roots?: readonly string[]): string[] { const normalized: string[] = []; const seen = new Set(); @@ -76,6 +80,7 @@ export function normalizeInboundPathRoots(roots?: readonly string[]): string[] { return normalized; } +/** Merges inbound attachment root lists while preserving first-seen priority. */ export function mergeInboundPathRoots( ...rootsLists: Array ): string[] { @@ -94,6 +99,7 @@ export function mergeInboundPathRoots( return merged; } +/** Checks whether a candidate inbound media path is covered by configured or fallback roots. */ export function isInboundPathAllowed(params: { filePath: string; roots: readonly string[]; diff --git a/packages/media-core/src/inline-image-data-url.ts b/packages/media-core/src/inline-image-data-url.ts index fb3f686fb2af..6fb23692f9a4 100644 --- a/packages/media-core/src/inline-image-data-url.ts +++ b/packages/media-core/src/inline-image-data-url.ts @@ -1,5 +1,6 @@ import { canonicalizeBase64 } from "./base64.js"; +/** Prefix used to distinguish inline data URLs from remote/local image references. */ export const INLINE_IMAGE_DATA_URL_PREFIX = "data:"; const IMAGE_SIGNATURES: Array<{ @@ -47,6 +48,7 @@ function startsWithDataUrl(value: string): boolean { ); } +/** Sniffs supported inline image formats from decoded bytes. */ export function sniffInlineImageMime(buffer: Buffer): string | undefined { return IMAGE_SIGNATURES.find((signature) => signature.matches(buffer))?.mime; } @@ -79,6 +81,7 @@ function metadataAllowsImageBase64(metadata: string[]): boolean { return isImageMimeType && options.some((part) => part.toLowerCase() === "base64"); } +/** Canonicalizes trusted inline image data URLs and rejects malformed or non-image payloads. */ export function sanitizeInlineImageDataUrl(imageUrl: string): string | undefined { const parsed = parseInlineImageDataUrl(imageUrl); if (!parsed) { @@ -99,5 +102,6 @@ export function sanitizeInlineImageDataUrl(imageUrl: string): string | undefined if (!sniffedMimeType) { return undefined; } + // Trust the byte signature over caller-supplied metadata before reinlining. return `data:${sniffedMimeType};base64,${canonicalPayload}`; } diff --git a/packages/media-core/src/lazy-import.ts b/packages/media-core/src/lazy-import.ts index 558e2cdece33..eaa2283f1f48 100644 --- a/packages/media-core/src/lazy-import.ts +++ b/packages/media-core/src/lazy-import.ts @@ -1,12 +1,15 @@ +/** Cached async loader used by runtime boundaries that should import on first use. */ export type LazyPromiseLoader = { load(): Promise; clear(): void; }; +/** Controls whether a failed first import stays cached or is retried later. */ export type LazyPromiseLoaderOptions = { cacheRejections?: boolean; }; +/** Creates a single-flight promise cache around a lazy import or other async loader. */ export function createLazyImportLoader( load: () => Promise, options: LazyPromiseLoaderOptions = {}, @@ -16,6 +19,7 @@ export function createLazyImportLoader( const createPromise = (): Promise => { const loaded = Promise.resolve().then(load); if (options.cacheRejections !== true) { + // Failed optional-runtime imports should retry after install/config changes. void loaded.catch(() => { if (promise === loaded) { promise = undefined; diff --git a/packages/media-core/src/media-source-url.ts b/packages/media-core/src/media-source-url.ts index 3b2e3bbbf119..0c423f26b735 100644 --- a/packages/media-core/src/media-source-url.ts +++ b/packages/media-core/src/media-source-url.ts @@ -1,6 +1,7 @@ const HTTP_URL_RE = /^https?:\/\//i; const MXC_URL_RE = /^mxc:\/\//i; +/** Returns true for remote media URLs that should stay URL-backed instead of local-file-backed. */ export function isPassThroughRemoteMediaSource(value: string | null | undefined): boolean { const normalized = value?.trim() ?? ""; return Boolean(normalized) && (HTTP_URL_RE.test(normalized) || MXC_URL_RE.test(normalized)); diff --git a/packages/media-core/src/mime.ts b/packages/media-core/src/mime.ts index eb37c0230110..df55d1662ba5 100644 --- a/packages/media-core/src/mime.ts +++ b/packages/media-core/src/mime.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { type MediaKind, mediaKindFromMime } from "./constants.js"; import { createLazyImportLoader } from "./lazy-import.js"; -/** @internal */ +/** Maximum byte prefix passed to dependency MIME sniffers for bounded memory/CPU work. */ export const FILE_TYPE_SNIFF_MAX_BYTES = 1024 * 1024; // Map common mimes to preferred file extensions. @@ -97,6 +97,7 @@ const AUDIO_FILE_EXTENSIONS = new Set([ const fileTypeModuleLoader = createLazyImportLoader(() => import("file-type")); +/** Normalizes MIME strings by dropping parameters, lowercasing, and folding APNG to PNG. */ export function normalizeMimeType(mime?: string | null): string | undefined { if (!mime) { return undefined; @@ -108,7 +109,7 @@ export function normalizeMimeType(mime?: string | null): string | undefined { return cleaned || undefined; } -/** @internal */ +/** Returns the bounded buffer prefix used for dependency MIME sniffing. */ export function sliceMimeSniffBuffer(buffer: Buffer): Buffer { if (buffer.byteLength <= FILE_TYPE_SNIFF_MAX_BYTES) { return buffer; @@ -144,6 +145,7 @@ function sniffKnownAudioMagic(buffer: Buffer): string | undefined { return undefined; } +/** Extracts a lowercase extension from a local path or HTTP URL pathname. */ export function getFileExtension(filePath?: string | null): string | undefined { if (!filePath) { return undefined; @@ -160,6 +162,7 @@ export function getFileExtension(filePath?: string | null): string | undefined { return ext || undefined; } +/** Maps a file path or URL extension to the preferred MIME type when known. */ export function mimeTypeFromFilePath(filePath?: string | null): string | undefined { const ext = getFileExtension(filePath); if (!ext) { @@ -168,6 +171,7 @@ export function mimeTypeFromFilePath(filePath?: string | null): string | undefin return MIME_BY_EXT[ext]; } +/** Returns true when a filename extension is a supported audio container. */ export function isAudioFileName(fileName?: string | null): boolean { const ext = getFileExtension(fileName); if (!ext) { @@ -176,6 +180,7 @@ export function isAudioFileName(fileName?: string | null): boolean { return AUDIO_FILE_EXTENSIONS.has(ext); } +/** Detects the best MIME type from bytes, file path, and header metadata. */ export function detectMime(opts: { buffer?: Buffer; headerMime?: string | null; @@ -232,6 +237,7 @@ async function detectMimeImpl(opts: { return undefined; } +/** Returns the preferred file extension for a normalized or raw MIME string. */ export function extensionForMime(mime?: string | null): string | undefined { const normalized = normalizeMimeType(mime); if (!normalized) { @@ -240,6 +246,7 @@ export function extensionForMime(mime?: string | null): string | undefined { return EXT_BY_MIME[normalized]; } +/** Returns true when content type or filename identifies GIF media. */ export function isGifMedia(opts: { contentType?: string | null; fileName?: string | null; @@ -251,6 +258,7 @@ export function isGifMedia(opts: { return ext === ".gif"; } +/** Maps image format labels from encoders/probes to MIME types. */ export function imageMimeFromFormat(format?: string | null): string | undefined { if (!format) { return undefined; @@ -274,6 +282,7 @@ export function imageMimeFromFormat(format?: string | null): string | undefined } } +/** Normalizes a MIME string before classifying it into a media family. */ export function kindFromMime(mime?: string | null): MediaKind | undefined { return mediaKindFromMime(normalizeMimeType(mime)); } diff --git a/packages/media-core/src/read-byte-stream-with-limit.ts b/packages/media-core/src/read-byte-stream-with-limit.ts index 07e5d66d9892..16d683e2771b 100644 --- a/packages/media-core/src/read-byte-stream-with-limit.ts +++ b/packages/media-core/src/read-byte-stream-with-limit.ts @@ -1,8 +1,10 @@ +/** Details passed to byte-stream overflow error factories. */ export type ByteStreamLimitOverflow = { size: number; maxBytes: number; }; +/** Options for reading an async byte stream under a hard byte cap. */ export type ReadByteStreamWithLimitOptions = { maxBytes: number; onOverflow?: (params: ByteStreamLimitOverflow) => Error; @@ -29,6 +31,8 @@ function destroyReadableOnOverflow(stream: unknown, err: Error): void { destroy?: (error?: Error) => unknown; cancel?: (reason?: unknown) => unknown; }; + // Stop upstream producers immediately after overflow; otherwise large media + // streams can continue buffering after the caller has already failed. if (typeof readable.destroy === "function") { try { readable.destroy(err); @@ -42,6 +46,7 @@ function destroyReadableOnOverflow(stream: unknown, err: Error): void { } } +/** Reads and concatenates an async byte stream, throwing once the byte cap is exceeded. */ export async function readByteStreamWithLimit( stream: AsyncIterable, opts: ReadByteStreamWithLimitOptions, diff --git a/packages/media-core/src/read-response-with-limit.ts b/packages/media-core/src/read-response-with-limit.ts index 30b4a241a941..e26361b7cbaf 100644 --- a/packages/media-core/src/read-response-with-limit.ts +++ b/packages/media-core/src/read-response-with-limit.ts @@ -23,6 +23,8 @@ async function readChunkWithIdleTimeout( onIdleTimeout?.({ chunkTimeoutMs: resolvedChunkTimeoutMs }) ?? new Error(`Media download stalled: no data received for ${resolvedChunkTimeoutMs}ms`); clear(); + // Cancel the body with the same error so fetch-backed streams release + // sockets/buffers instead of idling after the caller times out. void reader.cancel(error).catch(() => undefined); reject(error); }, resolvedChunkTimeoutMs); @@ -123,6 +125,7 @@ async function readResponsePrefix( }; } +/** Reads a response body under a byte cap, cancelling the stream on overflow or idle timeout. */ export async function readResponseWithLimit( res: Response, maxBytes: number, @@ -146,6 +149,7 @@ export async function readResponseWithLimit( return prefix.buffer; } +/** Reads a small collapsed text prefix from a response body for diagnostics/errors. */ export async function readResponseTextSnippet( res: Response, opts?: { diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts index f591a344f756..65c9605257bc 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts @@ -1,3 +1,5 @@ +// Agent-facing runtime facade for memory host packages. +// Keep exports here limited to config/state helpers that memory plugins may reuse. export { DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR, asToolParamsRecord, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts index 2620d437a2c0..0c112a653bfe 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts @@ -1,3 +1,5 @@ +// Config-facing runtime facade for memory host packages. +// This keeps memory plugins off broader core config modules and their private helpers. export { getRuntimeConfig, hasConfiguredSecretInput, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts index 07da653734ae..e6861345adde 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts @@ -1,3 +1,5 @@ +// Memory-facing runtime facade for plugin registration, embeddings, and prompt artifacts. +// Re-export only stable host seams; plugin implementations should not import core internals. export { buildActiveMemoryPromptSection, emptyPluginConfigSchema, diff --git a/packages/memory-host-sdk/src/host/query-expansion.ts b/packages/memory-host-sdk/src/host/query-expansion.ts index 859612a1f6c3..fdf7a1ea18a5 100644 --- a/packages/memory-host-sdk/src/host/query-expansion.ts +++ b/packages/memory-host-sdk/src/host/query-expansion.ts @@ -632,6 +632,7 @@ const STOP_WORDS_ZH = new Set([ "告诉", ]); +/** Returns true for low-value conversational tokens that should not drive FTS matching. */ export function isQueryStopWordToken(token: string): boolean { return ( STOP_WORDS_EN.has(token) || diff --git a/packages/memory-host-sdk/src/query.ts b/packages/memory-host-sdk/src/query.ts index bb945afaa65f..4c38b866ebaa 100644 --- a/packages/memory-host-sdk/src/query.ts +++ b/packages/memory-host-sdk/src/query.ts @@ -1 +1,2 @@ +// Public query helper facade for memory search token expansion. export { extractKeywords, isQueryStopWordToken } from "./host/query-expansion.js"; diff --git a/packages/tool-call-repair/src/index.ts b/packages/tool-call-repair/src/index.ts index b8a1e860962b..190786fc403d 100644 --- a/packages/tool-call-repair/src/index.ts +++ b/packages/tool-call-repair/src/index.ts @@ -1,3 +1,4 @@ +/** Public repair utilities for model-emitted plain-text tool calls. */ export { parseStandalonePlainTextToolCallBlocks, stripPlainTextToolCallBlocks, diff --git a/packages/tool-call-repair/src/payload.ts b/packages/tool-call-repair/src/payload.ts index 910f1da9936c..8378fe22c90c 100644 --- a/packages/tool-call-repair/src/payload.ts +++ b/packages/tool-call-repair/src/payload.ts @@ -10,16 +10,25 @@ import { skipWhitespace, } from "./grammar.js"; +/** Parsed standalone plain-text tool call block with source offsets for repair. */ export type PlainTextToolCallBlock = { + /** Parsed JSON arguments object. */ arguments: Record; + /** Exclusive end offset of the parsed block. */ end: number; + /** Tool name parsed from bracket, Harmony, or XML-ish syntax. */ name: string; + /** Original text slice that produced this block. */ raw: string; + /** Inclusive start offset of the parsed block. */ start: number; }; +/** Parser limits and allowlist options for plain-text tool-call repair. */ export type PlainTextToolCallParseOptions = { + /** Optional allowlist of tool names that may be repaired. */ allowedToolNames?: Iterable; + /** Maximum JSON payload size accepted for one repaired call. */ maxPayloadBytes?: number; }; @@ -389,6 +398,7 @@ export function parseStandalonePlainTextToolCallBlocks( return blocks.length > 0 ? blocks : null; } +/** Removes full-line standalone plain-text tool-call blocks from user-visible text. */ export function stripPlainTextToolCallBlocks(text: string): string { if ( !text || diff --git a/packages/tool-call-repair/src/stream-normalizer.ts b/packages/tool-call-repair/src/stream-normalizer.ts index ea94bc80b3db..e7b77a419077 100644 --- a/packages/tool-call-repair/src/stream-normalizer.ts +++ b/packages/tool-call-repair/src/stream-normalizer.ts @@ -11,26 +11,36 @@ import { } from "./grammar.js"; export type PlainTextToolCallNameMatcher = { + /** True only when the candidate is a complete tool name this request may repair. */ hasExactName(name: string): boolean; + /** True while streamed bytes still match at least one repairable tool name prefix. */ hasNamePrefix(prefix: string): boolean; }; +/** Result of repairing the final message carried by a provider stream `done` event. */ export type PlainTextToolCallMessageNormalization = | { kind: "promoted" | "scrubbed"; message: Record } | undefined; +/** Stream-level hooks used to promote leaked text tool calls into provider events. */ export type PlainTextToolCallStreamNormalizerOptions = { + /** Expands a promoted final message into provider-native tool-call stream events. */ createPromotedToolCallEvents(message: Record): Iterable; + /** Tool-name matcher scoped to the exact request being normalized. */ matcher: PlainTextToolCallNameMatcher; + /** Repairs or scrubs the final done-message snapshot after text buffering completes. */ normalizeDoneMessage(params: { message: unknown; reason: unknown; }): PlainTextToolCallMessageNormalization; + /** Stop after the first normalized done event when the wrapped provider has completed. */ stopAfterDone?: boolean; }; const TEXT_TOOL_CALL_BUFFER_MAX_CHARS = 256_000; +// Keep a bounded prefix plus enough tail to notice closing markers after the cap; +// otherwise a huge leaked payload could either grow unbounded or lose the visible suffix. const TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS = TEXT_TOOL_CALL_BUFFER_MAX_CHARS + 64_000; const TEXT_TOOL_CALL_SUPPRESSED_TAIL_CHARS = TEXT_TOOL_CALL_SUPPRESSED_SCAN_MAX_CHARS - TEXT_TOOL_CALL_BUFFER_MAX_CHARS; @@ -941,6 +951,7 @@ function scrubReclassifiedMixedTextFromError( }; } +/** Scrubs final messages whose streamed plain-text tool-call prefix exceeded the buffer cap. */ export function scrubOverCapPlainTextToolCallMessage(params: { candidateText: string | undefined; matcher: PlainTextToolCallNameMatcher; @@ -1039,6 +1050,7 @@ function isBufferedTextEvent(bufferedEvent: unknown): boolean { ); } +/** Buffers provider stream text long enough to promote or hide leaked plain-text tool calls. */ export async function* normalizePlainTextToolCallStreamEvents( source: AsyncIterable, options: PlainTextToolCallStreamNormalizerOptions, @@ -1121,6 +1133,8 @@ export async function* normalizePlainTextToolCallStreamEvents( continue; } if (suppressingOverCapTextToolCall) { + // Once the tentative tool call exceeds the cap, suppress text deltas until a closing + // marker proves whether the buffered prefix was a hidden call or mixed visible text. if (hasSuppressedTextContentIndex && record.contentIndex !== suppressedTextContentIndex) { if (isAllowedTextToolCallLikeEvent(record, options.matcher)) { continue; @@ -1171,6 +1185,8 @@ export async function* normalizePlainTextToolCallStreamEvents( ? stripSerializedToolCallPrefixes(bufferedText.trimStart(), options.matcher) : null; if (visibleText?.trim()) { + // A tool-call prefix followed by visible text must be reclassified: emit only the + // suffix now, then scrub the final done/error snapshots to match that stream history. yield* flushScrubbedBufferedNonTextEvents(true); reclassifiedMixedTextContentIndex = record.contentIndex; hasReclassifiedMixedTextContentIndex = true; diff --git a/src/acp/client-helpers.ts b/src/acp/client-helpers.ts index a48bb29dee51..563e68b6ab02 100644 --- a/src/acp/client-helpers.ts +++ b/src/acp/client-helpers.ts @@ -17,6 +17,7 @@ import { classifyAcpToolApproval, type AcpApprovalClass } from "./approval-class type PermissionOption = RequestPermissionRequest["options"][number]; +// ACP permission resolution keeps readonly tool classes noninteractive and prompts for risky tools. type PermissionResolverDeps = { prompt?: (toolName: string | undefined, toolTitle?: string) => Promise; log?: (line: string) => void; @@ -153,6 +154,7 @@ type AcpClientSpawnEnvOptions = { stripKeys?: Iterable; }; +/** Builds the sanitized environment used when spawning an ACP client process. */ export function resolveAcpClientSpawnEnv( baseEnv: NodeJS.ProcessEnv = process.env, options: AcpClientSpawnEnvOptions = {}, diff --git a/src/acp/control-plane/manager.backend-failover.ts b/src/acp/control-plane/manager.backend-failover.ts index ce8f2f166089..f0504ee6abce 100644 --- a/src/acp/control-plane/manager.backend-failover.ts +++ b/src/acp/control-plane/manager.backend-failover.ts @@ -1,6 +1,7 @@ import type { AcpRuntimeErrorCode } from "../runtime/errors.js"; import { normalizeText } from "./runtime-options.js"; +/** Captured backend attempt state used to decide whether failover is safe. */ export type BackendAttempt = { backend: string; error: string; diff --git a/src/acp/control-plane/manager.background-task.ts b/src/acp/control-plane/manager.background-task.ts index 0a4f35b71bb3..472ff8abe407 100644 --- a/src/acp/control-plane/manager.background-task.ts +++ b/src/acp/control-plane/manager.background-task.ts @@ -15,6 +15,7 @@ import { normalizeText } from "./runtime-options.js"; const ACP_BACKGROUND_TASK_TEXT_MAX_LENGTH = 160; const ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH = 240; +/** Context needed to mirror a child ACP turn into the requester task registry. */ export type BackgroundTaskContext = { requesterSessionKey: string; requesterOrigin?: DeliveryContext; diff --git a/src/acp/control-plane/manager.cancel-session.ts b/src/acp/control-plane/manager.cancel-session.ts index f4aeaccad8c9..d11b08522823 100644 --- a/src/acp/control-plane/manager.cancel-session.ts +++ b/src/acp/control-plane/manager.cancel-session.ts @@ -14,6 +14,7 @@ import type { } from "./manager.types.js"; import { normalizeActorKey, requireReadySessionMeta } from "./manager.utils.js"; +/** Cancels either the active ACP turn or the idle runtime handle for a session. */ export async function runManagerCancelSession(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 78be975b1c41..2a229ec54334 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -59,6 +59,7 @@ import { } from "./runtime-options.js"; import { SessionActorQueue } from "./session-actor-queue.js"; +/** Coordinates ACP session metadata, runtime handles, per-session queues, and turn execution. */ export class AcpSessionManager { private readonly actorQueue = new SessionActorQueue(); private readonly runtimeHandles = new ManagerRuntimeHandleCache(); diff --git a/src/acp/control-plane/manager.identity-reconcile.ts b/src/acp/control-plane/manager.identity-reconcile.ts index d8144d333a87..2ef3f1843181 100644 --- a/src/acp/control-plane/manager.identity-reconcile.ts +++ b/src/acp/control-plane/manager.identity-reconcile.ts @@ -17,6 +17,7 @@ import { withAcpRuntimeErrorBoundary } from "../runtime/errors.js"; import type { SessionAcpMeta, SessionEntry } from "./manager.types.js"; import { hasLegacyAcpIdentityProjection } from "./manager.utils.js"; +/** Reconciles runtime-reported session identifiers into persisted ACP session metadata. */ export async function reconcileManagerRuntimeSessionIdentifiers(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/acp/control-plane/manager.initialize-session.ts b/src/acp/control-plane/manager.initialize-session.ts index e233eaff3419..b6fd08b44f67 100644 --- a/src/acp/control-plane/manager.initialize-session.ts +++ b/src/acp/control-plane/manager.initialize-session.ts @@ -22,6 +22,7 @@ import { validateRuntimeOptionPatch, } from "./runtime-options.js"; +/** Initializes an ACP runtime session and persists its metadata before caching the handle. */ export async function runManagerInitializeSession(params: { input: AcpInitializeSessionInput; sessionKey: string; diff --git a/src/acp/control-plane/manager.runtime-handle-cache.ts b/src/acp/control-plane/manager.runtime-handle-cache.ts index d686a2768589..4470d9da8ce6 100644 --- a/src/acp/control-plane/manager.runtime-handle-cache.ts +++ b/src/acp/control-plane/manager.runtime-handle-cache.ts @@ -16,6 +16,7 @@ import { RuntimeCache, type CachedRuntimeState } from "./runtime-cache.js"; import { normalizeText } from "./runtime-options.js"; import type { SessionActorQueue } from "./session-actor-queue.js"; +/** Process-local cache of live ACP runtime handles keyed by canonical session actor. */ export class ManagerRuntimeHandleCache { private readonly runtimeCache = new RuntimeCache(); private evictedRuntimeCount = 0; @@ -96,6 +97,7 @@ export class ManagerRuntimeHandleCache { } for (const candidate of candidates) { + // Evict under the same actor queue so turns cannot race with runtime close. await params.actorQueue.run(candidate.actorKey, async () => { if (params.activeTurnBySession.has(candidate.actorKey)) { return; diff --git a/src/acp/control-plane/manager.runtime-resume-state.ts b/src/acp/control-plane/manager.runtime-resume-state.ts index b67e9228f763..9b1eae50577c 100644 --- a/src/acp/control-plane/manager.runtime-resume-state.ts +++ b/src/acp/control-plane/manager.runtime-resume-state.ts @@ -11,6 +11,7 @@ import type { WriteManagerSessionMeta, } from "./manager.types.js"; +/** Detects acpx exits that are safe to retry with a fresh runtime handle. */ export function isRecoverableManagerAcpxExitError(message: string): boolean { return /^acpx exited with (code \d+|signal [a-z0-9]+)/i.test(message.trim()); } diff --git a/src/acp/control-plane/manager.startup-identity-reconcile.ts b/src/acp/control-plane/manager.startup-identity-reconcile.ts index 0b79e92d9dc8..6569f7d484ab 100644 --- a/src/acp/control-plane/manager.startup-identity-reconcile.ts +++ b/src/acp/control-plane/manager.startup-identity-reconcile.ts @@ -14,6 +14,7 @@ import type { WithManagerSessionActor, } from "./manager.types.js"; +/** Resolves pending ACP session identities opportunistically during manager startup. */ export async function runManagerStartupIdentityReconcile(params: { cfg: OpenClawConfig; deps: Pick; diff --git a/src/acp/control-plane/manager.status.ts b/src/acp/control-plane/manager.status.ts index 15c582010ae4..257a939e9f8b 100644 --- a/src/acp/control-plane/manager.status.ts +++ b/src/acp/control-plane/manager.status.ts @@ -16,6 +16,7 @@ import type { import { requireReadySessionMeta } from "./manager.utils.js"; import { resolveRuntimeOptionsFromMeta } from "./runtime-options.js"; +/** Reads a fresh ACP session status and reconciles runtime identifiers from the status response. */ export async function runManagerGetSessionStatus(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/acp/control-plane/manager.test-helpers.ts b/src/acp/control-plane/manager.test-helpers.ts index 5d0a2170350a..afb3e5c46d98 100644 --- a/src/acp/control-plane/manager.test-helpers.ts +++ b/src/acp/control-plane/manager.test-helpers.ts @@ -37,6 +37,7 @@ vi.mock("../runtime/registry.js", () => ({ export const hoisted = hoistedMocks; +// Shared ACP manager test harness with hoisted runtime/session-meta mocks. const managerModule = await import("./manager.js"); export const AcpSessionManager = managerModule.AcpSessionManager; export const resetAcpSessionManagerForTests = () => diff --git a/src/acp/control-plane/manager.ts b/src/acp/control-plane/manager.ts index d9acc55549f8..f3587ff76466 100644 --- a/src/acp/control-plane/manager.ts +++ b/src/acp/control-plane/manager.ts @@ -15,6 +15,7 @@ export type { let ACP_SESSION_MANAGER_SINGLETON: AcpSessionManager | null = null; +/** Returns the process-wide ACP session manager singleton. */ export function getAcpSessionManager(): AcpSessionManager { if (!ACP_SESSION_MANAGER_SINGLETON) { ACP_SESSION_MANAGER_SINGLETON = new AcpSessionManager(); diff --git a/src/acp/control-plane/manager.turn-stream.ts b/src/acp/control-plane/manager.turn-stream.ts index aba46e31b71a..56fb13d18e8f 100644 --- a/src/acp/control-plane/manager.turn-stream.ts +++ b/src/acp/control-plane/manager.turn-stream.ts @@ -12,6 +12,7 @@ export type AcpTurnEventGate = { open: boolean; }; +/** Summary of whether a turn stream emitted user-visible output or terminal events. */ export type AcpTurnStreamOutcome = { sawOutput: boolean; sawTerminalEvent: boolean; @@ -113,6 +114,7 @@ export async function consumeAcpTurnStream(params: { ) => Promise | void; }): Promise { if (params.runtime.startTurn) { + // startTurn exposes result and event streams separately; coordinate both before reporting done. const turn = params.runtime.startTurn(params.turn); const eventsPromise = consumeAcpTurnEvents({ events: turn.events, diff --git a/src/acp/control-plane/manager.turn-timeout.ts b/src/acp/control-plane/manager.turn-timeout.ts index 4f9742eb9ecd..6461c7aa1e9c 100644 --- a/src/acp/control-plane/manager.turn-timeout.ts +++ b/src/acp/control-plane/manager.turn-timeout.ts @@ -10,6 +10,7 @@ import { resolveRuntimeOptionsFromMeta } from "./runtime-options.js"; const ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS = 2_000; const ACP_TURN_TIMEOUT_REASON = "turn-timeout"; +/** Resolves the effective ACP turn timeout from session runtime options or agent defaults. */ export function resolveTurnTimeoutMs(params: { cfg: OpenClawConfig; meta: SessionAcpMeta; diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index 087e518dfd4d..fb6e8791bed8 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -22,6 +22,7 @@ import { upsertAcpSessionMeta, } from "../runtime/session-meta.js"; +/** Result of resolving persisted ACP metadata for a session key. */ export type AcpSessionResolution = | { kind: "none"; diff --git a/src/acp/control-plane/manager.utils.ts b/src/acp/control-plane/manager.utils.ts index d92b9f9a6277..ac2949842aa5 100644 --- a/src/acp/control-plane/manager.utils.ts +++ b/src/acp/control-plane/manager.utils.ts @@ -13,6 +13,7 @@ import { } from "../../routing/session-key.js"; import type { AcpSessionResolution } from "./manager.types.js"; +/** Resolves the agent id encoded in an ACP session key. */ export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string { const parsed = parseAgentSessionKey(sessionKey); return normalizeAgentId(parsed?.agentId ?? fallback); diff --git a/src/acp/control-plane/runtime-cache.ts b/src/acp/control-plane/runtime-cache.ts index 9c2ed087c7ae..ee4fd3224760 100644 --- a/src/acp/control-plane/runtime-cache.ts +++ b/src/acp/control-plane/runtime-cache.ts @@ -4,6 +4,7 @@ import type { AcpRuntimeSessionMode, } from "@openclaw/acp-core/runtime/types"; +/** Cached runtime handle plus the configuration signature that made it reusable. */ export type CachedRuntimeState = { runtime: AcpRuntime; handle: AcpRuntimeHandle; diff --git a/src/acp/control-plane/runtime-options.ts b/src/acp/control-plane/runtime-options.ts index 46d119af3fe6..8508add567c0 100644 --- a/src/acp/control-plane/runtime-options.ts +++ b/src/acp/control-plane/runtime-options.ts @@ -19,6 +19,7 @@ const MAX_BACKEND_OPTION_VALUE_LENGTH = 512; const MAX_BACKEND_EXTRAS = 32; const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i; +// User-facing config aliases accepted by ACP clients and normalized to session runtime options. const RUNTIME_CONFIG_OPTION_ALIASES = { model: ["model"], thinking: ["thinking", "effort", "reasoning_effort", "thought_level"], diff --git a/src/acp/control-plane/session-actor-queue.ts b/src/acp/control-plane/session-actor-queue.ts index 54a8d33e54bd..ee6c1c206cad 100644 --- a/src/acp/control-plane/session-actor-queue.ts +++ b/src/acp/control-plane/session-actor-queue.ts @@ -1,5 +1,6 @@ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; +/** Per-session async queue that serializes ACP runtime operations and exposes queue depth. */ export class SessionActorQueue { private readonly queue = new KeyedAsyncQueue(); private readonly pendingBySession = new Map(); @@ -26,6 +27,7 @@ export class SessionActorQueue { this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1); }, onSettle: () => { + // Keep queue-depth accounting symmetric with enqueue even when operations reject. const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1; if (pending <= 0) { this.pendingBySession.delete(actorKey); diff --git a/src/acp/conversation-id.ts b/src/acp/conversation-id.ts index 0c84bc9fed85..a81b643bd265 100644 --- a/src/acp/conversation-id.ts +++ b/src/acp/conversation-id.ts @@ -1,3 +1,4 @@ +/** Normalizes ACP conversation identifiers from loose metadata values. */ export function normalizeConversationText(value: unknown): string { if (typeof value === "string") { return value.trim(); diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts index 4727a49defce..ed509d00b735 100644 --- a/src/acp/persistent-bindings.lifecycle.ts +++ b/src/acp/persistent-bindings.lifecycle.ts @@ -13,6 +13,7 @@ import { } from "./persistent-bindings.types.js"; import { readAcpSessionEntry } from "./runtime/session-meta.js"; +// Binding lifecycle keeps configured channel conversations attached to matching ACP sessions. function sessionMatchesConfiguredBinding(params: { cfg: OpenClawConfig; spec: ConfiguredAcpBindingSpec; diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index da533d2ab616..a202b91924f1 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -10,6 +10,7 @@ import { type ResolvedConfiguredAcpBinding, } from "./persistent-bindings.types.js"; +/** Resolves a configured ACP binding for a concrete channel conversation. */ export function resolveConfiguredAcpBindingRecord(params: { cfg: OpenClawConfig; channel: string; diff --git a/src/acp/runtime/adapter-contract.testkit.ts b/src/acp/runtime/adapter-contract.testkit.ts index 6d6018aa1156..55af9ea936b5 100644 --- a/src/acp/runtime/adapter-contract.testkit.ts +++ b/src/acp/runtime/adapter-contract.testkit.ts @@ -17,6 +17,7 @@ export type AcpRuntimeAdapterContractParams = { }) => void | Promise; }; +/** Runs the shared behavioral contract for ACP runtime adapters. */ export async function runAcpRuntimeAdapterContract( params: AcpRuntimeAdapterContractParams, ): Promise { diff --git a/src/acp/runtime/availability.ts b/src/acp/runtime/availability.ts index 32a4bed6bd6f..f577e1bfd286 100644 --- a/src/acp/runtime/availability.ts +++ b/src/acp/runtime/availability.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isAcpEnabledByPolicy } from "../policy.js"; import { getAcpRuntimeBackend } from "./registry.js"; +/** Returns whether ACP runtime spawning is allowed and the selected backend is healthy enough. */ export function isAcpRuntimeSpawnAvailable(params: { config?: OpenClawConfig; sandboxed?: boolean; diff --git a/src/acp/runtime/errors.ts b/src/acp/runtime/errors.ts index 6f08d4056dad..b6be7091b998 100644 --- a/src/acp/runtime/errors.ts +++ b/src/acp/runtime/errors.ts @@ -1,6 +1,7 @@ import { configureAcpErrorRedactor } from "@openclaw/acp-core"; import { redactSensitiveText } from "../../logging/redact.js"; +// Ensure ACP-core runtime errors use OpenClaw's secret redaction before re-export. configureAcpErrorRedactor(redactSensitiveText); export * from "@openclaw/acp-core/runtime/errors"; diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index 7127fe59f335..3d618a281145 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -36,6 +36,7 @@ export type AcpSessionStoreEntry = { storeReadFailed?: boolean; }; +// ACP metadata lives in SQLite but is keyed through the legacy JSON session store. type AcpSessionsTable = OpenClawStateKyselyDatabase["acp_sessions"]; type AcpSessionMetaDatabase = Pick; type AcpSessionRow = Selectable; @@ -154,6 +155,7 @@ function selectAcpSessionRow(db: DatabaseSync, sessionKey: string): AcpSessionRo } function acpSessionRowMatchesEntry(row: AcpSessionRow, entry: SessionEntry | undefined): boolean { + // Rows tied to a specific sessionId are stale after the JSON session entry rotates. return row.session_id == null || row.session_id === entry?.sessionId; } diff --git a/src/acp/secret-file.ts b/src/acp/secret-file.ts index 81fcc200e64b..abcb84fd0a7d 100644 --- a/src/acp/secret-file.ts +++ b/src/acp/secret-file.ts @@ -2,6 +2,7 @@ import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secr const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES; +/** Reads an ACP secret file with the shared secret-file size and symlink policy. */ export function readSecretFromFile(filePath: string, label: string): string { return readSecretFileSync(filePath, label, { maxBytes: MAX_SECRET_FILE_BYTES, diff --git a/src/acp/session-mapper.ts b/src/acp/session-mapper.ts index aa5d72cf6543..5d7ee9c6f09c 100644 --- a/src/acp/session-mapper.ts +++ b/src/acp/session-mapper.ts @@ -10,6 +10,7 @@ type AcpSessionMeta = { prefixCwd?: boolean; }; +/** Parses ACP request metadata into OpenClaw session routing hints. */ export function parseSessionMeta(meta: unknown): AcpSessionMeta { if (!meta || typeof meta !== "object") { return {}; diff --git a/src/acp/translator.bridge-test-helpers.ts b/src/acp/translator.bridge-test-helpers.ts index 453b1b4555a3..e357e2cc3d16 100644 --- a/src/acp/translator.bridge-test-helpers.ts +++ b/src/acp/translator.bridge-test-helpers.ts @@ -12,6 +12,7 @@ import type { GatewayClient } from "../gateway/client.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; +/** Builds a minimal ACP new-session request for translator tests. */ export function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest { return { cwd, diff --git a/src/acp/translator.presentation.ts b/src/acp/translator.presentation.ts index 14631ff82367..3715b91abcca 100644 --- a/src/acp/translator.presentation.ts +++ b/src/acp/translator.presentation.ts @@ -12,6 +12,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; import type { GatewaySessionRow } from "../gateway/session-utils.js"; +/** ACP config option ids exposed to compatible ACP clients. */ export const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; export const ACP_FAST_MODE_CONFIG_ID = "fast_mode"; export const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; @@ -28,6 +29,7 @@ export type ClientCapabilityState = { terminal: boolean; }; +/** Gateway session fields needed to build ACP session presentation state. */ export type GatewaySessionPresentationRow = Pick< GatewaySessionRow, | "key" diff --git a/src/acp/translator.prompt-harness.test-support.ts b/src/acp/translator.prompt-harness.test-support.ts index 61d05c9699c3..5ac2d2aa97cc 100644 --- a/src/acp/translator.prompt-harness.test-support.ts +++ b/src/acp/translator.prompt-harness.test-support.ts @@ -12,6 +12,7 @@ type PendingPromptHarness = { runId: string; }; +// Shared prompt harness used by translator cancellation and lifecycle tests. const DEFAULT_SESSION_ID = "session-1"; export const DEFAULT_SESSION_KEY = "agent:main:main"; const DEFAULT_PROMPT_TEXT = "hello"; diff --git a/src/acp/translator.replay.ts b/src/acp/translator.replay.ts index 0e901ff6192d..53a165d421d9 100644 --- a/src/acp/translator.replay.ts +++ b/src/acp/translator.replay.ts @@ -1,3 +1,4 @@ +/** Gateway transcript message shape accepted by ACP replay extraction. */ export type GatewayTranscriptMessage = { role?: unknown; content?: unknown; diff --git a/src/acp/translator.session-list.ts b/src/acp/translator.session-list.ts index b2712525d085..6c0e04ae8c77 100644 --- a/src/acp/translator.session-list.ts +++ b/src/acp/translator.session-list.ts @@ -6,6 +6,7 @@ const ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE = 100; const ACP_LIST_SESSIONS_MAX_PAGE_SIZE = 100; const ACP_LIST_SESSIONS_MAX_CURSOR_OFFSET = 10_000; +/** Maximum rows fetched to satisfy ACP session-list pagination plus next-page detection. */ export const ACP_LIST_SESSIONS_MAX_FETCH_LIMIT = ACP_LIST_SESSIONS_MAX_CURSOR_OFFSET + ACP_LIST_SESSIONS_MAX_PAGE_SIZE + 1; diff --git a/src/acp/translator.session-updates.ts b/src/acp/translator.session-updates.ts index 1db8da486393..77dfdc05b836 100644 --- a/src/acp/translator.session-updates.ts +++ b/src/acp/translator.session-updates.ts @@ -12,6 +12,7 @@ export type AcpTranslatorSessionRef = { ledgerSessionId?: string; }; +// Session update helper records ACP-visible updates into the replay ledger when requested. type AcpTranslatorLedgerSessionRef = AcpTranslatorSessionRef & { cwd: string; }; diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index 7ea97fa7d7f6..dd7ee8d86134 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -7,6 +7,7 @@ type TestAcpConnection = AgentSideConnection & { __sessionUpdateMock: ReturnType; }; +/** Creates a mocked ACP connection with exposed permission and update spies. */ export function createAcpConnection( params: { requestPermission?: ReturnType; diff --git a/src/acp/types.ts b/src/acp/types.ts index 7cd228682044..0772827178c6 100644 --- a/src/acp/types.ts +++ b/src/acp/types.ts @@ -2,6 +2,7 @@ export type { AcpProvenanceMode, AcpServerOptions, AcpSession } from "@openclaw/ export { normalizeAcpProvenanceMode } from "@openclaw/acp-core/types"; import { VERSION } from "../version.js"; +/** ACP agent identity advertised during protocol initialization. */ export const ACP_AGENT_INFO = { name: "openclaw-acp", title: "OpenClaw ACP Gateway", diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 339a2b00cd94..9ac8dff7c2db 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -12,6 +12,7 @@ type CliUsage = { total?: number; }; +/** Normalized result from a CLI-backed model provider turn. */ export type CliOutput = { text: string; rawText?: string; @@ -20,6 +21,7 @@ export type CliOutput = { finalPromptText?: string; }; +/** Incremental assistant text emitted while parsing a streaming CLI response. */ export type CliStreamingDelta = { text: string; delta: string; @@ -27,12 +29,14 @@ export type CliStreamingDelta = { usage?: CliUsage; }; +/** Tool-call start event reconstructed from CLI stream output. */ export type CliToolUseStartDelta = { toolCallId: string; name: string; args: Record; }; +/** Tool-call result event reconstructed from CLI stream output. */ export type CliToolResultDelta = { toolCallId: string; name: string; @@ -82,6 +86,7 @@ function parseJsonRecordCandidates(raw: string): Record[] { // Fall back to scanning for top-level JSON objects embedded in mixed output. } + // Some CLIs prefix JSON with banners/logs; balanced scanning recovers structured records. for (const candidate of extractJsonObjectCandidates(trimmed)) { try { const parsed = JSON.parse(candidate); @@ -238,6 +243,7 @@ function unwrapNestedCliResultText(raw: string): string { ) { return text; } + // Claude can wrap a result payload inside repeated JSON-string result envelopes. text = parsed.result; } catch { return text; @@ -304,6 +310,7 @@ function shouldUnwrapNestedCliResultText(params: { return !Object.hasOwn(params.parsed, "type") || params.parsed.type === "result"; } +/** Parses JSON CLI output, including mixed stdout that contains embedded JSON objects. */ export function parseCliJson( raw: string, backend: CliBackendConfig, @@ -436,6 +443,7 @@ function emitToolStartOnce( args: Record, onToolUseStart?: (delta: CliToolUseStartDelta) => void, ): void { + // Streaming and final assistant records may both describe the same tool call. if (tracker.startedIds.has(toolCallId)) { return; } @@ -451,6 +459,7 @@ function emitToolResultOnce( result: unknown, onToolResult?: (delta: CliToolResultDelta) => void, ): void { + // Tool results can arrive as assistant result blocks or echoed user tool_result blocks. if (tracker.resultDeliveredIds.has(toolCallId)) { return; } @@ -609,6 +618,7 @@ function dispatchClaudeCliStreamingToolEvent(params: { } } +/** Creates an incremental JSONL parser for CLI streaming responses and tool events. */ export function createCliJsonlStreamingParser(params: { backend: CliBackendConfig; providerId: string; @@ -735,6 +745,7 @@ export function createCliJsonlStreamingParser(params: { }; } +/** Parses complete JSONL CLI output into the final assistant result and metadata. */ export function parseCliJsonl( raw: string, backend: CliBackendConfig, @@ -786,6 +797,7 @@ export function parseCliJsonl( return { text, sessionId, usage }; } +/** Parses CLI output according to the backend output mode with text fallback. */ export function parseCliOutput(params: { raw: string; backend: CliBackendConfig; @@ -813,6 +825,7 @@ export function parseCliOutput(params: { ); } +/** Extracts the most specific structured CLI error message from mixed or JSON output. */ export function extractCliErrorMessage(raw: string): string | null { const parsedRecords = parseJsonRecordCandidates(raw); if (parsedRecords.length === 0) { diff --git a/src/agents/compaction-planning.ts b/src/agents/compaction-planning.ts index 91ca7b2a8960..424ad25b5475 100644 --- a/src/agents/compaction-planning.ts +++ b/src/agents/compaction-planning.ts @@ -14,6 +14,7 @@ const DEFAULT_PARTS = 2; // generateSummary uses reasoning: "high" which also consumes context budget. export const SUMMARIZATION_OVERHEAD_TOKENS = 4096; +/** Decision for whether a summarization stage should run as one chunk or multiple chunks. */ export type StageSplitPlan = | { mode: "single"; @@ -23,11 +24,13 @@ export type StageSplitPlan = chunks: AgentMessage[][]; }; +/** Messages safe to summarize plus notes for messages too large to fit in a summary request. */ export type OversizedFallbackPlan = { smallMessages: AgentMessage[]; oversizedNotes: string[]; }; +/** Token accounting and optional prune result for preserving context-window headroom. */ export type HistoryPrunePlan = { summarizableTokens: number; newContentTokens: number; @@ -35,20 +38,24 @@ export type HistoryPrunePlan = { pruned?: ReturnType; }; +/** Estimates compaction tokens after removing fields that must not reach summarization. */ export function estimateMessagesTokens(messages: AgentMessage[]): number { // SECURITY: toolResult.details and runtime-context transcript entries must never enter LLM-facing compaction. const safe = sanitizeCompactionMessages(messages); return safe.reduce((sum, message) => sum + estimateTokens(message), 0); } +/** Removes runtime-only context and tool-result details before token estimates or summaries. */ export function sanitizeCompactionMessages(messages: AgentMessage[]): AgentMessage[] { return stripToolResultDetails(stripRuntimeContextCustomMessages(messages)); } +/** Estimates one message using the same sanitization path as multi-message planning. */ export function estimateCompactionMessageTokens(message: AgentMessage): number { return estimateMessagesTokens([message]); } +/** Clamps requested split parts to a usable count for the available messages. */ export function normalizeCompactionParts(parts: number, messageCount: number): number { if (!Number.isFinite(parts) || parts <= 1) { return 1; @@ -56,6 +63,7 @@ export function normalizeCompactionParts(parts: number, messageCount: number): n return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount)); } +/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */ export function splitMessagesByTokenShare( messages: AgentMessage[], parts = DEFAULT_PARTS, @@ -85,6 +93,7 @@ export function splitMessagesByTokenShare( ) { return false; } + // Keep an assistant tool_use and its following tool_result responses in the same chunk. chunks.push(current.slice(0, pendingChunkStartIndex)); current = current.slice(pendingChunkStartIndex); currentTokens = current.reduce((sum, msg) => sum + estimateCompactionMessageTokens(msg), 0); @@ -152,6 +161,7 @@ export function splitMessagesByTokenShare( return chunks; } +/** Chunks messages by a max-token budget while applying the shared estimator safety margin. */ export function chunkMessagesByMaxTokens( messages: AgentMessage[], maxTokens: number, @@ -228,6 +238,7 @@ export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): return tokens > contextWindow * 0.5; } +/** Builds sanitized chunks for summarization prompts. */ export function buildSummaryChunks(params: { messages: AgentMessage[]; maxChunkTokens: number; @@ -237,6 +248,7 @@ export function buildSummaryChunks(params: { return chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens); } +/** Separates messages too large to summarize and emits compact placeholder notes for them. */ export function buildOversizedFallbackPlan(params: { messages: AgentMessage[]; contextWindow: number; @@ -259,6 +271,7 @@ export function buildOversizedFallbackPlan(params: { return { smallMessages, oversizedNotes }; } +/** Plans whether to split a summarization stage based on message count and token budget. */ export function buildStageSplitPlan(params: { messages: AgentMessage[]; maxChunkTokens: number; @@ -283,6 +296,7 @@ export function buildStageSplitPlan(params: { return chunks.length > 1 ? { mode: "split", chunks } : { mode: "single" }; } +/** Drops oldest token-share chunks until history fits the requested context share. */ export function pruneHistoryForContextShare(params: { messages: AgentMessage[]; maxContextTokens: number; @@ -350,6 +364,7 @@ export function pruneHistoryForContextShare(params: { }; } +/** Computes whether new content exceeds the history budget and plans pruning when needed. */ export function buildHistoryPrunePlan(params: { messagesToSummarize: AgentMessage[]; turnPrefixMessages: AgentMessage[]; diff --git a/src/agents/embedded-agent-runner/run/abortable.ts b/src/agents/embedded-agent-runner/run/abortable.ts index 5a2427eb183d..4d5d275c8280 100644 --- a/src/agents/embedded-agent-runner/run/abortable.ts +++ b/src/agents/embedded-agent-runner/run/abortable.ts @@ -14,6 +14,11 @@ function makeAbortError(signal: AbortSignal): Error { return err; } +/** + * Races a promise against an AbortSignal while preserving normal promise + * settlement. Abort wins immediately and rejected non-Error payloads are + * normalized so callers can safely log/inspect them as Error objects. + */ export function abortable(signal: AbortSignal, promise: Promise): Promise { if (signal.aborted) { return Promise.reject(makeAbortError(signal)); @@ -37,6 +42,7 @@ export function abortable(signal: AbortSignal, promise: Promise): Promise< }); } +/** Converts non-Error promise rejections into Error instances without dropping object fields. */ function toLintErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; diff --git a/src/agents/embedded-agent-runner/run/assistant-failover.ts b/src/agents/embedded-agent-runner/run/assistant-failover.ts index ff12fb9ef4e9..a27f33122ec8 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failover.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failover.ts @@ -32,6 +32,11 @@ type AssistantFailoverOutcome = error: FailoverError; }; +/** + * Applies an assistant-stage failover decision and returns the next run action. + * It owns auth-profile rotation, overload/rate-limit escalation, same-model + * idle-timeout retry, and FailoverError construction for outer model fallback. + */ export async function handleAssistantFailover(params: { initialDecision: AssistantFailoverDecision; aborted: boolean; @@ -165,6 +170,8 @@ export async function handleAssistantFailover(params: { ); } if (rotated) { + // Marking the failed profile is non-blocking after rotation succeeds; the + // retry can proceed with the next profile while the failure record settles. void markFailedProfilePromise; params.logAssistantFailoverDecision("rotate_profile"); await params.maybeBackoffBeforeOverloadFailover(params.failoverReason); @@ -200,6 +207,8 @@ export async function handleAssistantFailover(params: { } if (decision.action === "fallback_model") { + // Backoff runs before throwing so the outer fallback model starts after the + // provider-specific overload delay. await params.maybeBackoffBeforeOverloadFailover(params.failoverReason); const message = resolveAssistantFailoverErrorMessage(params); const status = diff --git a/src/agents/embedded-agent-runner/run/attempt-abort.ts b/src/agents/embedded-agent-runner/run/attempt-abort.ts index 9c4cf72aae0c..ce67cbcb9583 100644 --- a/src/agents/embedded-agent-runner/run/attempt-abort.ts +++ b/src/agents/embedded-agent-runner/run/attempt-abort.ts @@ -4,6 +4,11 @@ type AbortLockReleaseLog = { warn(message: string): void; }; +/** + * Releases the held session lock after an abort without blocking abort + * propagation. Release failures are logged because the caller is already + * unwinding the run and cannot safely await lock cleanup there. + */ export function releaseEmbeddedAttemptSessionLockForAbort(params: { sessionLockController: Pick; log: AbortLockReleaseLog; diff --git a/src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts b/src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts index 22adc2534255..70bfa63b2ec8 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bootstrap-routing.ts @@ -2,6 +2,7 @@ import type { BootstrapMode } from "../../bootstrap-mode.js"; import { resolveBootstrapMode } from "../../bootstrap-mode.js"; import { DEFAULT_BOOTSTRAP_FILENAME, type WorkspaceBootstrapFile } from "../../workspace.js"; +/** Inputs that decide whether this attempt should inject workspace bootstrap context. */ export type AttemptBootstrapRoutingInput = { workspaceBootstrapPending: boolean; bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; @@ -14,6 +15,7 @@ export type AttemptBootstrapRoutingInput = { hasBootstrapFileAccess: boolean; }; +/** Bootstrap placement decision consumed by system/runtime context assembly. */ export type AttemptBootstrapRouting = { bootstrapMode: BootstrapMode; includeBootstrapInSystemContext: boolean; @@ -28,6 +30,11 @@ export type AttemptWorkspaceBootstrapRoutingInput = Omit< bootstrapFiles?: readonly WorkspaceBootstrapFile[]; }; +/** + * Maps a resolved bootstrap mode to concrete prompt destinations. Today only + * full bootstrap enters system context; limited/none intentionally avoid + * runtime-context injection until that path has a separate contract. + */ export function resolveBootstrapContextTargets(params: { bootstrapMode: BootstrapMode; }): Pick< @@ -72,6 +79,12 @@ export function hasBootstrapFileContent(files?: readonly WorkspaceBootstrapFile[ ); } +/** + * Resolves workspace bootstrap routing after checking pending state and + * hook-provided bootstrap files. Hook content counts as both pending bootstrap + * and file access so generated bootstrap text follows the same route as disk + * bootstrap content. + */ export async function resolveAttemptWorkspaceBootstrapRouting( params: AttemptWorkspaceBootstrapRoutingInput, ): Promise { diff --git a/src/agents/embedded-agent-runner/run/attempt-http-runtime.ts b/src/agents/embedded-agent-runner/run/attempt-http-runtime.ts index 3c79be7b1d99..21d7a1081353 100644 --- a/src/agents/embedded-agent-runner/run/attempt-http-runtime.ts +++ b/src/agents/embedded-agent-runner/run/attempt-http-runtime.ts @@ -4,6 +4,7 @@ import { ensureGlobalUndiciEnvProxyDispatcher, } from "../../../infra/net/undici-global-dispatcher.js"; +/** Configures process-wide Undici proxy and stream timeout behavior for one embedded attempt. */ export function configureEmbeddedAttemptHttpRuntime(params: { timeoutMs: number }): void { // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. diff --git a/src/agents/embedded-agent-runner/run/attempt-stage-timing.ts b/src/agents/embedded-agent-runner/run/attempt-stage-timing.ts index e53d2723d979..ecf8e4561642 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stage-timing.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stage-timing.ts @@ -1,19 +1,23 @@ +/** Timing for one named stage, including both stage duration and run-relative elapsed time. */ export type EmbeddedRunStageTiming = { name: string; durationMs: number; elapsedMs: number; }; +/** Snapshot of all marked stages plus total elapsed time at snapshot creation. */ export type EmbeddedRunStageSummary = { totalMs: number; stages: EmbeddedRunStageTiming[]; }; +/** Lightweight monotonic-ish stage tracker used for embedded run startup diagnostics. */ export type EmbeddedRunStageTracker = { mark: (name: string) => void; snapshot: () => EmbeddedRunStageSummary; }; +/** Canonical stage names for dispatch-time embedded attempt diagnostics. */ export const EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE = { workspace: "attempt-workspace", prompt: "attempt-prompt", @@ -24,6 +28,11 @@ export const EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE = { const EMBEDDED_RUN_STAGE_WARN_TOTAL_MS = 10_000; const EMBEDDED_RUN_STAGE_WARN_STAGE_MS = 5_000; +/** + * Creates an append-only stage tracker. `mark` records time since the previous + * mark while `snapshot` reports current total elapsed time without mutating the + * recorded stage list. + */ export function createEmbeddedRunStageTracker(options?: { now?: () => number; }): EmbeddedRunStageTracker { @@ -53,6 +62,7 @@ export function createEmbeddedRunStageTracker(options?: { }; } +/** Returns true when either total runtime or any single stage exceeds warning thresholds. */ export function shouldWarnEmbeddedRunStageSummary( summary: EmbeddedRunStageSummary, options?: { @@ -68,6 +78,7 @@ export function shouldWarnEmbeddedRunStageSummary( ); } +/** Formats stage timing into compact log text for startup/attempt diagnostics. */ export function formatEmbeddedRunStageSummary( prefix: string, summary: EmbeddedRunStageSummary, diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt.ts index 7828359ea62b..ca67e19977b9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt.ts @@ -22,11 +22,17 @@ export type BuildAttemptSystemPromptParams = { }; }; +/** System prompt pair used by an attempt: untransformed base plus provider-ready prompt. */ export type AttemptSystemPrompt = { baseSystemPrompt: string; systemPrompt: string; }; +/** + * Builds the embedded system prompt and applies provider-specific transforms + * unless this is a raw model run. Raw runs still keep `baseSystemPrompt` for + * diagnostics/cache boundaries, but submit an empty provider prompt. + */ export function buildAttemptSystemPrompt( params: BuildAttemptSystemPromptParams, ): AttemptSystemPrompt { diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index 624691edc95e..b0106b999aae 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -70,6 +70,7 @@ function cloneCodingToolConstructionPlan( } function isBundleMcpAllowlistName(normalized: string): boolean { + // Bundle MCP tools use the synthetic bundle name or `bundle__tool` separator form. return normalized === "bundle-mcp" || normalized.includes(TOOL_NAME_SEPARATOR); } @@ -82,6 +83,8 @@ function hasWildcardToolAllowlist(toolsAllow: string[]): boolean { } function isKnownLocalCodingToolName(normalized: string): boolean { + // Unknown non-bundle names are treated as plugin tools so installed plugin + // catalog entries still materialize under narrow allowlists. return ( BASE_CODING_TOOL_FACTORY_NAMES.has(normalized) || SHELL_CODING_TOOL_FACTORY_NAMES.has(normalized) || @@ -89,6 +92,11 @@ function isKnownLocalCodingToolName(normalized: string): boolean { ); } +/** + * Applies a runtime allowlist to a concrete tool list after expanding tool and + * plugin groups. Undefined allowlists keep all tools; an explicit empty list + * intentionally disables all runtime tools. + */ export function applyEmbeddedAttemptToolsAllow( tools: T[], toolsAllow?: string[], @@ -114,6 +122,11 @@ export function applyEmbeddedAttemptToolsAllow( return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); } +/** + * Adds the message tool to a narrowed allowlist when the caller must support + * forced source-reply delivery. Wildcard and undefined allowlists already cover + * message, while an empty allowlist becomes message-only. + */ export function mergeForcedEmbeddedAttemptToolsAllow( toolsAllow: string[] | undefined, params: { forceMessageTool?: boolean }, @@ -154,8 +167,10 @@ function resolveCodingToolConstructionPlanForAllowlist( const includePluginTools = normalized.some( (name) => name === "group:plugins" || + // Plugin ids/tool names are not known to this local factory list at build time. (!isBundleMcpAllowlistName(name) && !isKnownLocalCodingToolName(name)), ); + // Channel delivery tools are constructed through plugin-capable runtime setup. const includeChannelTools = includePluginTools; return { @@ -167,6 +182,11 @@ function resolveCodingToolConstructionPlanForAllowlist( }; } +/** + * Decides which tool families need to be constructed for an embedded attempt. + * This keeps allowlisted plugin/channel tools available without forcing every + * local core tool factory to run for narrow plugin-only configurations. + */ export function resolveEmbeddedAttemptToolConstructionPlan(params: { disableTools?: boolean; isRawModelRun?: boolean; @@ -206,10 +226,16 @@ export function resolveEmbeddedAttemptToolConstructionPlan(params: { }; } +/** Returns whether the allowlist requires any built-in coding/OpenClaw tools. */ export function shouldBuildCoreCodingToolsForAllowlist(toolsAllow?: string[]): boolean { return resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow }).includeCoreTools; } +/** + * Decides whether the bundled MCP runtime is needed for this attempt. Bundle + * runtime creation follows explicit bundle/plugin allowlist names rather than + * generic local tool names. + */ export function shouldCreateBundleMcpRuntimeForAttempt(params: { toolsEnabled: boolean; disableTools?: boolean; @@ -233,6 +259,11 @@ export function shouldCreateBundleMcpRuntimeForAttempt(params: { }); } +/** + * Decides whether the bundled LSP runtime is needed for this attempt. LSP tools + * are enabled by default/wildcard and by allowlist entries with the `lsp_` + * prefix. + */ export function shouldCreateBundleLspRuntimeForAttempt(params: { toolsEnabled: boolean; disableTools?: boolean; diff --git a/src/agents/embedded-agent-runner/run/attempt-trajectory-flush-cleanup.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory-flush-cleanup.ts index af25ca9042b3..24ad8f17b250 100644 --- a/src/agents/embedded-agent-runner/run/attempt-trajectory-flush-cleanup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-trajectory-flush-cleanup.ts @@ -1,10 +1,16 @@ import { runAgentCleanupStep } from "../../run-cleanup-timeout.js"; +/** Minimal recorder surface needed to flush trajectory data during run cleanup. */ export type EmbeddedAttemptTrajectoryRecorder = { describeFlushState: () => string | undefined; flush: () => Promise; }; +/** + * Flushes attempt trajectory data through the shared cleanup timeout wrapper so + * stuck recorder writes warn with run/session context instead of blocking run + * teardown indefinitely. + */ export async function flushEmbeddedAttemptTrajectoryRecorder(params: { runId: string; sessionId: string; diff --git a/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts index aa196035b70e..f8d6f79a521f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts +++ b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts @@ -5,13 +5,16 @@ import { export type AttemptTrajectoryTerminalStatus = "success" | "error" | "interrupted"; +/** Terminal error marker for runs that produced no user-visible delivery or durable progress. */ export const NON_DELIVERABLE_TERMINAL_TURN_REASON = "non_deliverable_terminal_turn"; +/** Normalized terminal status recorded for an embedded run attempt trajectory. */ export type AttemptTrajectoryTerminal = { status: AttemptTrajectoryTerminalStatus; terminalError?: typeof NON_DELIVERABLE_TERMINAL_TURN_REASON; }; +/** Signals that decide whether a completed run attempt has deliverable output. */ export type ResolveAttemptTrajectoryTerminalParams = { promptError?: unknown; aborted: boolean; @@ -42,6 +45,11 @@ export type ResolveAttemptTrajectoryTerminalParams = { lastAssistantStopReason?: string; }; +/** + * Chooses assistant text that can safely count as terminal output. Provider error + * and abort stop reasons cannot fall back to the raw last visible text because + * that text may describe an interrupted generation rather than a completed reply. + */ export function resolveTerminalAssistantTexts(params: { assistantTexts: string[]; lastAssistantStopReason?: string; @@ -82,6 +90,12 @@ function hasAsyncStartedToolActivity(toolMetas?: readonly { asyncStarted?: boole return (toolMetas ?? []).some((entry) => entry.asyncStarted === true); } +/** + * Classifies the final attempt trajectory from visible output, durable side + * effects, and interruption state. Empty terminal turns are errors unless a + * caller proves a silent success, message delivery, spawned session, async task, + * or other durable progress. + */ export function resolveAttemptTrajectoryTerminal( params: ResolveAttemptTrajectoryTerminalParams, ): AttemptTrajectoryTerminal { @@ -92,6 +106,9 @@ export function resolveAttemptTrajectoryTerminal( return { status: "interrupted" }; } + // Messaging/tool-use attempts may not have assistant text; only committed + // delivery evidence or durable side effects can make those terminal turns + // successful. const hasExplicitTerminalDelivery = params.silentExpected === true || params.emptyAssistantReplyIsSilent === true || diff --git a/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts b/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts index 624a160e59dc..be45d592bc00 100644 --- a/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts @@ -4,6 +4,11 @@ type AbortSettleTimeoutEnv = Partial< Pick >; +/** + * Resolves how long embedded-run cleanup waits for abort side effects to settle. + * The explicit env override is strict decimal milliseconds; invalid values fall + * back to the normal/test defaults instead of silently widening cleanup waits. + */ export function resolveEmbeddedAbortSettleTimeoutMs( env: AbortSettleTimeoutEnv = process.env, ): number { diff --git a/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts b/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts index 10b5527b48e7..250c7dc78ac2 100644 --- a/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts +++ b/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts @@ -13,6 +13,7 @@ export type AsyncStartedToolMeta = { asyncTaskId?: string; }; +/** Summary of completion-required async task waits performed before a cron run can finish. */ export type CompletionRequiredAsyncTaskWaitResult = { waitedRunIds: string[]; timedOutRunIds: string[]; @@ -101,6 +102,8 @@ function collectAsyncTaskRunIds( if (!normalizedSessionKey) { return runIds; } + // Registry lookup catches completion-required tasks started before their + // tool metadata reached the current attempt result. for (const task of listTasksForOwnerOrRequesterSessionKeyForStatus(normalizedSessionKey)) { if (!COMPLETION_REQUIRED_TASK_KINDS.has(task.taskKind ?? "")) { continue; @@ -130,6 +133,7 @@ function findTerminalTasks(runIds: readonly string[]): { return { pendingRunIds, terminalTasks }; } +/** Returns whether a cron run has non-terminal generated-media tasks that must settle first. */ export function requiresCompletionRequiredAsyncTaskWait(params: { sessionKey: string | undefined; toolMetas: readonly AsyncStartedToolMeta[]; @@ -153,6 +157,12 @@ export function requiresCompletionRequiredAsyncTaskWait(params: { ); } +/** + * Polls completion-required async tasks until they reach terminal state, time + * out at the run deadline, or abort. Newly discovered task run ids are folded + * into later poll rounds so task metadata and registry state can arrive in any + * order. + */ export async function waitForCompletionRequiredAsyncTasks(params: { getToolMetas: () => readonly AsyncStartedToolMeta[]; sessionKey?: string; @@ -171,6 +181,8 @@ export async function waitForCompletionRequiredAsyncTasks(params: { while (true) { throwIfAborted(params.abortSignal); + // Re-read metadata every outer loop; tool calls may record async run ids + // after an earlier task wait finished. const runIds = collectAsyncTaskRunIds(params.getToolMetas(), params.sessionKey, waitedRunIds); if (runIds.length === 0) { return { diff --git a/src/agents/embedded-agent-runner/run/attempt.bootstrap-context.ts b/src/agents/embedded-agent-runner/run/attempt.bootstrap-context.ts index ade5b7e88611..596536c8eafd 100644 --- a/src/agents/embedded-agent-runner/run/attempt.bootstrap-context.ts +++ b/src/agents/embedded-agent-runner/run/attempt.bootstrap-context.ts @@ -2,11 +2,17 @@ import path from "node:path"; import { isAcpSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import type { EmbeddedContextFile } from "../../embedded-agent-helpers.js"; +/** + * Returns whether a session should receive primary bootstrap context. Subagents + * and ACP worker sessions inherit/run their own context path instead of getting + * the top-level bootstrap payload again. + */ export function isPrimaryBootstrapRun(sessionKey?: string): boolean { return !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey); } function isRelativePathInsideOrEqual(relativePath: string): boolean { + // `path.relative` returns "" for the workspace root; reject parent escapes and absolute paths. return ( relativePath === "" || (relativePath !== ".." && @@ -15,6 +21,11 @@ function isRelativePathInsideOrEqual(relativePath: string): boolean { ); } +/** + * Rewrites injected context file paths when a bootstrap assembled in one + * workspace is replayed in another. Files outside the source workspace keep + * their original absolute path to avoid manufacturing unsafe relative paths. + */ export function remapInjectedContextFilesToWorkspace(params: { files: EmbeddedContextFile[]; sourceWorkspaceDir: string; @@ -25,6 +36,8 @@ export function remapInjectedContextFilesToWorkspace(params: { } return params.files.map((file) => { const relative = path.relative(params.sourceWorkspaceDir, file.path); + // Only files that were inside the source workspace can be safely projected + // into the target workspace. const canRemap = isRelativePathInsideOrEqual(relative); return canRemap ? { diff --git a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts index 7ff6a7eeb278..30707958b8c2 100644 --- a/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts @@ -18,6 +18,12 @@ export type AttemptBootstrapContext(params: { contextInjectionMode: "always" | "continuation-skip" | "never"; bootstrapContextMode?: string; @@ -39,6 +45,8 @@ export async function resolveAttemptBootstrapContext 0 ? promptCache : undefined; } +/** + * Finds the assistant message produced by the current attempt, ignoring + * historical messages that were present before prompt submission. + */ export function findCurrentAttemptAssistantMessage(params: { messagesSnapshot: AgentMessage[]; prePromptMessageCount: number; @@ -126,7 +144,11 @@ function parsePromptCacheTouchTimestamp(value: unknown): number | null { return null; } -/** Resolve the effective prompt-cache touch timestamp for the current assistant turn. */ +/** + * Resolves the effective prompt-cache touch timestamp for the current assistant + * turn. Cache-read/write usage is required before an assistant timestamp can + * advance the touch time; otherwise the previous touch is carried forward. + */ export function resolvePromptCacheTouchTimestamp(params: { lastCallUsage?: NormalizedUsage; assistantTimestamp?: unknown; @@ -145,6 +167,11 @@ export function resolvePromptCacheTouchTimestamp(params: { ); } +/** + * Derives prompt-cache metadata from the loop transcript snapshot after a model + * attempt finishes. It combines the current attempt assistant usage with the + * carried-forward touch timestamp from earlier attempts. + */ export function buildLoopPromptCacheInfo(params: { messagesSnapshot: AgentMessage[]; prePromptMessageCount: number; @@ -155,6 +182,8 @@ export function buildLoopPromptCacheInfo(params: { messagesSnapshot: params.messagesSnapshot, prePromptMessageCount: params.prePromptMessageCount, }); + // Normalize only the assistant produced by this attempt so older transcript + // usage does not masquerade as a fresh cache touch. const lastCallUsage = normalizeUsage(currentAttemptAssistant?.usage); return buildContextEnginePromptCacheInfo({ diff --git a/src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts b/src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts index ebd4ef0318cb..90d962f25429 100644 --- a/src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts +++ b/src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts @@ -6,6 +6,11 @@ import { normalizeAssistantReplayContent } from "../replay-history.js"; import { markTranscriptPromptText } from "../tool-result-context-guard.js"; import type { RuntimeContextCustomMessage } from "./runtime-context-prompt.js"; +/** + * Removes transcript-only metadata before messages cross into provider prompt + * conversion. The LLM boundary should see replay-safe assistant content, compact + * tool results, and only the active inbound metadata. + */ export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] { const normalized = stripUnsafeBlockedRunMetadata( stripToolResultDetails(normalizeAssistantReplayContent(messages)), @@ -15,6 +20,7 @@ export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): Agent return stripHistoricalRuntimeContextCustomMessages(withoutHistoricalInboundMetadata); } +/** Normalizes existing transcript messages as if the current prompt were appended last. */ export function normalizeMessagesForCurrentPromptBoundary(params: { messages: AgentMessage[]; prompt: string; @@ -27,6 +33,11 @@ export function normalizeMessagesForCurrentPromptBoundary(params: { return normalizeMessagesForLlmBoundary([...params.messages, promptMessage]).slice(0, -1); } +/** + * Temporarily injects a runtime-context message for prompt conversion and retry. + * Cleanup restores the original continuation hook and removes only the injected + * message object. + */ export function installRuntimeContextMessageForPrompt(params: { session: { messages: AgentMessage[]; @@ -86,6 +97,11 @@ function appendRuntimeContextMessageForPrompt(params: { return [...params.messages, params.message]; } +/** + * Inserts runtime context before the active user turn on retry. Overflow rebuilds + * can rehydrate a transcript ending in tool-call messages, so the active prompt + * is found by walking backward through tool-call assistants. + */ export function insertRuntimeContextMessageForPrompt(params: { message: RuntimeContextCustomMessage; messages: AgentMessage[]; @@ -174,6 +190,10 @@ function composeModelPromptContext(params: { .join("\n\n"); } +/** + * Temporarily rewrites only the active user prompt for model submission while + * preserving the transcript prompt text for repair/guard metadata. + */ export function installModelPromptTransform(params: { session: { agent: { @@ -317,6 +337,9 @@ function stripUnsafeBlockedRunMetadata(messages: AgentMessage[]): AgentMessage[] } function findActiveUserMessageIndex(messages: AgentMessage[]): number { + // A prompt turn may be followed by assistant tool-call scaffolding during + // retry reconstruction. A normal assistant reply means the latest user turn is + // historical, not the active prompt boundary. for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (!message) { diff --git a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts index 2ab1dbc5ab7e..a88bfb8da74e 100644 --- a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts +++ b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts @@ -508,6 +508,8 @@ async function safeReturnIterator(iterator: AsyncIterator): Promise | undefined; try { + // Early consumer return should not hang diagnostic completion forever; give + // provider cleanup a short chance, then emit completion for the observed call. await Promise.race([ Promise.resolve(returnResult).catch(() => undefined), new Promise((resolve) => { @@ -553,6 +555,8 @@ async function* observeModelCallIterator( throw err; } finally { if (!terminalEmitted) { + // A consumer can stop reading before the provider emits done/error. Close + // the iterator best-effort and record the call as completed with observed bytes. await safeReturnIterator(iterator); emitModelCallCompleted(eventBase, startedAt, state); } @@ -611,6 +615,11 @@ function observeModelCallResult( return result; } +/** + * Wraps a model stream function with diagnostic model-call lifecycle events, + * traceparent propagation, request/response byte accounting, optional captured + * model content, progress heartbeats, and plugin hook dispatch. + */ export function wrapStreamFnWithDiagnosticModelCallEvents( streamFn: StreamFn, ctx: ModelCallDiagnosticContext, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 3b5c30889f56..ea673a2bcf6b 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -93,6 +93,11 @@ export function forgetPromptBuildDrainCacheForRun(runId: string | undefined): vo } } +/** + * Resolves prompt-build hook contributions for one attempt. Next-turn + * injections are drained once per run and cached for retries so destructive + * session-store reads do not lose plugin context after a failed first attempt. + */ export async function resolvePromptBuildHookResult(params: { config: OpenClawConfig; prompt: string; @@ -115,6 +120,8 @@ export async function resolvePromptBuildHookResult(params: { if (runId && !cachedInjections) { rememberDrainedInjections(runId, queuedContext.queuedInjections); } + // Hook ordering mirrors the prompt assembly boundary: queued injections first, + // then prepare/heartbeat contributions, then prompt-build and legacy start hooks. const turnPrepareResult = params.hookRunner?.runAgentTurnPrepare && params.hookRunner.hasHooks("agent_turn_prepare") ? await params.hookRunner @@ -215,6 +222,11 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f return isSubagentSessionKey(sessionKey) || isCronSessionKey(sessionKey) ? "minimal" : "full"; } +/** + * Determines whether the default agent's heartbeat run should include the + * heartbeat prompt contribution. Non-default agents and non-heartbeat triggers + * keep their normal prompt shape. + */ export function shouldInjectHeartbeatPrompt(params: { config?: OpenClawConfig; agentId?: string; @@ -235,6 +247,7 @@ export function shouldInjectHeartbeatPrompt(params: { ); } +/** User-visible runs warn when transcript repair had to merge an orphaned user turn. */ export function shouldWarnOnOrphanedUserRepair( trigger: EmbeddedRunAttemptParams["trigger"], ): boolean { @@ -243,6 +256,11 @@ export function shouldWarnOnOrphanedUserRepair( export type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images"; +/** + * Distinguishes a truly empty prompt/history from a blank follow-up in a visible + * conversation. This lets callers skip model submission while reporting the + * reason accurately. + */ export function resolvePromptSubmissionSkipReason(params: { prompt: string; messages: readonly unknown[]; @@ -463,6 +481,11 @@ function promptAlreadyIncludesQueuedUserMessage(prompt: string, orphanText: stri ); } +/** + * Merges a trailing user message that was queued in transcript history but not + * present in the active prompt. The leaf is removed whether merged or already + * present so the transcript cannot submit the same user turn twice. + */ export function mergeOrphanedTrailingUserPrompt(params: { prompt: string; trigger: EmbeddedRunAttemptParams["trigger"]; diff --git a/src/agents/embedded-agent-runner/run/attempt.queue-message.ts b/src/agents/embedded-agent-runner/run/attempt.queue-message.ts index 88269f9491a1..7f318b75b842 100644 --- a/src/agents/embedded-agent-runner/run/attempt.queue-message.ts +++ b/src/agents/embedded-agent-runner/run/attempt.queue-message.ts @@ -1,5 +1,9 @@ import { log } from "../logger.js"; +/** + * Minimal active-session surface needed to steer a running attempt and observe + * whether the queued user message reached the transcript. + */ export type EmbeddedAgentActiveSessionSteerTarget = { agent?: unknown; getSteeringMessages?(): readonly string[]; @@ -7,6 +11,7 @@ export type EmbeddedAgentActiveSessionSteerTarget = { subscribe(listener: (event: unknown) => void): () => void; }; +/** Default wait for a steered user message to appear in the active transcript. */ export const DEFAULT_QUEUE_TRANSCRIPT_COMMIT_TIMEOUT_MS = 120_000; function extractQueuedUserMessageText(message: unknown): string | undefined { @@ -76,6 +81,11 @@ function getAgentSteeringQueueMessages(agent: unknown): unknown[] | undefined { return Array.isArray(messages) ? messages : undefined; } +/** + * Removes one pending steered user message from both the runtime queue and UI + * steering list. This targets the exact text so unrelated queued messages keep + * their payloads and ordering. + */ export async function cancelQueuedSteeringMessage( activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, @@ -103,6 +113,11 @@ export async function cancelQueuedSteeringMessage( return true; } +/** + * Sends a steering message and resolves only after the matching user + * `message_end` event appears. If the run ends or times out first, the pending + * queue entry is removed so an abandoned steer does not leak into a later turn. + */ export async function steerAndWaitForTranscriptCommit( activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, @@ -130,6 +145,8 @@ export async function steerAndWaitForTranscriptCommit( resolve(); }; const rejectAfterCancellation = (message: string) => { + // Cancellation is best-effort but must finish before rejecting so callers + // do not return while a stale queued message can leak into the next turn. void cancelQueuedSteeringMessage(activeSession, text) .then((removed) => { if (!removed) { @@ -166,6 +183,8 @@ export async function steerAndWaitForTranscriptCommit( timer.unref?.(); const unsubscribe: (() => void) | undefined = activeSession.subscribe((event) => { if (isAutoRetryStartEvent(event) || isCompactionStartEvent(event)) { + // Continuation events prove the run is still alive under a new attempt, + // so keep waiting for the queued user message to drain. if (terminalTimer) { clearTimeout(terminalTimer); terminalTimer = undefined; @@ -189,6 +208,10 @@ export async function steerAndWaitForTranscriptCommit( }); } +/** + * Steers the active session directly or waits for transcript commitment when a + * caller needs delivery proof before returning. + */ export async function steerActiveSessionWithOptionalDeliveryWait( activeSession: EmbeddedAgentActiveSessionSteerTarget, text: string, diff --git a/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts b/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts index 405184ac5995..a4b5bbe6497b 100644 --- a/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts +++ b/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts @@ -6,6 +6,11 @@ import { import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; +/** + * Builds the session write-lock timing for a live embedded attempt. The lock is + * capped by compaction time because cleanup may keep writing after model abort, + * but should not inherit the much larger full run timeout. + */ export function resolveEmbeddedAttemptSessionWriteLockOptions(params: { config?: OpenClawConfig; compactionTimeoutMs: number; @@ -22,12 +27,22 @@ export function resolveEmbeddedAttemptSessionWriteLockOptions(params: { }); } +/** + * Returns the auth profile id that should be attached to model-stream + * provenance. Only runtime-forwarded ids are exposed; raw request auth ids can + * represent local caller state rather than provider-visible credentials. + */ export function resolveAttemptStreamAuthProfileId( params: Pick, ): string | undefined { return params.runtimePlan?.auth.forwardedAuthProfileId; } +/** + * Resolves the consecutive unknown-tool threshold for the provider stream + * guard. The guard remains active even when generic loop detection is disabled + * because an unregistered tool call is an objective dead end for this run. + */ export function resolveUnknownToolGuardThreshold(loopDetection?: { enabled?: boolean; unknownToolThreshold?: number; @@ -48,10 +63,20 @@ export function resolveUnknownToolGuardThreshold(loopDetection?: { return UNKNOWN_TOOL_THRESHOLD; } +/** + * Skips `llm_output` hooks only when `before_agent_run` blocked the prompt + * before any model submission; later prompt errors can still have model output + * or tool state that downstream hooks need to observe. + */ export function shouldRunLlmOutputHooksForAttempt(params: { promptErrorSource: string | null }) { return params.promptErrorSource !== "hook:before_agent_run"; } +/** + * Chooses the provider label used by tool-policy messages. Message providers + * are more specific than transport channels, while channel remains the fallback + * for older callers that do not split those concepts. + */ export function resolveAttemptToolPolicyMessageProvider(params: { messageProvider?: string; messageChannel?: string; diff --git a/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts index bb0d2801e7b1..955226b40f75 100644 --- a/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts +++ b/src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts @@ -111,6 +111,9 @@ function wrapStreamHandleUnhandledStopReason( if (!normalizedMessage) { throw err; } + // The provider stream failed before yielding a terminal event. Emit a + // synthetic error event once so callers still receive a normal stream + // shape and iterator completion. emittedSyntheticTerminal = true; return { done: false as const, @@ -135,6 +138,11 @@ function wrapStreamHandleUnhandledStopReason( return stream; } +/** + * Wraps provider streams so raw "Unhandled stop reason" failures are rewritten + * into stable error messages. Recovery covers synchronous creation failures, + * async stream creation failures, iterator errors, and `result()` errors. + */ export function wrapStreamFnHandleSensitiveStopReason(baseFn: StreamFn): StreamFn { return (model, context, options) => { try { diff --git a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts index db5fde09410b..83159795dc5f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts @@ -2,6 +2,7 @@ import type { SubscribeEmbeddedAgentSessionParams } from "../../embedded-agent-s import { log } from "../logger.js"; import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js"; +/** Shared timeout for waiting on aborted model/prompt cleanup before releasing resources. */ export const EMBEDDED_ABORT_SETTLE_TIMEOUT_MS = resolveEmbeddedAbortSettleTimeoutMs(); type IdleAwareAgent = { @@ -23,6 +24,8 @@ async function waitForEmbeddedAbortSettle(params: { } let timeout: NodeJS.Timeout | undefined; + // Abort settlement is advisory cleanup; timeout or errors are logged but do + // not block releasing the session write-lock. const outcome = await Promise.race([ params.promise .then(() => "settled" as const) @@ -46,12 +49,23 @@ async function waitForEmbeddedAbortSettle(params: { } } +/** + * Identity helper that preserves the concrete subscription params type at call + * sites. Keeping this as a named helper lets tests assert the exact shape passed + * into the subscription layer without widening the object inline. + */ export function buildEmbeddedSubscriptionParams( params: SubscribeEmbeddedAgentSessionParams, ): SubscribeEmbeddedAgentSessionParams { return params; } +/** + * Tears down per-attempt resources in lock-safe order: remove guards, settle + * aborted prompts, flush tool results, release the session lock, then dispose + * runtimes. Lock release errors are reported after best-effort disposal so a + * failed lock does not leak spawned runtimes. + */ export async function cleanupEmbeddedAttemptResources(params: { removeToolResultContextGuard?: () => void; flushPendingToolResultsAfterIdle: (params: { @@ -100,6 +114,8 @@ export async function cleanupEmbeddedAttemptResources(params: { } } finally { try { + // Release the write-lock before disposing runtimes so another attempt can + // recover even if runtime disposal stalls or throws. await params.sessionLock.release(); } catch (err) { sessionLockReleaseError = err; diff --git a/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts index ef87fa03fc33..d7fcd01166eb 100644 --- a/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts @@ -2,8 +2,14 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { normalizeStructuredPromptSection } from "../../prompt-cache-stability.js"; +/** Custom transcript marker used to preserve cache-TTL pruning state across attempts. */ export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl"; +/** + * Combines hook-provided system context with the base prompt while preserving + * stable structured-section bytes. Returning undefined when hooks add nothing + * lets callers avoid rewriting the original prompt. + */ export function composeSystemPromptWithHookContext(params: { baseSystemPrompt?: string; prependSystemContext?: string; @@ -25,6 +31,11 @@ export function composeSystemPromptWithHookContext(params: { }); } +/** + * Returns the workspace path that must be mounted for sandboxed spawn attempts. + * Read-only sandbox modes need the resolved workspace explicitly; full rw + * access uses the normal workspace wiring. + */ export function resolveAttemptSpawnWorkspaceDir(params: { sandbox?: { enabled?: boolean; @@ -37,6 +48,11 @@ export function resolveAttemptSpawnWorkspaceDir(params: { : undefined; } +/** + * Determines whether this attempt should append a cache-TTL marker. Compaction + * and timeout attempts skip the marker because their transcript boundary is + * already being rewritten. + */ function shouldAppendAttemptCacheTtl(params: { timedOutDuringCompaction: boolean; compactionOccurredThisAttempt: boolean; @@ -55,6 +71,11 @@ function shouldAppendAttemptCacheTtl(params: { ); } +/** + * Appends the cache-TTL transcript marker when context-pruning policy and model + * eligibility both allow it. The boolean result tells callers whether the + * session transcript changed. + */ export function appendAttemptCacheTtlIfNeeded(params: { sessionManager: { appendCustomEntry?: (customType: string, data: unknown) => void; @@ -79,6 +100,10 @@ export function appendAttemptCacheTtlIfNeeded(params: { return true; } +/** + * Records completed bootstrap turns only after a clean, non-compaction attempt. + * Failed, aborted, or compaction-mutated turns are not stable bootstrap history. + */ export function shouldPersistCompletedBootstrapTurn(params: { shouldRecordCompletedBootstrapTurn: boolean; promptError: unknown; diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts index 9012eecff5cb..66682b105ab3 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { normalizeLowercaseStringOrEmpty } from "../../../../packages/normalization-core/src/string-coerce.js"; +import { normalizeStringEntries } from "../../../../packages/normalization-core/src/string-normalization.js"; import { extractStandalonePlainTextToolCallText, normalizePlainTextToolCallStreamEvents, @@ -8,8 +10,6 @@ import { type PlainTextToolCallNameMatcher, } from "../../../../packages/tool-call-repair/src/index.js"; import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js"; -import { normalizeLowercaseStringOrEmpty } from "../../../../packages/normalization-core/src/string-coerce.js"; -import { normalizeStringEntries } from "../../../../packages/normalization-core/src/string-normalization.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, @@ -827,6 +827,8 @@ function guardUnknownToolLoopInMessage( const unknownToolName = toolCallState.toolName; if (!params.countAttempt) { + // Partial stream events can rewrite after the threshold, but only final + // messages advance the loop counter. if (state.lastUnknownToolName === unknownToolName && state.count > threshold) { rewriteUnknownToolLoopMessage(message, unknownToolName); } @@ -1036,6 +1038,7 @@ function wrapStreamPromoteStandaloneTextToolCalls( return stream; } +/** Promotes standalone plain-text tool-call replies into structured toolCall blocks when safe. */ export function wrapStreamFnPromoteStandaloneTextToolCalls( baseFn: StreamFn, allowedToolNames?: Set, @@ -1105,6 +1108,7 @@ function wrapStreamTrimToolCallNames( return stream; } +/** Normalizes streamed tool-call names and guards repeated unknown-tool loops. */ export function wrapStreamFnTrimToolCallNames( baseFn: StreamFn, allowedToolNames?: Set, @@ -1137,6 +1141,7 @@ type ReplayToolCallIdSanitizerDecision = { isOpenAIResponsesApi: boolean; }; +/** Returns whether replayed tool-call ids should be sanitized for non-Responses providers. */ export function shouldApplyReplayToolCallIdSanitizer( params: ReplayToolCallIdSanitizerDecision, ): params is ReplayToolCallIdSanitizerDecision & { toolCallIdMode: ToolCallIdMode } { @@ -1145,6 +1150,7 @@ export function shouldApplyReplayToolCallIdSanitizer( ); } +/** Rewrites replayed tool-call ids into provider-safe ids and optionally repairs result pairing. */ export function sanitizeReplayToolCallIdsForStream(params: { messages: AgentMessage[]; mode: ToolCallIdMode; @@ -1164,12 +1170,19 @@ export function sanitizeReplayToolCallIdsForStream(params: { return sanitizeToolUseResultPairing(sanitized); } +/** Downgrades OpenAI Responses replay turns into the stream format expected by runtime callers. */ export function sanitizeOpenAIResponsesReplayForStream(messages: AgentMessage[]): AgentMessage[] { return downgradeOpenAIFunctionCallReasoningPairs( normalizeOpenAIResponsesToolCallIds(downgradeOpenAIReasoningBlocks(messages)), ); } +/** + * Sanitizes malformed replay tool calls before provider submission. The wrapper + * drops invalid assistant tool calls, repairs adjacent tool results when needed, + * strips trailing assistant prefill turns for strict providers, and revalidates + * Anthropic/Gemini transcripts after mutations. + */ export function wrapStreamFnSanitizeMalformedToolCalls( baseFn: StreamFn, allowedToolNames?: Set, diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-search-run-plan.ts b/src/agents/embedded-agent-runner/run/attempt.tool-search-run-plan.ts index 4b2addbb4fdf..9cc692738fd6 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-search-run-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-search-run-plan.ts @@ -7,6 +7,7 @@ import { } from "../../tool-search.js"; import { collectAllowedToolNames } from "../tool-name-allowlist.js"; +/** Tool-search control tools that may be auto-added when tool search is enabled. */ export const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [ TOOL_SEARCH_CODE_MODE_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, @@ -16,6 +17,7 @@ export const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [ type CollectAllowedToolNamesParams = Parameters[0]; +/** Derived tool allowlists used for visible prompt tools, replay tools, and empty-allowlist checks. */ export type ToolSearchRunPlan = { visibleAllowedToolNames: Set; replayAllowedToolNames: Set; @@ -23,6 +25,11 @@ export type ToolSearchRunPlan = { emptyAllowlistCallableNames: string[]; }; +/** + * Builds the callable-name list used to decide whether an allowlist is empty. + * Auto-added tool-search controls are excluded so they do not make an otherwise + * empty user/tool allowlist look populated. + */ export function buildCallableToolNamesForEmptyAllowlistCheck(params: { effectiveToolNames: string[]; autoAddedToolSearchControlNames?: Set; @@ -39,6 +46,11 @@ export function buildCallableToolNamesForEmptyAllowlistCheck(params: { ]; } +/** + * Identifies tool-search control names that were added by policy rather than + * explicitly allowed by the user. Explicit controls stay visible to empty + * allowlist checks because the user selected them. + */ export function buildAutoAddedToolSearchControlNamesForAllowlistCheck(params: { toolSearchControlsEnabled: boolean; explicitAllowlistSources: Array<{ entries: string[] }>; @@ -74,6 +86,11 @@ function collectExplicitlyAllowedClientToolNames(params: { .filter((name) => explicitNames.has(normalizeToolName(name))); } +/** + * Builds the complete tool-search allowlist plan for one run. Visible tools use + * compacted prompt state, replay tools use uncompacted state, and catalog-backed + * client tools are represented through synthetic tool-search callable names. + */ export function buildToolSearchRunPlan(params: { visibleTools: CollectAllowedToolNamesParams["tools"]; uncompactedTools: CollectAllowedToolNamesParams["tools"]; @@ -93,6 +110,8 @@ export function buildToolSearchRunPlan(params: { clientTools: params.clientTools, }); if (params.controlsEnabled) { + // A control that was visible in the compacted prompt must remain allowed + // during replay even when the uncompacted tool set would otherwise omit it. for (const controlName of params.controlNames ?? TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES) { if (visibleAllowedToolNames.has(controlName)) { replayAllowedToolNames.add(controlName); diff --git a/src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts b/src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts index 903071020108..1696d168fcdc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts +++ b/src/agents/embedded-agent-runner/run/attempt.transcript-policy.ts @@ -7,12 +7,21 @@ export type AttemptRuntimeModelContext = NonNullable< Parameters[0] >; +/** + * Adapts the RuntimePlan model context to the legacy provider-runtime model + * shape used by transcript-policy fallbacks. + */ function asProviderRuntimeModel( model: AttemptRuntimeModelContext["model"], ): ProviderRuntimeModel | undefined { return typeof model?.id === "string" ? (model as ProviderRuntimeModel) : undefined; } +/** + * Resolves the transcript policy for an embedded attempt. RuntimePlan owns the + * policy when present; otherwise the older provider/config/env resolver remains + * the compatibility path for callers that have not produced a runtime plan yet. + */ export function resolveAttemptTranscriptPolicy(params: { runtimePlan?: AgentRuntimePlan; runtimePlanModelContext: AttemptRuntimeModelContext; diff --git a/src/agents/embedded-agent-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts index ca4fee33edbf..77558162468d 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -45,6 +45,11 @@ type LogLike = { warn(message: string): void; }; +/** + * Coordinates auth profile selection, runtime auth preparation/refresh, and + * profile failover for one embedded run. State is injected through accessors so + * the runner can keep provider/model/auth snapshots in sync across retries. + */ export function createEmbeddedRunAuthController(params: { config: RunEmbeddedAgentParams["config"]; agentDir: string; @@ -99,6 +104,8 @@ export function createEmbeddedRunAuthController(params: { capability: "llm", transport: "stream", }); + // Runtime auth plugins may override baseUrl and safe request auth headers, + // but sanitizeRuntimeProviderRequestOverrides strips privileged transport knobs. params.setRuntimeModel({ ...paramsForApply.runtimeModel, ...(paramsForApply.preparedAuth.baseUrl @@ -171,6 +178,8 @@ export function createEmbeddedRunAuthController(params: { await runtimeAuthState.refreshInFlight; return; } + // Generation/profile/source checks below discard refreshes that complete + // after another profile or credential has already become active. const refreshGeneration = runtimeAuthState.generation; const refreshProfileId = runtimeAuthState.profileId; const refreshPromise: Promise = (async () => { diff --git a/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts index d01fed1a5fc4..fd29e72c1826 100644 --- a/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts +++ b/src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts @@ -2,6 +2,12 @@ import type { AuthProfileFailureReason } from "../../auth-profiles/types.js"; import type { FailoverReason } from "../../embedded-agent-helpers/types.js"; import type { AuthProfileFailurePolicy } from "./auth-profile-failure-policy.types.js"; +/** + * Returns the subset of failover reasons that should affect shared auth-profile + * health. Local helper failures and request-shape/transport outcomes stay + * session-local so one bad transcript or connection does not cool down an + * otherwise healthy provider profile. + */ export function resolveAuthProfileFailureReason(params: { failoverReason: FailoverReason | null; providerStarted?: boolean; diff --git a/src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts b/src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts index e92800d7b41d..2965c64ff51d 100644 --- a/src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts +++ b/src/agents/embedded-agent-runner/run/codex-app-server-recovery.ts @@ -1,5 +1,10 @@ import type { EmbeddedRunAttemptResult } from "./types.js"; +/** + * Decides whether a Codex app-server failure can be retried by replaying the + * same turn. The retry is intentionally narrow: stdio-only, replay-safe, once + * per run, and only before any assistant/tool/item side effects escape. + */ export function resolveCodexAppServerRecoveryRetry(params: { attempt: EmbeddedRunAttemptResult; alreadyRetried: boolean; @@ -49,4 +54,9 @@ export function resolveCodexAppServerRecoveryRetry(params: { return { retry: true }; } +/** + * Backward-compatible name for the original client-close retry decision. The + * resolver now also handles completion idle timeouts under the same replay-safe + * side-effect gate. + */ export const resolveCodexAppServerClientCloseRetry = resolveCodexAppServerRecoveryRetry; diff --git a/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts b/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts index ed6b5d054c32..435e5083d5e2 100644 --- a/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts +++ b/src/agents/embedded-agent-runner/run/compaction-retry-aggregate-timeout.ts @@ -1,9 +1,9 @@ -/** - * Wait for compaction retry completion with an aggregate timeout to avoid - * holding a session lane indefinitely when retry resolution is lost. - */ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +/** + * Waits for compaction retry completion with an aggregate timeout so a lost + * retry resolution cannot hold the session lane indefinitely. + */ export async function waitForCompactionRetryWithAggregateTimeout(params: { waitForCompactionRetry: () => Promise; abortable: (promise: Promise) => Promise; diff --git a/src/agents/embedded-agent-runner/run/compaction-timeout.ts b/src/agents/embedded-agent-runner/run/compaction-timeout.ts index 834789a54be0..2f660951178b 100644 --- a/src/agents/embedded-agent-runner/run/compaction-timeout.ts +++ b/src/agents/embedded-agent-runner/run/compaction-timeout.ts @@ -1,11 +1,13 @@ import type { AgentMessage } from "../../runtime/index.js"; +/** Timeout state used to distinguish normal run deadlines from compaction stalls. */ export type CompactionTimeoutSignal = { isTimeout: boolean; isCompactionPendingOrRetrying: boolean; isCompactionInFlight: boolean; }; +/** Flags only run-timeout events that overlap pending, retrying, or active compaction work. */ export function shouldFlagCompactionTimeout(signal: CompactionTimeoutSignal): boolean { if (!signal.isTimeout) { return false; @@ -13,6 +15,11 @@ export function shouldFlagCompactionTimeout(signal: CompactionTimeoutSignal): bo return signal.isCompactionPendingOrRetrying || signal.isCompactionInFlight; } +/** + * Grants a single timeout grace window when compaction is still responsible for + * the delay. A second timeout, or a timeout unrelated to compaction, aborts the + * run instead of extending indefinitely. + */ export function resolveRunTimeoutDuringCompaction(params: { isCompactionPendingOrRetrying: boolean; isCompactionInFlight: boolean; @@ -24,6 +31,7 @@ export function resolveRunTimeoutDuringCompaction(params: { return params.graceAlreadyUsed ? "abort" : "extend"; } +/** Effective run timeout after adding the one-time compaction grace budget. */ export function resolveRunTimeoutWithCompactionGraceMs(params: { runTimeoutMs: number; compactionTimeoutMs: number; @@ -31,6 +39,7 @@ export function resolveRunTimeoutWithCompactionGraceMs(params: { return params.runTimeoutMs + params.compactionTimeoutMs; } +/** Candidate transcript snapshots available when a timeout fires during compaction. */ export type SnapshotSelectionParams = { timedOutDuringCompaction: boolean; preCompactionSnapshot: AgentMessage[] | null; @@ -39,6 +48,7 @@ export type SnapshotSelectionParams = { currentSessionId: string; }; +/** Snapshot chosen for retry/replay after a compaction-related timeout. */ export type SnapshotSelection = { messagesSnapshot: AgentMessage[]; sessionIdUsed: string; @@ -60,6 +70,9 @@ function canContinueFromMessage(message: AgentMessage | undefined): boolean { } } +// Drop trailing assistant/tool-call-only fragments before retrying. Those tails +// are not safe continuation points because replay could resume after an +// incomplete action instead of a user, tool-result, or summary boundary. function trimToContinuableTail(messages: AgentMessage[]): AgentMessage[] | null { let end = messages.length; while (end > 0 && !canContinueFromMessage(messages[end - 1])) { @@ -68,6 +81,11 @@ function trimToContinuableTail(messages: AgentMessage[]): AgentMessage[] | null return end > 0 ? messages.slice(0, end) : null; } +/** + * Selects the transcript snapshot used after a compaction timeout. Prefer the + * pre-compaction view when it can be continued cleanly; otherwise fall back to a + * trimmed current snapshot so retry does not replay past an unsafe tail. + */ export function selectCompactionTimeoutSnapshot( params: SnapshotSelectionParams, ): SnapshotSelection { diff --git a/src/agents/embedded-agent-runner/run/failover-observation.ts b/src/agents/embedded-agent-runner/run/failover-observation.ts index b5c3cbbeaf74..b319093c1dfb 100644 --- a/src/agents/embedded-agent-runner/run/failover-observation.ts +++ b/src/agents/embedded-agent-runner/run/failover-observation.ts @@ -8,6 +8,7 @@ import { import type { FailoverReason } from "../../embedded-agent-helpers.js"; import { log } from "../logger.js"; +/** Structured fields emitted whenever embedded run failover chooses an action. */ export type FailoverDecisionLoggerInput = { stage: "prompt" | "assistant"; decision: "rotate_profile" | "fallback_model" | "surface_error"; @@ -26,8 +27,13 @@ export type FailoverDecisionLoggerInput = { status?: number; }; +/** Stable context captured before a concrete failover decision is known. */ export type FailoverDecisionLoggerBase = Omit; +/** + * Derives timeout failure reasons for logs that were built from timeout state + * before the normal provider error classifier had a raw error to inspect. + */ export function normalizeFailoverDecisionObservationBase( base: FailoverDecisionLoggerBase, ): FailoverDecisionLoggerBase { @@ -38,6 +44,11 @@ export function normalizeFailoverDecisionObservationBase( }; } +/** + * Captures sanitized failover context and returns a decision logger. The closure + * keeps prompt/assistant failover branches consistent while still allowing the + * final decision and HTTP status to be supplied at the action point. + */ export function createFailoverDecisionLogger( base: FailoverDecisionLoggerBase, ): ( @@ -59,6 +70,9 @@ export function createFailoverDecisionLogger( return (decision, extra) => { const observedError = buildApiErrorObservationFields(normalizedBase.rawError); const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview); + // Some provider/runtime failure kinds already have normalized detail fields. + // Repeating the raw suffix there makes the console line noisier without + // adding actionable failover evidence. const rawErrorConsoleSuffix = safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix(observedError.providerRuntimeFailureKind) diff --git a/src/agents/embedded-agent-runner/run/failover-policy.ts b/src/agents/embedded-agent-runner/run/failover-policy.ts index e96db12e8c05..69d05a55c437 100644 --- a/src/agents/embedded-agent-runner/run/failover-policy.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.ts @@ -1,5 +1,6 @@ import type { FailoverReason } from "../../embedded-agent-helpers.js"; +/** Failover action selected for one embedded run failure decision point. */ export type RunFailoverDecision = | { action: "continue_normal"; @@ -132,6 +133,7 @@ function assistantFallbackReason(params: AssistantDecisionParams): FailoverReaso return isAssistantTimeoutFailure(params) ? "timeout" : (failoverReason ?? "unknown"); } +/** Preserves an existing retry reason unless the current attempt produced a stronger signal. */ export function mergeRetryFailoverReason(params: { previous: FailoverReason | null; failoverReason: FailoverReason | null; @@ -147,6 +149,11 @@ export function resolveRunFailoverDecision(params: PromptDecisionParams): Prompt export function resolveRunFailoverDecision( params: AssistantDecisionParams, ): AssistantFailoverDecision; +/** + * Chooses whether a run should rotate auth profile, switch model fallback, + * surface the error, continue normally, or return an error payload. Prompt, + * assistant, and retry-limit stages intentionally use different action sets. + */ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): RunFailoverDecision { if (params.stage === "retry_limit") { if (params.fallbackConfigured && shouldEscalateRetryLimit(params.failoverReason)) { diff --git a/src/agents/embedded-agent-runner/run/fallbacks.ts b/src/agents/embedded-agent-runner/run/fallbacks.ts index 9e668c9f5b4f..a1b53a050659 100644 --- a/src/agents/embedded-agent-runner/run/fallbacks.ts +++ b/src/agents/embedded-agent-runner/run/fallbacks.ts @@ -1,12 +1,18 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { hasConfiguredModelFallbacks } from "../../agent-scope.js"; +/** + * Resolves whether this embedded run has any model fallback path available. + * Per-run overrides are authoritative so compaction/replay callers can force + * either a fallback lane or a no-fallback lane independent of agent defaults. + */ export function hasEmbeddedRunConfiguredModelFallbacks(params: { cfg: OpenClawConfig | undefined; agentId?: string | null; sessionKey?: string | null; modelFallbacksOverride?: string[]; }): boolean { + // An explicit empty override disables fallbacks even when config has defaults. if (params.modelFallbacksOverride !== undefined) { return params.modelFallbacksOverride.length > 0; } diff --git a/src/agents/embedded-agent-runner/run/history-image-prune.ts b/src/agents/embedded-agent-runner/run/history-image-prune.ts index 3561be9abb02..f5d3777542a1 100644 --- a/src/agents/embedded-agent-runner/run/history-image-prune.ts +++ b/src/agents/embedded-agent-runner/run/history-image-prune.ts @@ -1,6 +1,9 @@ import type { AgentMessage } from "../../runtime/index.js"; +/** Replacement text for old image blocks that were already available to the model. */ export const PRUNED_HISTORY_IMAGE_MARKER = "[image data removed - already processed by model]"; + +/** Replacement text for old textual media references that would otherwise be reloaded. */ export const PRUNED_HISTORY_MEDIA_REFERENCE_MARKER = "[media reference removed - already processed by model]"; @@ -148,6 +151,7 @@ export function pruneProcessedHistoryImages(messages: AgentMessage[]): AgentMess return prunedMessages; } +/** Installs an agent context transform that prunes old image/media history before model input. */ export function installHistoryImagePruneContextTransform(agent: PrunableContextAgent): () => void { const originalTransformContext = agent.transformContext; agent.transformContext = async (messages: AgentMessage[], signal?: AbortSignal) => { diff --git a/src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts b/src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts index a1584a0a3d17..425b31099fcc 100644 --- a/src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts +++ b/src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts @@ -15,20 +15,28 @@ */ export const MAX_CONSECUTIVE_IDLE_TIMEOUTS_BEFORE_OUTPUT = 5; +/** Mutable outer-loop state that survives across retry/profile attempts. */ export type IdleTimeoutBreakerState = { consecutiveIdleTimeoutsBeforeOutput: number; }; +/** Creates a fresh breaker counter for one embedded run loop. */ export function createIdleTimeoutBreakerState(): IdleTimeoutBreakerState { return { consecutiveIdleTimeoutsBeforeOutput: 0 }; } +/** + * Summarizes the latest attempt outcome for the idle-timeout breaker. Completed + * model progress means durable text/tool-call progress, not merely billed token + * deltas from a partial stream. + */ export type IdleTimeoutBreakerInput = { idleTimedOut: boolean; completedModelProgress: boolean; outputTokens?: number; }; +/** Result of applying one attempt outcome to the breaker state. */ export type IdleTimeoutBreakerStep = { consecutive: number; tripped: boolean; diff --git a/src/agents/embedded-agent-runner/run/images.ts b/src/agents/embedded-agent-runner/run/images.ts index 00cd8dc03d20..2cd3a51574f5 100644 --- a/src/agents/embedded-agent-runner/run/images.ts +++ b/src/agents/embedded-agent-runner/run/images.ts @@ -112,6 +112,11 @@ function isOpenClawCliImageCachePath(filePath: string): boolean { }); } +/** + * Rebuilds the model image array in the same order the prompt saw them: + * existing inline images and offloaded attachments follow `imageOrder`, then + * explicit prompt path/media refs are appended after attachment-owned images. + */ export function mergePromptAttachmentImages(params: { imageOrder?: PromptImageOrderEntry[]; existingImages?: ImageContent[]; @@ -183,6 +188,10 @@ function consumeRefCount(counts: Map, ref: DetectedImageRef): bo return true; } +/** + * Reads only the leading attachment boilerplate block. User-authored image refs + * after the first blank/non-attachment line must remain prompt refs. + */ function extractLeadingAttachmentPrompt(prompt: string): string { const lines = prompt.split(/\r?\n/); const attachmentLines: string[] = []; @@ -215,6 +224,11 @@ function extractLeadingInlineAttachmentRefs(prompt: string, count: number): Dete return detectImageReferences(attachmentPrompt).slice(0, count); } +/** + * Finds trailing media:// attachment lines produced by claim-check offload. The + * reverse scan stops at the first non-attachment line so prompt text above it is + * not accidentally treated as attachment boilerplate. + */ function extractTrailingAttachmentMediaUris(prompt: string, count: number): string[] { if (count <= 0) { return []; @@ -241,6 +255,11 @@ function extractTrailingAttachmentMediaUris(prompt: string, count: number): stri return uris; } +/** + * Separates image refs that came from attachment boilerplate from refs the user + * actually typed into the prompt. Attachment refs are already represented by + * existing/offloaded image content and should not be loaded a second time. + */ export function splitPromptAndAttachmentRefs(params: { prompt: string; refs: DetectedImageRef[]; @@ -252,6 +271,7 @@ export function splitPromptAndAttachmentRefs(params: { } { const existingImageCount = params.existingImageCount ?? 0; const inlineOrderCount = params.imageOrder?.filter((entry) => entry === "inline").length; + // Inline attachments appear at the front of the prompt and are already present in existingImages. const inlineAttachmentRefCount = Math.min( existingImageCount, inlineOrderCount ?? existingImageCount, @@ -260,6 +280,7 @@ export function splitPromptAndAttachmentRefs(params: { extractLeadingInlineAttachmentRefs(params.prompt, inlineAttachmentRefCount), ); const offloadedCount = params.imageOrder?.filter((entry) => entry === "offloaded").length ?? 0; + // Offloaded claim-check attachments are appended after the prompt and loaded through media://. const attachmentUris = new Set( offloadedCount > 0 ? extractTrailingAttachmentMediaUris(params.prompt, offloadedCount) : [], ); @@ -313,7 +334,7 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] { const refs: DetectedImageRef[] = []; const seen = new Set(); - // Helper to add a path ref + // Dedupe by the user-visible token before resolving so repeated refs keep their first spelling. const addPathRef = (raw: string) => { const trimmed = raw.trim(); const dedupeKey = normalizeRefForDedupe(trimmed); @@ -434,12 +455,10 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] { } /** - * Loads an image from a file path and returns it as ImageContent. - * - * @param ref The detected image reference - * @param workspaceDir The current workspace directory for resolving relative paths - * @param options Optional settings for sandbox and size limits - * @returns The loaded image content, or null if loading failed + * Resolves and loads one detected image ref into model-ready image content. + * Sandbox refs must validate through the bridge; non-sandbox refs can resolve + * media claim-checks and workspace-relative paths before loadWebMedia enforces + * local-root and size limits. */ export async function loadImageFromRef( ref: DetectedImageRef, @@ -454,11 +473,12 @@ export async function loadImageFromRef( try { let targetPath = ref.resolved; + // media:// claim-check refs are resolved only outside sandbox mode; sandbox + // mode validates through resolveSandboxedBridgeMediaPath instead. if (!options?.sandbox) { targetPath = await resolveMediaReferenceLocalPath(targetPath); } - // Resolve paths relative to sandbox or workspace as needed if (options?.sandbox) { try { const resolved = await resolveSandboxedBridgeMediaPath({ @@ -481,7 +501,7 @@ export async function loadImageFromRef( targetPath = path.resolve(workspaceDir, targetPath); } - // loadWebMedia handles local file paths (including file:// URLs) + // loadWebMedia handles local file paths and file:// URLs after the path policy above. const media = options?.sandbox ? await loadWebMedia(targetPath, { maxBytes: options.maxBytes, @@ -513,25 +533,15 @@ export async function loadImageFromRef( } } -/** - * Checks if a model supports image input based on its input capabilities. - * - * @param model The model object with input capability array - * @returns True if the model supports image input - */ +/** Returns whether the resolved model advertises native image input support. */ export function modelSupportsImages(model: { input?: string[] }): boolean { return model.input?.includes("image") ?? false; } /** - * Detects and loads images referenced in a prompt for models with vision capability. - * - * This function scans the prompt for image references (file paths and URLs), - * loads them, and returns them as ImageContent array ready to be passed to - * the model's prompt method. - * - * @param params Configuration for image detection and loading - * @returns Object with loaded images for current prompt only + * Detects, loads, orders, and sanitizes the image payload for one prompt turn. + * Attachment boilerplate is separated from user-authored refs so existing + * inline images and offloaded claim-check images are not loaded twice. */ export async function detectAndLoadPromptImages(params: { prompt: string; @@ -551,7 +561,6 @@ export async function detectAndLoadPromptImages(params: { loadedCount: number; skippedCount: number; }> { - // If model doesn't support images, return empty results if (!modelSupportsImages(params.model)) { return { images: [], @@ -561,7 +570,6 @@ export async function detectAndLoadPromptImages(params: { }; } - // Detect images from current prompt const allRefs = detectImageReferences(params.prompt); if (allRefs.length === 0) { diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn.ts b/src/agents/embedded-agent-runner/run/incomplete-turn.ts index cebb5376cfd8..e5edccb675a3 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn.ts @@ -230,6 +230,11 @@ export type PlanningOnlyPlanDetails = { steps: string[]; }; +/** + * Marks whether retrying the attempt can safely replay the prompt. Mutating + * tools, async work, committed delivery, spawned sessions, and cron writes all + * count as side effects that make blind replay unsafe. + */ export function buildAttemptReplayMetadata( params: ReplayMetadataAttempt, ): EmbeddedRunAttemptResult["replayMetadata"] { @@ -247,12 +252,18 @@ export function buildAttemptReplayMetadata( }; } +/** Falls back to replay-unsafe metadata when older attempt records lack replay details. */ export function resolveAttemptReplayMetadata(attempt: { replayMetadata?: EmbeddedRunAttemptResult["replayMetadata"] | null; }): EmbeddedRunAttemptResult["replayMetadata"] { return attempt.replayMetadata ?? REPLAY_UNSAFE_FALLBACK_METADATA; } +/** + * Builds the user-visible incomplete-turn warning when a terminal attempt did + * not produce a safe final assistant response and no committed delivery/progress + * already completed the task. + */ export function resolveIncompleteTurnPayloadText(params: { payloadCount: number; aborted: boolean; @@ -321,6 +332,11 @@ export function resolveIncompleteTurnPayloadText(params: { : "⚠️ Agent couldn't generate a response. Please try again."; } +/** + * Allows one retry when the provider returned no assistant turn at all and the + * attempt has no side effects, active lifecycle items, delivery, or terminal + * assistant/tool state. + */ export function shouldRetryMissingAssistantTurn(params: { payloadCount: number; aborted: boolean; @@ -437,6 +453,7 @@ function hasTrailingSilentToolResult(messages: readonly AgentMessage[]): boolean return false; } +/** Emits the silent-reply token for cron turns whose last successful tool result is silent. */ export function resolveSilentToolResultReplyPayload(params: { isCronTrigger: boolean; payloadCount: number; @@ -464,6 +481,11 @@ export function resolveSilentToolResultReplyPayload(params: { : null; } +/** + * Marks replay invalid whenever the recorded attempt might not be safe to + * replay or the current run ended in a compaction/incomplete-turn state that + * needs a fresh prompt boundary. + */ export function resolveReplayInvalidFlag(params: { attempt: RunLivenessAttempt; incompleteTurnText?: string | null; @@ -476,6 +498,7 @@ export function resolveReplayInvalidFlag(params: { ); } +/** Classifies the persisted run state used by session recovery and resume logic. */ export function resolveRunLivenessState(params: { payloadCount: number; aborted: boolean; @@ -588,6 +611,7 @@ function shouldSkipPlanningOnlyRetry(params: { ); } +/** Allows configured silent handling for replay-safe empty or reasoning-only assistant turns. */ export function shouldTreatEmptyAssistantReplyAsSilent(params: { allowEmptyAssistantReplyAsSilent?: boolean; payloadCount: number; @@ -607,6 +631,10 @@ export function shouldTreatEmptyAssistantReplyAsSilent(params: { }); } +/** + * Builds the retry instruction for reasoning-only turns that consumed provider + * output budget but produced no visible assistant text. + */ export function resolveReasoningOnlyRetryInstruction(params: { provider?: string; modelId?: string; @@ -645,6 +673,10 @@ export function resolveReasoningOnlyRetryInstruction(params: { return REASONING_ONLY_RETRY_INSTRUCTION; } +/** + * Builds the retry instruction for empty assistant turns when the provider/model + * is eligible for non-visible turn recovery. + */ export function resolveEmptyResponseRetryInstruction(params: { provider?: string; modelId?: string; @@ -762,6 +794,7 @@ function normalizeAckPrompt(text: string): string { return normalizeLowercaseStringOrEmpty(normalized); } +/** Detects short multilingual approval prompts that should continue execution immediately. */ export function isLikelyExecutionAckPrompt(text: string): boolean { const trimmed = text.trim(); if (!trimmed || trimmed.length > 80 || trimmed.includes("\n") || trimmed.includes("?")) { @@ -781,6 +814,7 @@ function isLikelyActionableUserPrompt(text: string): boolean { return ACTIONABLE_PROMPT_DIRECTIVE_RE.test(trimmed) || ACTIONABLE_PROMPT_REQUEST_RE.test(trimmed); } +/** Builds the fast-path execution instruction for short approval prompts like "go ahead". */ export function resolveAckExecutionFastPathInstruction(params: { provider?: string; modelId?: string; @@ -820,6 +854,7 @@ function hasStructuredPlanningOnlyFormat(text: string): boolean { return (hasPlanningHeading && hasPlanningCueLine) || (bulletLineCount >= 2 && hasPlanningCueLine); } +/** Extracts the visible plan text and normalized step list from a plan-only reply. */ export function extractPlanningOnlyPlanDetails(text: string): PlanningOnlyPlanDetails | null { const trimmed = text.trim(); if (!trimmed) { @@ -889,6 +924,7 @@ function isSingleActionThenNarrativePattern(params: { ); } +/** Retry budget for plan-only recovery, higher for strict-agentic models. */ export function resolvePlanningOnlyRetryLimit( executionContract?: EmbeddedAgentExecutionContract, ): number { @@ -897,6 +933,11 @@ export function resolvePlanningOnlyRetryLimit( : DEFAULT_PLANNING_ONLY_RETRY_LIMIT; } +/** + * Builds the retry instruction for assistant turns that only promised a plan + * instead of taking concrete action. The guard excludes real side effects, + * non-actionable prompts, explicit completions, and multi-tool progress. + */ export function resolvePlanningOnlyRetryInstruction(params: { provider?: string; modelId?: string; diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index b2f834b6652b..ab6fa8a3776b 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -109,8 +109,9 @@ function isOllamaCloudModel(model: { id?: string; provider?: string } | undefine } /** - * Resolves the LLM idle timeout from configuration. - * @returns Idle timeout in milliseconds, or 0 to disable + * Resolves the stream-idle watchdog timeout for one embedded run. Explicit + * provider request timeouts and bounded run/agent timeouts cap the watchdog; + * local provider base URLs disable the implicit cloud-provider default. */ export function resolveLlmIdleTimeoutMs(params?: { cfg?: OpenClawConfig; @@ -200,13 +201,9 @@ export function resolveLlmIdleTimeoutMs(params?: { } /** - * Wraps a stream function with idle timeout detection. - * If no token is received within the specified timeout, the request is aborted. - * - * @param baseFn - The base stream function to wrap - * @param timeoutMs - Idle timeout in milliseconds - * @param onIdleTimeout - Optional callback invoked when idle timeout triggers - * @returns A wrapped stream function with idle timeout detection + * Wraps a stream function with idle timeout detection for both stream creation + * and iterator progress. Each successful `next()` resets the timer; a timeout + * aborts the provider request and surfaces the same Error to the caller. */ export function streamWithIdleTimeout( baseFn: StreamFn, @@ -225,6 +222,8 @@ export function streamWithIdleTimeout( } }; const abortFromSourceSignal = () => abortStream(sourceSignal?.reason); + // Mirror caller cancellation into the provider request while still allowing + // this wrapper to abort independently on idle timeout. if (sourceSignal?.aborted) { abortFromSourceSignal(); } else { @@ -292,7 +291,7 @@ export function streamWithIdleTimeout( clearTimer(); try { - // Race between the actual next() and the timeout + // Arm the watchdog only while waiting for provider progress. const result = await Promise.race([ streamIterator.next(), createTimeoutPromise((timer) => { @@ -341,6 +340,8 @@ export function streamWithIdleTimeout( } }; + // Some providers return a pending Promise before the stream object exists; + // protect that creation phase with the same idle watchdog. return Promise.race([ Promise.resolve(maybeStream), createTimeoutPromise((timer) => { diff --git a/src/agents/embedded-agent-runner/run/message-merge-strategy.ts b/src/agents/embedded-agent-runner/run/message-merge-strategy.ts index 1d6dd7ebbb90..3c3d38a8e7d4 100644 --- a/src/agents/embedded-agent-runner/run/message-merge-strategy.ts +++ b/src/agents/embedded-agent-runner/run/message-merge-strategy.ts @@ -1,12 +1,14 @@ import { mergeOrphanedTrailingUserPrompt } from "./attempt.prompt-helpers.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; +/** Inputs required to reconcile an active session leaf with the prompt about to be sent. */ export type OrphanedTrailingUserPromptMergeParams = { prompt: string; trigger: EmbeddedRunAttemptParams["trigger"]; leafMessage: { content?: unknown }; }; +/** Result of merging or dropping a trailing user leaf before provider submission. */ export type OrphanedTrailingUserPromptMergeResult = { prompt: string; merged: boolean; @@ -18,8 +20,10 @@ export type OrphanedTrailingUserPromptMergeResult = { removeLeaf: boolean; }; +/** Registry id for the transcript message merge behavior currently supported by embedded runs. */ export type MessageMergeStrategyId = "orphan-trailing-user-prompt"; +/** Strategy seam for tests and future runtime variants that alter prompt/leaf reconciliation. */ export type MessageMergeStrategy = { id: MessageMergeStrategyId; mergeOrphanedTrailingUserPrompt: ( @@ -27,6 +31,7 @@ export type MessageMergeStrategy = { ) => OrphanedTrailingUserPromptMergeResult; }; +/** Default strategy used by embedded attempts when no test override is installed. */ export const DEFAULT_MESSAGE_MERGE_STRATEGY_ID: MessageMergeStrategyId = "orphan-trailing-user-prompt"; @@ -37,6 +42,7 @@ const defaultMessageMergeStrategy: MessageMergeStrategy = { let activeMessageMergeStrategy = defaultMessageMergeStrategy; +/** Returns the active merge strategy for the current process. */ export function resolveMessageMergeStrategy(): MessageMergeStrategy { return activeMessageMergeStrategy; } @@ -49,6 +55,7 @@ function registerMessageMergeStrategy(strategy: MessageMergeStrategy): () => voi }; } +/** Installs a process-local merge strategy override and returns a restore callback. */ export function registerMessageMergeStrategyForTest(strategy: MessageMergeStrategy): () => void { return registerMessageMergeStrategy(strategy); } diff --git a/src/agents/embedded-agent-runner/run/message-tool-terminal.ts b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts index 0aeb2c0a3ec5..4a6e024ae969 100644 --- a/src/agents/embedded-agent-runner/run/message-tool-terminal.ts +++ b/src/agents/embedded-agent-runner/run/message-tool-terminal.ts @@ -147,6 +147,11 @@ function deliveryEnvelopeIndicatesDelivered(value: unknown, depth = 0): boolean ); } +/** + * Determines whether a `message.send` tool call should end the turn in + * message-tool-only delivery mode. Only implicit-route, non-dry-run, delivered + * sends qualify; explicit routes and errors keep the model loop alive. + */ export function shouldTerminateAfterMessageToolOnlySend(params: { sourceReplyDeliveryMode?: SourceReplyDeliveryMode; context: AfterToolCallContext; @@ -183,6 +188,7 @@ export function shouldTerminateAfterMessageToolOnlySend(params: { return true; } +/** Installs an after-tool hook that terminates the turn after a qualifying message send. */ export function installMessageToolOnlyTerminalHook(params: { agent: Agent; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; diff --git a/src/agents/embedded-agent-runner/run/midturn-precheck.ts b/src/agents/embedded-agent-runner/run/midturn-precheck.ts index 018698847469..23caf70e6c65 100644 --- a/src/agents/embedded-agent-runner/run/midturn-precheck.ts +++ b/src/agents/embedded-agent-runner/run/midturn-precheck.ts @@ -1,5 +1,9 @@ import type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js"; +/** + * Captures the token-pressure snapshot that made the mid-turn tool-result guard + * stop the attempt before another model call. + */ export type MidTurnPrecheckRequest = { route: Exclude; estimatedPromptTokens: number; @@ -9,9 +13,15 @@ export type MidTurnPrecheckRequest = { effectiveReserveTokens: number; }; +/** Stable message used to identify synthetic mid-turn overflow errors in session cleanup. */ export const MID_TURN_PRECHECK_ERROR_MESSAGE = "Context overflow: prompt too large for the model (mid-turn precheck)."; +/** + * Internal control-flow signal thrown after a tool result makes the next prompt + * exceed budget. The attempt runner catches it and routes through the overflow + * recovery path instead of treating it as an ordinary provider failure. + */ export class MidTurnPrecheckSignal extends Error { readonly request: MidTurnPrecheckRequest; @@ -22,6 +32,7 @@ export class MidTurnPrecheckSignal extends Error { } } +/** Narrows unknown errors to the mid-turn overflow signal used by attempt cleanup. */ export function isMidTurnPrecheckSignal(error: unknown): error is MidTurnPrecheckSignal { return error instanceof MidTurnPrecheckSignal; } diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index 6d06722211e2..be2dbf3d9c39 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -145,6 +145,11 @@ function shouldMarkNonTerminalToolErrorWarning(lastToolError: ToolErrorSummary): return lastToolError.middlewareError === true; } +/** + * Chooses whether a tool failure needs a separate user-visible warning and + * whether to include raw details. Mutating failures are stricter because a + * silent failed write/send/delete can make the assistant look successful. + */ function resolveToolErrorWarningPolicy(params: { lastToolError: ToolErrorSummary; hasUserFacingReply: boolean; @@ -199,6 +204,12 @@ function resolveToolErrorWarningPolicy(params: { }; } +/** + * Converts a completed embedded attempt into reply payloads for channels. This + * is the boundary that suppresses duplicate source replies, filters raw API + * errors, preserves directive metadata, and decides when tool failures must be + * surfaced to the user. + */ export function buildEmbeddedRunPayloads(params: { assistantTexts: string[]; toolMetas: ToolMetaEntry[]; @@ -267,6 +278,8 @@ export function buildEmbeddedRunPayloads(params: { ) { return; } + // Message-tool-only replies were already sent by the tool. Mirror them into + // the transcript while marking payloads so channel delivery suppresses a duplicate send. replyItems.push({ text, ...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}), @@ -441,6 +454,8 @@ export function buildEmbeddedRunPayloads(params: { (!assistantTextsHaveMedia && normalizedAssistantTexts.length > 0 && normalizedAssistantTexts === normalizedRawAnswerText)); + // When streamed text lost media directives but the canonical assistant answer + // still contains them, keep the raw answer so attachments are not dropped. const fallbackAnswerSourceText = shouldPreferRawAnswerText && fallbackRawAnswerText ? fallbackRawAnswerText : fallbackAnswerText; const normalizedFallbackAnswerSourceText = fallbackAnswerSourceText @@ -585,6 +600,7 @@ export function buildEmbeddedRunPayloads(params: { payload.channelData = item.channelData; } if (item.sourceReplyMirror) { + // Source-reply mirrors are transcript artifacts, not channel sends. markReplyPayloadForSourceSuppressionDelivery(payload); if (params.sessionKey) { const sourceReplyTranscriptMirror: NonNullable< diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts index 3d9fff5cba97..6b3176cd54ba 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -23,6 +23,7 @@ const TRUNCATION_ROUTE_BUFFER_TOKENS = 512; export type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js"; +/** Pre-prompt routing decision plus the budget facts used to explain it in logs and session state. */ export type PreemptiveCompactionDecision = { route: PreemptiveCompactionRoute; shouldCompact: boolean; @@ -34,6 +35,7 @@ export type PreemptiveCompactionDecision = { effectiveReserveTokens: number; }; +/** Token pressure reported by the rendered provider-boundary prompt when available. */ export type LlmBoundaryTokenPressure = { estimatedPromptTokens: number; source: string; @@ -184,6 +186,11 @@ function estimateMessageTokenPressure(message: AgentMessage): number { return tokens; } +/** + * Estimates the prompt pressure at the LLM boundary from transcript messages, + * optional system prompt, and current prompt text. The result intentionally + * includes a safety margin because this path runs before provider tokenization. + */ export function estimateLlmBoundaryTokenPressure(params: { messages: AgentMessage[]; systemPrompt?: string; @@ -202,6 +209,7 @@ export function estimateLlmBoundaryTokenPressure(params: { return Math.max(0, Math.ceil((historyTokens + systemTokens + promptTokens) * SAFETY_MARGIN)); } +/** Estimates only the rendered prompt/system portion when history has already been accounted for. */ export function estimateRenderedLlmBoundaryTokenPressure(params: { systemPrompt?: string; prompt: string; @@ -215,6 +223,7 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: { return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN)); } +/** Backward-compatible alias for callers that still name this a pre-prompt estimate. */ export function estimatePrePromptTokens(params: { messages: AgentMessage[]; systemPrompt?: string; @@ -239,6 +248,11 @@ function normalizeLlmBoundaryTokenPressure( }; } +/** + * Decides whether a run should compact before submitting the prompt, and + * whether reducible tool results can avoid or follow compaction. Rendered LLM + * boundary pressure wins over local transcript estimates when supplied. + */ export function shouldPreemptivelyCompactBeforePrompt(params: { messages: AgentMessage[]; unwindowedMessages?: AgentMessage[]; @@ -279,6 +293,7 @@ export function shouldPreemptivelyCompactBeforePrompt(params: { MIN_PROMPT_BUDGET_TOKENS, Math.max(1, Math.floor(contextTokenBudget * MIN_PROMPT_BUDGET_RATIO)), ); + // Keep a minimum prompt budget even when reserveTokens asks for most of the context window. const effectiveReserveTokens = Math.min( requestedReserveTokens, Math.max(0, contextTokenBudget - minPromptBudget), @@ -300,6 +315,7 @@ export function shouldPreemptivelyCompactBeforePrompt(params: { let route: PreemptiveCompactionRoute = "fits"; if (overflowTokens > 0) { + // Choose truncate-only only when available reduction comfortably exceeds the overflow. if (toolResultReducibleChars <= 0) { route = "compact_only"; } else if (toolResultReducibleChars >= truncateOnlyThresholdChars) { @@ -320,6 +336,7 @@ export function shouldPreemptivelyCompactBeforePrompt(params: { }; } +/** Formats the compact operator log line for one pre-prompt budget check. */ export function formatPrePromptPrecheckLog(params: { result: PreemptiveCompactionDecision; sessionKey?: string; @@ -352,6 +369,7 @@ export function formatPrePromptPrecheckLog(params: { ); } +/** Converts the pre-prompt decision into the persisted session context-budget status record. */ export function buildPrePromptContextBudgetStatus(params: { result: PreemptiveCompactionDecision; provider: string; diff --git a/src/agents/embedded-agent-runner/run/retry-limit.ts b/src/agents/embedded-agent-runner/run/retry-limit.ts index 1206460d5439..974a12b5b7dd 100644 --- a/src/agents/embedded-agent-runner/run/retry-limit.ts +++ b/src/agents/embedded-agent-runner/run/retry-limit.ts @@ -3,6 +3,12 @@ import type { EmbeddedRunLivenessState } from "../types.js"; import type { EmbeddedAgentMeta, EmbeddedAgentRunResult } from "../types.js"; import type { RetryLimitFailoverDecision } from "./failover-policy.js"; +/** + * Converts retry-limit exhaustion into either a failover escalation or a local + * user-visible error payload. Replay-safe provider failures throw FailoverError + * so the outer run loop can switch models; non-escalating reasons preserve + * retry metadata on the returned run result. + */ export function handleRetryLimitExhaustion(params: { message: string; decision: RetryLimitFailoverDecision; diff --git a/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts b/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts index c6d322ff06c5..b32105622353 100644 --- a/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts +++ b/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts @@ -18,6 +18,7 @@ type RuntimeContextPromptParts = { runtimeSystemContext?: string; }; +/** Hidden custom transcript message that carries runtime context into model conversion. */ export type RuntimeContextCustomMessage = { role: "custom"; customType: string; @@ -29,6 +30,7 @@ export type RuntimeContextCustomMessage = { type EmptyTranscriptMode = "model-prompt" | "runtime-event"; +/** Returns the visible or resumable inbound prompt prefix used before the user prompt. */ export function buildCurrentInboundPromptContextPrefix( context: CurrentInboundPromptContext | undefined, options?: { preferResumableText?: boolean }, @@ -40,6 +42,7 @@ export function buildCurrentInboundPromptContextPrefix( return text?.trim() ?? ""; } +/** Combines inbound context and the current prompt using the channel-provided joiner. */ export function buildCurrentInboundPrompt(params: { context: CurrentInboundPromptContext | undefined; prompt: string; @@ -70,6 +73,11 @@ function removeLastPromptOccurrence(text: string, prompt: string): string | null .trim(); } +/** + * Separates user-authored prompt text from hidden runtime context. Transcript + * prompt stays user-visible; model prompt may carry runtime-only additions that + * should be delivered as hidden context instead of persisted as user text. + */ export function resolveRuntimeContextPromptParts(params: { effectivePrompt: string; transcriptPrompt?: string; @@ -106,6 +114,8 @@ export function resolveRuntimeContextPromptParts(params: { : transcriptPrompt ? removeLastPromptOccurrence(extracted.text, transcriptPrompt)?.trim() : undefined; + // The hidden context is whatever remains after removing the last visible + // prompt occurrence, plus any explicit internal runtime-context block. const runtimeContext = [hiddenRuntimeContext, extracted.runtimeContext] .filter((value): value is string => Boolean(value?.trim())) @@ -150,14 +160,17 @@ function buildRuntimeContextMessageContent(params: { ].join("\n"); } +/** Builds the hidden next-turn system context payload for model conversion. */ export function buildRuntimeContextSystemContext(runtimeContext: string): string { return buildRuntimeContextMessageContent({ runtimeContext, kind: "next-turn" }); } +/** Builds the hidden runtime-event system context payload for empty runtime-only turns. */ export function buildRuntimeEventSystemContext(runtimeContext: string): string { return buildRuntimeContextMessageContent({ runtimeContext, kind: "runtime-event" }); } +/** Creates a non-displayed custom transcript message for runtime context, if any exists. */ export function buildRuntimeContextCustomMessage( runtimeContext: string | undefined, ): RuntimeContextCustomMessage | undefined { diff --git a/src/agents/embedded-agent-runner/run/setup.ts b/src/agents/embedded-agent-runner/run/setup.ts index f92ff62ef5c7..248c6d4afd4a 100644 --- a/src/agents/embedded-agent-runner/run/setup.ts +++ b/src/agents/embedded-agent-runner/run/setup.ts @@ -39,6 +39,11 @@ type HookRunnerLike = { ): Promise; }; +/** + * Runs model-selection hooks before resolving the runtime model. The dedicated + * `before_model_resolve` hook wins over legacy `before_agent_start` overrides + * when both provide provider/model changes. + */ export async function resolveHookModelSelection(params: { prompt: string; attachments?: PluginHookBeforeModelResolveAttachment[]; @@ -103,6 +108,11 @@ export async function resolveHookModelSelection(params: { }; } +/** + * Converts prompt image refs into the minimal attachment shape exposed to + * before-model-resolve hooks. Empty image lists stay undefined so hook payloads + * do not grow a meaningless attachments field. + */ export function buildBeforeModelResolveAttachments( images: readonly { mimeType?: string }[] | undefined, ): PluginHookBeforeModelResolveAttachment[] | undefined { @@ -115,6 +125,12 @@ export function buildBeforeModelResolveAttachments( })); } +/** + * Resolves context-window policy for the selected runtime model and returns the + * model shape the session runtime should see. Configured context caps are + * reflected in `effectiveModel.contextWindow` so auto-compaction uses the same + * limit as the guard. + */ export function resolveEffectiveRuntimeModel(params: { cfg: OpenClawConfig | undefined; provider: string; diff --git a/src/agents/embedded-agent-runner/run/stream-wrapper.ts b/src/agents/embedded-agent-runner/run/stream-wrapper.ts index b2279e633053..1dcd542f2402 100644 --- a/src/agents/embedded-agent-runner/run/stream-wrapper.ts +++ b/src/agents/embedded-agent-runner/run/stream-wrapper.ts @@ -1,6 +1,11 @@ import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js"; +/** + * Mutates a stream so every object event passes through `onEvent` before the + * consumer receives it. Used by stream adapters that need to normalize partial + * and final message snapshots without replacing the stream object. + */ export function wrapStreamObjectEvents( stream: MutableAssistantMessageEventStream, onEvent: (event: Record) => void | Promise, diff --git a/src/agents/embedded-agent-runner/run/tool-media-payloads.ts b/src/agents/embedded-agent-runner/run/tool-media-payloads.ts index 542b262f6f47..09e79550f940 100644 --- a/src/agents/embedded-agent-runner/run/tool-media-payloads.ts +++ b/src/agents/embedded-agent-runner/run/tool-media-payloads.ts @@ -5,8 +5,14 @@ import { } from "../../../auto-reply/reply-payload.js"; import type { EmbeddedAgentRunResult } from "../types.js"; +/** Channel payload shape produced by embedded runs after auto-reply normalization. */ type EmbeddedRunPayload = NonNullable[number]; +/** + * Merges media emitted by tools into the channel payloads produced by the + * assistant turn. The first non-reasoning reply owns the media so text and + * attachments stay together; metadata is preserved for delivery bookkeeping. + */ export function mergeAttemptToolMediaPayloads(params: { payloads?: EmbeddedRunPayload[]; toolMediaUrls?: string[]; @@ -14,6 +20,7 @@ export function mergeAttemptToolMediaPayloads(params: { toolTrustedLocalMedia?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }): EmbeddedRunPayload[] | undefined { + // Trim and dedupe tool media before merging with assistant-owned payload media. const mediaUrls = Array.from( new Set(params.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? []), ); @@ -29,6 +36,9 @@ export function mergeAttemptToolMediaPayloads(params: { params.sourceReplyDeliveryMode === "message_tool_only" && getReplyPayloadMetadata(payload)?.sourceReplyTranscriptMirror ) { + // Message-tool-only source replies are transcript mirrors of a send that + // already happened elsewhere; attaching generated media here would create + // a duplicate channel delivery. return payloads; } const mergedMediaUrls = Array.from(new Set([...(payload.mediaUrls ?? []), ...mediaUrls])); @@ -42,6 +52,7 @@ export function mergeAttemptToolMediaPayloads(params: { return payloads; } + // Reasoning-only turns still need a concrete media payload so channel delivery sees the attachment. return [ ...payloads, { diff --git a/src/agents/embedded-agent-runner/run/trigger-policy.ts b/src/agents/embedded-agent-runner/run/trigger-policy.ts index db8a2626a342..56c6f73426e9 100644 --- a/src/agents/embedded-agent-runner/run/trigger-policy.ts +++ b/src/agents/embedded-agent-runner/run/trigger-policy.ts @@ -9,11 +9,18 @@ const DEFAULT_EMBEDDED_RUN_TRIGGER_POLICY: EmbeddedRunTriggerPolicy = { }; const EMBEDDED_RUN_TRIGGER_POLICY: Partial> = { + // Heartbeat runs are scheduler-originated and need an explicit prompt nudge; + // all user/operator triggers keep their existing prompt shape by default. heartbeat: { injectHeartbeatPrompt: true, }, }; +/** + * Decides whether a run trigger should add the heartbeat-specific prompt + * instruction. Unknown or omitted triggers fall back to the user-prompt shape + * so non-heartbeat runs do not get scheduler wording. + */ export function shouldInjectHeartbeatPromptForTrigger(trigger?: EmbeddedRunTrigger): boolean { return ( (trigger ? EMBEDDED_RUN_TRIGGER_POLICY[trigger] : undefined)?.injectHeartbeatPrompt ?? diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index 36a05701b916..f5d692668b5d 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -10,33 +10,39 @@ import type { ProviderEnvVarLookupParams, } from "../secrets/provider-env-vars.js"; +/** Returns provider-to-env-var candidates for API-key style auth lookup. */ export function resolveProviderEnvApiKeyCandidates( params?: ProviderEnvVarLookupParams, ): Record { return resolveProviderAuthEnvVarCandidates(params); } +/** Returns provider auth evidence that may come from env vars, files, or plugin manifests. */ export function resolveProviderEnvAuthEvidence( params?: ProviderEnvVarLookupParams, ): Record { return resolveProviderAuthEvidence(params); } +/** Resolves both env-var candidates and richer auth evidence from one manifest snapshot. */ export function resolveProviderEnvAuthLookupMaps( params?: ProviderEnvVarLookupParams, ): ProviderAuthLookupMaps { return resolveProviderAuthLookupMaps(params); } +/** Lists every provider key represented by either env candidates or auth evidence. */ export function listProviderEnvAuthLookupKeys(params: { envCandidateMap: Readonly>; authEvidenceMap: Readonly>; }): string[] { + // Evidence-only providers still need status/discovery rows even when they do not expose env vars. return Array.from( new Set([...Object.keys(params.envCandidateMap), ...Object.keys(params.authEvidenceMap)]), ).toSorted((a, b) => a.localeCompare(b)); } +/** Resolves provider auth lookup maps and returns their sorted provider keys. */ export function resolveProviderEnvAuthLookupKeys(params?: ProviderEnvVarLookupParams): string[] { const lookupMaps = resolveProviderEnvAuthLookupMaps(params); return listProviderEnvAuthLookupKeys({ @@ -45,6 +51,7 @@ export function resolveProviderEnvAuthLookupKeys(params?: ProviderEnvVarLookupPa }); } +/** Lists known provider API-key env var names for redaction and marker matching. */ export function listKnownProviderEnvApiKeyNames(): string[] { return listKnownProviderAuthEnvVarNames(); } diff --git a/src/agents/tools/cron-tool-canonicalize.ts b/src/agents/tools/cron-tool-canonicalize.ts index 5332839ca233..977db3b0c730 100644 --- a/src/agents/tools/cron-tool-canonicalize.ts +++ b/src/agents/tools/cron-tool-canonicalize.ts @@ -107,6 +107,7 @@ function repairConcatenatedCronToolKeys(value: Record): void { function setScheduleAtMs(schedule: Record, value: unknown): void { const atMs = typeof value === "number" ? value : Number(value); + // Invalid/out-of-range timestamps stay raw so cron gateway validation reports the user error. schedule.at = Number.isFinite(atMs) ? (timestampMsToIsoString(Math.floor(atMs)) ?? value) : value; } @@ -236,6 +237,7 @@ function canonicalizeCronToolPayload(value: Record): void { } } +/** Converts model-friendly cron tool shorthands into the nested gateway job/patch shape. */ export function canonicalizeCronToolObject( value: Record, ): Record { @@ -247,6 +249,7 @@ export function canonicalizeCronToolObject( return next; } +/** Detects recovered update patches that contain no meaningful cron fields after normalization. */ export function isEmptyRecoveredCronPatch(value: unknown): boolean { if (!isRecord(value)) { return true; @@ -261,6 +264,7 @@ export function isEmptyRecoveredCronPatch(value: unknown): boolean { ); } +/** Recovers cron job or patch fields that a model flattened beside the action arguments. */ export function recoverCronObjectFromFlatParams(params: Record): { found: boolean; value: Record; @@ -276,6 +280,7 @@ export function recoverCronObjectFromFlatParams(params: Record) return { found, value: canonicalizeCronToolObject(value) }; } +/** Checks whether a recovered flat object has enough schedule/payload signal to create a job. */ export function hasCronCreateSignal(value: Record): boolean { return ( value.schedule !== undefined || diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 123b3728726e..5e1d7e086b7e 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -20,6 +20,9 @@ import { readPositiveIntegerParam, readStringParam } from "./common.js"; export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; +/** + * Optional gateway connection overrides accepted by agent tools. + */ export type GatewayCallOptions = { gatewayUrl?: string; gatewayToken?: string; @@ -28,6 +31,9 @@ export type GatewayCallOptions = { type GatewayOverrideTarget = "local" | "remote"; +/** + * Reads common gateway options from tool parameters while preserving explicit token whitespace. + */ export function readGatewayCallOptions(params: Record): GatewayCallOptions { return { gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), @@ -36,6 +42,9 @@ export function readGatewayCallOptions(params: Record): Gateway }; } +/** + * Canonicalizes websocket URLs for allowlist comparisons without retaining paths or credentials. + */ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: string } { const input = raw.trim(); let url: URL; @@ -88,7 +97,8 @@ function validateGatewayUrlOverrideForAgentTools(params: { const remote = canonicalizeToolGatewayWsUrl(remoteUrl); remoteKey = remote.key; } catch { - // ignore: misconfigured remote url; tools should fall back to default resolution. + // Misconfigured remote URL should not make ordinary tool calls fail; only explicit + // gatewayUrl overrides need strict validation. } } @@ -125,6 +135,9 @@ function resolveGatewayOverrideToken(params: { }).token; } +/** + * Resolves the gateway URL, token, and timeout for agent tool calls. + */ export function resolveGatewayOptions(opts?: GatewayCallOptions) { const cfg = getRuntimeConfig(); const validatedOverride = @@ -165,11 +178,16 @@ function resolveApprovalRuntimeTokenForGatewayTool(params: { return undefined; } if (trimToUndefined(params.opts.gatewayUrl) !== undefined) { + // Runtime approval tokens are scoped to the local approval bridge, not arbitrary + // caller-supplied gateway URLs. return undefined; } return getOperatorApprovalRuntimeToken(); } +/** + * Calls a gateway method as the agent-tool backend client with least-privilege scopes. + */ export async function callGatewayTool>( method: string, opts: GatewayCallOptions, diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 9c9e73599cf8..fadfd545b6ea 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -72,6 +72,9 @@ type TaskRunDetailHandle = { export const REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS = 120_000; +/** + * Applies an image-editing model as the agent default without mutating the loaded config. + */ export function applyImageModelConfigDefaults( cfg: OpenClawConfig | undefined, imageModelConfig: ImageModelConfig, @@ -79,6 +82,9 @@ export function applyImageModelConfigDefaults( return applyAgentDefaultModelConfig(cfg, "imageModel", imageModelConfig); } +/** + * Applies an image-generation model as the agent default for downstream tool calls. + */ export function applyImageGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, imageGenerationModelConfig: ToolModelConfig, @@ -86,6 +92,9 @@ export function applyImageGenerationModelConfigDefaults( return applyAgentDefaultModelConfig(cfg, "imageGenerationModel", imageGenerationModelConfig); } +/** + * Applies a video-generation model as the agent default for downstream tool calls. + */ export function applyVideoGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, videoGenerationModelConfig: ToolModelConfig, @@ -93,6 +102,9 @@ export function applyVideoGenerationModelConfigDefaults( return applyAgentDefaultModelConfig(cfg, "videoGenerationModel", videoGenerationModelConfig); } +/** + * Applies a music-generation model as the agent default for downstream tool calls. + */ export function applyMusicGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, musicGenerationModelConfig: ToolModelConfig, @@ -100,12 +112,18 @@ export function applyMusicGenerationModelConfigDefaults( return applyAgentDefaultModelConfig(cfg, "musicGenerationModel", musicGenerationModelConfig); } +/** + * Reads an optional generation timeout while preserving common tool parameter validation. + */ export function readGenerationTimeoutMs(args: Record): number | undefined { return readPositiveIntegerParam(args, "timeoutMs", { message: "timeoutMs must be a positive integer in milliseconds.", }); } +/** + * Resolves the shared remote-media SSRF policy used by media tools that fetch URLs. + */ export function resolveRemoteMediaSsrfPolicy( cfg: OpenClawConfig | undefined, ): SsrFPolicy | undefined { @@ -160,6 +178,10 @@ function parseCapabilityModelRefForProviders(params: { }); } +/** + * Checks whether a generation provider is usable from either its custom readiness hook or + * the generic tool auth profile/config lookup. + */ export function isCapabilityProviderConfigured(params: { providers: T[]; provider?: T; @@ -202,6 +224,9 @@ export function isCapabilityProviderConfigured(par }); } +/** + * Resolves the provider implied by a model override or configured primary model. + */ export function resolveSelectedCapabilityProvider(params: { providers: T[]; modelConfig: ToolModelConfig; @@ -289,6 +314,10 @@ function resolveCapabilityModelCandidatesForTool(params: { return orderedRefs; } +/** + * Builds the model config for a generation tool from explicit config first, then configured + * provider defaults ordered around the agent's primary provider. + */ export function resolveCapabilityModelConfigForTool(params: { cfg?: OpenClawConfig; workspaceDir?: string; @@ -332,6 +361,9 @@ export function resolveCapabilityModelConfigForTool(params: { }); } +/** + * Reports whether a generation tool should be offered for the current config and auth state. + */ export function hasGenerationToolAvailability(params: { cfg?: OpenClawConfig; agentDir?: string; @@ -407,6 +439,9 @@ function formatQuotedList(values: readonly string[]): string { .join(", ")}, or "${values[values.length - 1]}"`; } +/** + * Reads a constrained generation action and raises a tool-input error for invalid values. + */ export function resolveGenerateAction(params: { args: Record; allowed: readonly TAction[]; @@ -423,6 +458,9 @@ export function resolveGenerateAction(params: { throw new ToolInputError(`action must be ${formatQuotedList(params.allowed)}`); } +/** + * Reads boolean tool parameters from either canonical or snake_case keys. + */ export function readBooleanToolParam( params: Record, key: string, @@ -443,6 +481,9 @@ export function readBooleanToolParam( return undefined; } +/** + * Normalizes singular/plural media reference parameters into a deduped, bounded list. + */ export function normalizeMediaReferenceInputs(params: { args: Record; singularKey: string; @@ -472,6 +513,9 @@ export function normalizeMediaReferenceInputs(params: { return deduped; } +/** + * Builds result detail fields for one or many rewritten media references. + */ export function buildMediaReferenceDetails(params: { entries: readonly T[]; singleKey: string; @@ -501,6 +545,9 @@ export function buildMediaReferenceDetails( return {}; } +/** + * Adds task/run provenance details when an async media generation handle is present. + */ export function buildTaskRunDetails( handle: TaskRunDetailHandle | null | undefined, ): Record { @@ -514,6 +561,9 @@ export function buildTaskRunDetails( : {}; } +/** + * Resolves host-local read roots for tools that accept filesystem media references. + */ export function resolveMediaToolLocalRoots( workspaceDirRaw: string | undefined, options?: { @@ -528,10 +578,15 @@ export function resolveMediaToolLocalRoots( if (options?.workspaceOnly) { return workspaceDir ? [workspaceDir] : []; } + // Channel inbound attachment roots stay separate: those paths are scoped to inbound media + // access, not broad host-local file reads. const roots = getDefaultLocalRoots(); return uniqueStrings([...roots, ...(workspaceDir ? [workspaceDir] : [])]); } +/** + * Resolves channel-scoped inbound attachment roots separately from host-local roots. + */ export function resolveMediaToolInboundRoots(options?: { workspaceOnly?: boolean; cfg?: OpenClawConfig; @@ -550,6 +605,9 @@ export function resolveMediaToolInboundRoots(options?: { ); } +/** + * Resolves the effective prompt and optional model override from common media tool args. + */ export function resolvePromptAndModelOverride( args: Record, defaultPrompt: string, @@ -562,6 +620,9 @@ export function resolvePromptAndModelOverride( return { prompt, modelOverride }; } +/** + * Wraps a generated text result in the common tool result shape with model attempt details. + */ export function buildTextToolResult( result: TextToolResult, extraDetails: Record, @@ -579,6 +640,9 @@ export function buildTextToolResult( }; } +/** + * Resolves a catalog model while supporting registries that index model ids with provider prefixes. + */ export function resolveModelFromRegistry(params: { modelRegistry: { find: (provider: string, modelId: string) => unknown }; provider: string; @@ -600,6 +664,9 @@ export function resolveModelFromRegistry(params: { return model; } +/** + * Loads the runtime API key for a resolved model and caches it in per-run auth storage. + */ export async function resolveModelRuntimeApiKey(params: { model: Model; cfg: OpenClawConfig | undefined; diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index cc54ff627f34..735ba1780bb6 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -60,6 +60,7 @@ async function loadNodes(opts: GatewayCallOptions): Promise { if (!shouldFallbackToPairList(error)) { throw error; } + // Older gateways only expose paired-node state; preserve node tools until node.list exists. const res = await callGatewayTool("node.pair.list", opts, {}); const { paired } = parsePairingList(res); return paired.map((n) => ({ @@ -88,6 +89,7 @@ function compareDefaultNodeOrder(a: NodeListNode, b: NodeListNode): number { return a.nodeId.localeCompare(b.nodeId); } +/** Selects the implicit node target when a tool call omits an explicit node query. */ export function selectDefaultNodeFromList( nodes: NodeListNode[], options: DefaultNodeSelectionOptions = {}, @@ -134,10 +136,12 @@ function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { }); } +/** Lists Gateway nodes, falling back to paired-node records for older Gateway versions. */ export async function listNodes(opts: GatewayCallOptions): Promise { return loadNodes(opts); } +/** Resolves a node id from an already-loaded node list using shared node matching rules. */ export function resolveNodeIdFromList( nodes: NodeListNode[], query?: string, @@ -149,6 +153,7 @@ export function resolveNodeIdFromList( }); } +/** Loads nodes from the Gateway and resolves the requested or default node id. */ export async function resolveNodeId( opts: GatewayCallOptions, query?: string, @@ -157,6 +162,7 @@ export async function resolveNodeId( return (await resolveNode(opts, query, allowDefault)).nodeId; } +/** Loads nodes from the Gateway and returns the requested or default node record. */ export async function resolveNode( opts: GatewayCallOptions, query?: string, diff --git a/src/agents/tools/pdf-tool.helpers.ts b/src/agents/tools/pdf-tool.helpers.ts index baa846eb488b..a9cc582c0c9f 100644 --- a/src/agents/tools/pdf-tool.helpers.ts +++ b/src/agents/tools/pdf-tool.helpers.ts @@ -7,8 +7,10 @@ import type { AssistantMessage } from "../../llm/types.js"; import { providerSupportsNativePdfDocument } from "../../media-understanding/defaults.js"; import { extractAssistantText } from "../embedded-agent-utils.js"; +/** Normalized PDF model preference used by tool registration and execution. */ export type PdfModelConfig = { primary?: string; fallbacks?: string[] }; +/** Reads `pdf` and `pdfs` tool arguments into a trimmed, de-duplicated PDF input list. */ export function resolvePdfInputs(record: Record): string[] { const pdfCandidates: string[] = []; if (typeof record.pdf === "string") { @@ -34,16 +36,12 @@ export function resolvePdfInputs(record: Record): string[] { return pdfInputs; } -/** - * Check whether a provider supports native PDF document input. - */ +/** Checks whether a provider supports native PDF document input. */ export function providerSupportsNativePdf(provider: string): boolean { return providerSupportsNativePdfDocument({ providerId: provider }); } -/** - * Parse a page range string (e.g. "1-5", "3", "1-3,7-9") into an array of 1-based page numbers. - */ +/** Parses a page range string into sorted, unique, 1-based page numbers within `maxPages`. */ export function parsePageRange(range: string, maxPages: number): number[] { const pages = new Set(); const parts = range.split(",").map((p) => p.trim()); @@ -74,6 +72,7 @@ export function parsePageRange(range: string, maxPages: number): number[] { return Array.from(pages).toSorted((a, b) => a - b); } +/** Converts a provider assistant message into PDF text or throws a model-labelled failure. */ export function coercePdfAssistantText(params: { message: AssistantMessage; provider: string; @@ -100,6 +99,7 @@ export function coercePdfAssistantText(params: { throw new Error(`PDF model returned no text (${label}).`); } +/** Reads configured PDF primary/fallback models from agent defaults. */ export function coercePdfModelConfig(cfg?: OpenClawConfig): PdfModelConfig { const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.pdfModel); const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.pdfModel); @@ -113,6 +113,7 @@ export function coercePdfModelConfig(cfg?: OpenClawConfig): PdfModelConfig { return modelConfig; } +/** Caps requested PDF response tokens to the selected model's advertised maximum. */ export function resolvePdfToolMaxTokens( modelMaxTokens: number | undefined, requestedMaxTokens = 4096, diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index d886e2828820..26f7052df23a 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -22,6 +22,7 @@ export type AnnounceTarget = { threadId?: string; // Forum topic/thread ID }; +/** Resolves a session key into the channel target used for source-reply announcements. */ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { const parsed = resolveSessionConversationRef(sessionKey); if (!parsed) { @@ -32,6 +33,7 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget const channel = normalizedChannel ?? parsed.channel; const plugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : null; const genericTarget = parsed.kind === "channel" ? `channel:${parsed.id}` : `group:${parsed.id}`; + // Prefer plugin-owned target normalization so channel-specific IDs and topics survive routing. const normalized = plugin?.messaging?.resolveSessionTarget?.({ kind: parsed.kind, @@ -63,6 +65,7 @@ function buildAgentSessionLines(params: { ].filter((line): line is string => Boolean(line)); } +/** Builds the initial prompt context for a sessions_send agent-to-agent request. */ export function buildAgentToAgentMessageContext(params: { requesterSessionKey?: string; requesterChannel?: string; @@ -74,6 +77,7 @@ export function buildAgentToAgentMessageContext(params: { return lines.join("\n"); } +/** Builds the bounded ping-pong reply prompt for the current A2A participant. */ export function buildAgentToAgentReplyContext(params: { requesterSessionKey?: string; requesterChannel?: string; @@ -95,6 +99,7 @@ export function buildAgentToAgentReplyContext(params: { return lines.join("\n"); } +/** Builds the final announce prompt that decides whether to post back to the target channel. */ export function buildAgentToAgentAnnounceContext(params: { requesterSessionKey?: string; requesterChannel?: string; @@ -119,6 +124,7 @@ export function buildAgentToAgentAnnounceContext(params: { return lines.join("\n"); } +/** Resolves the configured A2A ping-pong turn limit with a hard runtime cap. */ export function resolvePingPongTurns(cfg?: OpenClawConfig) { const raw = cfg?.session?.agentToAgent?.maxPingPongTurns; const fallback = DEFAULT_AGENTNG_PONG_TURNS; diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 1db030bd997e..362bb4c79238 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -234,6 +234,7 @@ export function isoToPerplexityDate(iso: string): string | undefined { return `${Number.parseInt(month, 10)}/${Number.parseInt(day, 10)}/${year}`; } +/** Accepts ISO dates plus Perplexity `M/D/YYYY` dates and returns canonical ISO dates. */ export function normalizeToIsoDate(value: string): string | undefined { const trimmed = value.trim(); if (ISO_DATE_PATTERN.test(trimmed)) { @@ -248,6 +249,7 @@ export function normalizeToIsoDate(value: string): string | undefined { return undefined; } +/** Parses optional date range filters and returns provider-facing validation errors. */ export function parseIsoDateRange(params: { rawDateAfter?: string; rawDateBefore?: string; @@ -292,6 +294,7 @@ export function parseIsoDateRange(params: { return { dateAfter, dateBefore }; } +/** Converts shared freshness names into provider-specific Brave or Perplexity values. */ export function normalizeFreshness( value: string | undefined, provider: WebSearchFreshnessProvider, @@ -315,6 +318,7 @@ export function normalizeFreshness( const match = trimmed.match(BRAVE_FRESHNESS_RANGE); if (match) { const [, start, end] = match; + // Brave accepts explicit ISO ranges; Perplexity only supports recency buckets here. if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { return `${start}to${end}`; } @@ -324,6 +328,7 @@ export function normalizeFreshness( return undefined; } +/** Parses freshness/date filters while rejecting combinations providers cannot express safely. */ export function parseWebSearchTimeFilters(params: { rawFreshness?: string; rawDateAfter?: string; @@ -392,17 +397,20 @@ export function parseWebSearchTimeFilters | undefined { const cached = readCache(SEARCH_CACHE, cacheKey); return cached ? { ...cached.value, cached: true } : undefined; } +/** Builds a normalized cache key from provider-specific search dimensions. */ export function buildSearchCacheKey(parts: Array): string { return normalizeCacheKey( parts.map((part) => (part === undefined ? "default" : String(part))).join(":"), ); } +/** Stores one provider search payload with its provider-selected TTL. */ export function writeCachedSearchPayload( cacheKey: string, payload: Record, diff --git a/src/bootstrap/node-startup-env.ts b/src/bootstrap/node-startup-env.ts index f743ddff9ecb..ca7040bd2bb1 100644 --- a/src/bootstrap/node-startup-env.ts +++ b/src/bootstrap/node-startup-env.ts @@ -1,10 +1,13 @@ import { type EnvMap, resolveAutoNodeExtraCaCerts } from "./node-extra-ca-certs.js"; +// Startup TLS environment defaults for child Node processes. macOS needs +// explicit system-CA env in launch-style contexts; Linux uses version-manager heuristics. type NodeStartupTlsEnvironment = { NODE_EXTRA_CA_CERTS?: string; NODE_USE_SYSTEM_CA?: string; }; +/** Resolves NODE_* TLS env values without overwriting user-provided settings. */ export function resolveNodeStartupTlsEnvironment( params: { env?: EnvMap; diff --git a/src/channels/account-inspection.ts b/src/channels/account-inspection.ts index 7dd3224105cc..41203027c76b 100644 --- a/src/channels/account-inspection.ts +++ b/src/channels/account-inspection.ts @@ -15,6 +15,9 @@ type AccountInspectionFields = { configured?: boolean; } | null; +/** + * Inspects one channel account using the plugin hook or read-only fallback. + */ export async function inspectChannelAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; @@ -30,6 +33,9 @@ export async function inspectChannelAccount(params: { ); } +/** + * Resolves an inspected channel account plus enabled/configured state for status surfaces. + */ export async function resolveInspectedChannelAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; @@ -54,6 +60,8 @@ export async function resolveInspectedChannelAccount(params: { const sourceInspection = sourceInspectedAccount as AccountInspectionFields; const resolvedAccount = resolvedInspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId); + // When a source config says a credential exists but this process cannot resolve it, keep the + // unavailable source snapshot so status can distinguish "configured" from "missing". const useSourceUnavailableAccount = Boolean( sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index fc2cf2a54c8f..1298935c7491 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,3 +1,7 @@ +/** + * Status-safe channel account projection helpers for CLI, status APIs, and plugin SDK callers. + * This file is the redaction boundary between runtime account objects and public snapshots. + */ import { stripUrlUserInfo } from "@openclaw/net-policy/url-userinfo"; import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -6,10 +10,6 @@ import { isRecord } from "../utils.js"; import { asBoolean } from "../utils/boolean.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; -// Read-only status commands project a safe subset of account fields into snapshots -// so renderers can preserve "configured but unavailable" state without touching -// strict runtime-only credential helpers. - const CREDENTIAL_STATUS_KEYS = [ "tokenStatus", "botTokenStatus", @@ -57,6 +57,12 @@ function readCredentialStatus(record: Record, key: CredentialSt : undefined; } +/** + * Infers whether any known credential status makes an account configured. + * + * Status commands need this metadata for "configured but unavailable" accounts without reading + * raw credentials from runtime-only helpers. + */ export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined { const record = isRecord(account) ? account : null; if (!record) { @@ -76,6 +82,7 @@ export function resolveConfiguredFromCredentialStatuses(account: unknown): boole return sawCredentialStatus ? false : undefined; } +/** Infers configured state only from the credential status keys required by a channel. */ export function resolveConfiguredFromRequiredCredentialStatuses( account: unknown, requiredKeys: CredentialStatusKey[], @@ -98,6 +105,7 @@ export function resolveConfiguredFromRequiredCredentialStatuses( return sawCredentialStatus ? true : undefined; } +/** Returns true when a credential exists but cannot be resolved at status-render time. */ export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean { const record = isRecord(account) ? account : null; if (!record) { @@ -108,6 +116,7 @@ export function hasConfiguredUnavailableCredentialStatus(account: unknown): bool ); } +/** Returns true when account data contains a resolved credential value or available status. */ export function hasResolvedCredentialValue(account: unknown): boolean { const record = isRecord(account) ? account : null; if (!record) { @@ -120,6 +129,7 @@ export function hasResolvedCredentialValue(account: unknown): boolean { ); } +/** Projects credential source/status metadata while omitting raw credential values. */ export function projectCredentialSnapshotFields( account: unknown, ): Pick< @@ -143,6 +153,8 @@ export function projectCredentialSnapshotFields( const appTokenSource = normalizeOptionalString(record.appTokenSource); const signingSecretSource = normalizeOptionalString(record.signingSecretSource); + // Only project source/status fields. Token-like values stay out of account snapshots even when + // callers pass full runtime account objects. return { ...(tokenSource ? { tokenSource } : {}), ...(botTokenSource ? { botTokenSource } : {}), @@ -166,6 +178,12 @@ export function projectCredentialSnapshotFields( }; } +/** + * Projects status-safe account fields for read-only channel/account snapshots. + * + * This is the boundary between runtime account objects and status renderers; keep it explicit so + * new channel fields do not accidentally expose webhook URLs, public keys, or raw credentials. + */ export function projectSafeChannelAccountSnapshotFields( account: unknown, ): Partial { @@ -232,6 +250,7 @@ export function projectSafeChannelAccountSnapshotFields( ? { allowFrom: readStringArray(record, "allowFrom") } : {}), ...projectCredentialSnapshotFields(account), + // Base URLs are useful diagnostics, but embedded userinfo would expose credentials. ...(baseUrl ? { baseUrl: stripUrlUserInfo(baseUrl) } : {}), ...(readBoolean(record, "allowUnmentionedGroups") !== undefined ? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") } diff --git a/src/channels/account-summary.ts b/src/channels/account-summary.ts index 324026e694a3..49271b78693b 100644 --- a/src/channels/account-summary.ts +++ b/src/channels/account-summary.ts @@ -5,6 +5,9 @@ import { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fiel import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; import type { ChannelPlugin } from "./plugins/types.plugin.js"; +/** + * Builds the safe account snapshot shown by CLI, gateway, and status summaries. + */ export function buildChannelAccountSnapshot(params: { plugin: ChannelPlugin; account: unknown; @@ -23,6 +26,9 @@ export function buildChannelAccountSnapshot(params: { }; } +/** + * Formats allowFrom entries with a plugin formatter when one exists. + */ export function formatChannelAllowFrom(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; @@ -39,6 +45,9 @@ export function formatChannelAllowFrom(params: { return normalizeStringEntries(params.allowFrom); } +/** + * Resolves whether a channel account should be treated as enabled. + */ export function resolveChannelAccountEnabled(params: { plugin: ChannelPlugin; account: unknown; @@ -51,6 +60,9 @@ export function resolveChannelAccountEnabled(params: { return enabled !== false; } +/** + * Resolves whether a channel account has enough configuration to run. + */ export async function resolveChannelAccountConfigured(params: { plugin: ChannelPlugin; account: unknown; @@ -61,6 +73,8 @@ export async function resolveChannelAccountConfigured(params: { return await params.plugin.config.isConfigured(params.account, params.cfg); } if (params.readAccountConfiguredField) { + // Status inspection can project an explicit configured=false marker. Normal runtime + // account objects default to configured unless the plugin owns a stricter check. const configured = isRecord(params.account) ? params.account.configured : undefined; return configured !== false; } diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts index f4d3693978c4..88001a15b61c 100644 --- a/src/channels/ack-reactions.ts +++ b/src/channels/ack-reactions.ts @@ -1,13 +1,23 @@ +/** Channel-level policy for which inbound messages should receive an ack reaction. */ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; +/** WhatsApp group-mode policy; direct-message ack reactions are configured separately. */ export type WhatsAppAckReactionMode = "always" | "mentions" | "never"; +/** Sent ack reaction state plus the cleanup hook callers can run after reply delivery. */ export type AckReactionHandle = { ackReactionPromise: Promise; ackReactionValue: string; remove: () => Promise; }; +/** + * Inputs for the reusable direct/group/mention gate shared by channel plugins. + * + * `effectiveWasMentioned` should already include any channel-specific mention + * normalization. `shouldBypassMention` is only for an earlier channel gate that + * proved the active conversation, such as a group activation state. + */ export type AckReactionGateParams = { scope: AckReactionScope | undefined; isDirect: boolean; @@ -19,6 +29,7 @@ export type AckReactionGateParams = { shouldBypassMention?: boolean; }; +/** Resolves the generic ack reaction gate without sending or removing reactions. */ export function shouldAckReaction(params: AckReactionGateParams): boolean { const scope = params.scope ?? "group-mentions"; if (scope === "off" || scope === "none") { @@ -43,11 +54,14 @@ export function shouldAckReaction(params: AckReactionGateParams): boolean { if (!params.canDetectMention) { return false; } + // Group activation can stand in for a literal mention when another gate already established + // that this inbound message belongs to the active conversation. return params.effectiveWasMentioned || params.shouldBypassMention === true; } return false; } +/** Resolves WhatsApp ack policy while preserving the shared mention-only group gate. */ export function shouldAckReactionForWhatsApp(params: { emoji: string; isDirect: boolean; @@ -72,6 +86,8 @@ export function shouldAckReactionForWhatsApp(params: { if (params.groupMode === "always") { return true; } + // WhatsApp "mentions" mode shares the generic group-mentions path so activation bypass and + // mention detection semantics stay aligned with other channels. return shouldAckReaction({ scope: "group-mentions", isDirect: false, @@ -84,6 +100,7 @@ export function shouldAckReactionForWhatsApp(params: { }); } +/** Starts sending an ack reaction and returns the success-tracking cleanup handle. */ export function createAckReactionHandle(params: { ackReactionValue: string; send: () => Promise; @@ -97,8 +114,10 @@ export function createAckReactionHandle(params: { let sendPromise: Promise; try { + // Send starts eagerly so callers can keep processing while the channel API resolves. sendPromise = params.send(); } catch (err) { + // Convert sync throws into the same Promise flow used for async send failures. sendPromise = Promise.reject(toLintErrorObject(err, "Non-Error rejection")); } @@ -115,6 +134,7 @@ export function createAckReactionHandle(params: { }; } +/** Schedules removal of a previously sent ack reaction after reply delivery. */ export function removeAckReactionAfterReply(params: { removeAfterReply: boolean; ackReactionPromise: Promise | null; @@ -131,6 +151,7 @@ export function removeAckReactionAfterReply(params: { if (!params.ackReactionValue) { return; } + // Only remove if the send actually succeeded; failed sends are already reported by the handle. void params.ackReactionPromise.then((didAck) => { if (!didAck) { return; @@ -139,6 +160,7 @@ export function removeAckReactionAfterReply(params: { }); } +/** Convenience wrapper that removes an ack reaction handle after reply delivery. */ export function removeAckReactionHandleAfterReply(params: { removeAfterReply: boolean; ackReaction: AckReactionHandle | null | undefined; diff --git a/src/channels/allow-from.ts b/src/channels/allow-from.ts index f30ed702aa93..67f0f04d074a 100644 --- a/src/channels/allow-from.ts +++ b/src/channels/allow-from.ts @@ -1,7 +1,13 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +/** + * Prefix that marks an allowFrom entry as an access-group reference instead of a sender id. + */ export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:"; +/** + * Parses an access-group allowFrom entry and returns the referenced group name. + */ export function parseAccessGroupAllowFromEntry(entry: string): string | null { const trimmed = entry.trim(); if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) { @@ -11,6 +17,9 @@ export function parseAccessGroupAllowFromEntry(entry: string): string | null { return name.length > 0 ? name : null; } +/** + * Merges configured DM allowFrom entries with pairing-store sender ids when policy allows it. + */ export function mergeDmAllowFromSources(params: { allowFrom?: Array; storeAllowFrom?: Array; @@ -23,6 +32,9 @@ export function mergeDmAllowFromSources(params: { return normalizeStringEntries([...(params.allowFrom ?? []), ...storeEntries]); } +/** + * Resolves the allowFrom entries used for group chats, optionally falling back to DM policy. + */ export function resolveGroupAllowFromSources(params: { allowFrom?: Array; groupAllowFrom?: Array; @@ -40,6 +52,9 @@ export function resolveGroupAllowFromSources(params: { return normalizeStringEntries(scoped); } +/** + * Returns the first value that is present, preserving falsy values such as false, 0, and "". + */ export function firstDefined(...values: Array) { for (const value of values) { if (value !== undefined) { @@ -49,6 +64,9 @@ export function firstDefined(...values: Array) { return undefined; } +/** + * Checks a normalized sender allowlist with wildcard and empty-list policy handling. + */ export function isSenderIdAllowed( allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean }, senderId: string | undefined, @@ -60,6 +78,7 @@ export function isSenderIdAllowed( if (allow.hasWildcard) { return true; } + // A non-empty allowlist without wildcard needs a concrete sender id match. if (!senderId) { return false; } diff --git a/src/channels/allowlist-match.ts b/src/channels/allowlist-match.ts index 7ffa000913d0..6aca056ffcf2 100644 --- a/src/channels/allowlist-match.ts +++ b/src/channels/allowlist-match.ts @@ -3,6 +3,8 @@ import { normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; +// Shared allowlist matching primitives for channel ingress, setup, and plugin +// helpers. Callers provide candidate sources so audit logs can explain matches. export type AllowlistMatchSource = | "wildcard" | "id" @@ -26,12 +28,14 @@ export type CompiledAllowlist = { wildcard: boolean; }; +/** Formats match metadata for diagnostics without leaking channel-specific text. */ export function formatAllowlistMatchMeta( match?: { matchKey?: string; matchSource?: string } | null, ): string { return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`; } +/** Compiles normalized allowlist entries and records wildcard presence. */ export function compileAllowlist(entries: ReadonlyArray): CompiledAllowlist { const set = new Set(entries.filter(Boolean)); return { @@ -67,6 +71,7 @@ export function resolveAllowlistCandidates(params: { return { allowed: false }; } +/** Applies wildcard and empty-list semantics before candidate matching. */ export function resolveCompiledAllowlistMatch(params: { compiledAllowlist: CompiledAllowlist; candidates: Array<{ value?: string; source: TSource }>; @@ -80,6 +85,7 @@ export function resolveCompiledAllowlistMatch(params: { return resolveAllowlistCandidates(params); } +/** Convenience wrapper for callers that do not need to reuse a compiled list. */ export function resolveAllowlistMatchByCandidates(params: { allowList: ReadonlyArray; candidates: Array<{ value?: string; source: TSource }>; @@ -90,6 +96,7 @@ export function resolveAllowlistMatchByCandidates(params }); } +/** Matches simple sender id/name allowlists used by legacy channel config. */ export function resolveAllowlistMatchSimple(params: { allowFrom: ReadonlyArray; senderId: string; diff --git a/src/channels/allowlists/resolve-utils.ts b/src/channels/allowlists/resolve-utils.ts index b9e24d61eaf1..024b2e36e0d9 100644 --- a/src/channels/allowlists/resolve-utils.ts +++ b/src/channels/allowlists/resolve-utils.ts @@ -37,6 +37,7 @@ export function mergeAllowlist(params: { return dedupeAllowlistEntries([...mapAllowFromEntries(params.existing), ...params.additions]); } +/** Splits lookup results into resolved mappings, unresolved display text, and id additions. */ export function buildAllowlistResolutionSummary( resolvedUsers: T[], opts?: { formatResolved?: (entry: T) => string; formatUnresolved?: (entry: T) => string }, @@ -74,6 +75,7 @@ function resolveAllowlistIdAdditions(para return additions; } +/** Replaces resolvable user entries with canonical ids while preserving unresolved entries and `*`. */ export function canonicalizeAllowlistWithResolvedIds< T extends AllowlistUserResolutionLike, >(params: { existing?: Array; resolvedMap: Map }): string[] { @@ -84,6 +86,7 @@ export function canonicalizeAllowlistWithResolvedIds< continue; } if (trimmed === "*") { + // Wildcard allowlists are a policy value, not a lookup target. canonicalized.push(trimmed); continue; } @@ -93,6 +96,7 @@ export function canonicalizeAllowlistWithResolvedIds< return dedupeAllowlistEntries(canonicalized); } +/** Updates nested `{ users }` allowlist entries using merge or canonicalize semantics. */ export function patchAllowlistUsersInConfigEntries< T extends AllowlistUserResolutionLike, TEntries extends Record, @@ -110,6 +114,7 @@ export function patchAllowlistUsersInConfigEntries< if (!Array.isArray(users) || users.length === 0) { continue; } + // `merge` keeps original user text and appends resolved ids; `canonicalize` replaces it. const resolvedUsers = params.strategy === "canonicalize" ? canonicalizeAllowlistWithResolvedIds({ @@ -131,6 +136,7 @@ export function patchAllowlistUsersInConfigEntries< return nextEntries as TEntries; } +/** Collects concrete user lookup targets from one config entry, excluding wildcard policy entries. */ export function addAllowlistUserEntriesFromConfigEntry(target: Set, entry: unknown): void { if (!entry || typeof entry !== "object") { return; @@ -147,6 +153,7 @@ export function addAllowlistUserEntriesFromConfigEntry(target: Set, entr } } +/** Logs a compact resolved/unresolved allowlist lookup summary when there is anything to report. */ export function summarizeMapping( label: string, mapping: string[], diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 97743ff65919..c25c747eef4e 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -25,6 +25,8 @@ const officialCatalogFileCache = new Map(); function listPackageRoots(): string[] { + // Source checkouts and packaged installs can resolve OpenClaw from different roots; scan both + // once so channel metadata works in dev, linked packages, and published CLI layouts. return uniqueStrings( [ resolveOpenClawPackageRootSync({ cwd: process.cwd() }), @@ -119,6 +121,9 @@ function toBundledChannelEntry( }; } +/** + * Lists bundled channel catalog entries from package manifests and generated catalog files. + */ export function listBundledChannelCatalogEntries(): BundledChannelCatalogEntry[] { const entries = new Map(); for (const entry of readBundledExtensionCatalogEntriesSync()) { @@ -130,6 +135,7 @@ export function listBundledChannelCatalogEntries(): BundledChannelCatalogEntry[] for (const entry of readOfficialCatalogFileSync()) { const channelEntry = toBundledChannelEntry(entry); if (channelEntry) { + // Package manifests win over the generated catalog when both describe the same id. entries.set(channelEntry.id, entries.get(channelEntry.id) ?? channelEntry); } } diff --git a/src/channels/channel-config.ts b/src/channels/channel-config.ts index e96b603b3828..dffab6dd1492 100644 --- a/src/channels/channel-config.ts +++ b/src/channels/channel-config.ts @@ -1,8 +1,10 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +/** How a channel config entry was selected. */ export type ChannelMatchSource = "direct" | "parent" | "wildcard"; +/** Match result carrying direct, parent, and wildcard candidates for channel config lookup. */ export type ChannelEntryMatch = { entry?: T; key?: string; @@ -14,6 +16,7 @@ export type ChannelEntryMatch = { matchSource?: ChannelMatchSource; }; +/** Copies match metadata onto resolved channel config output. */ export function applyChannelMatchMeta< TResult extends { matchKey?: string; matchSource?: ChannelMatchSource }, >(result: TResult, match: ChannelEntryMatch): TResult { @@ -24,6 +27,7 @@ export function applyChannelMatchMeta< return result; } +/** Resolves a matched entry and preserves the config key that selected it. */ export function resolveChannelMatchConfig< TEntry, TResult extends { matchKey?: string; matchSource?: ChannelMatchSource }, @@ -34,6 +38,7 @@ export function resolveChannelMatchConfig< return applyChannelMatchMeta(resolveEntry(match.entry), match); } +/** Normalizes human channel names into config-safe slugs. */ export function normalizeChannelSlug(value: string): string { return normalizeLowercaseStringOrEmpty(value) .replace(/^#/, "") @@ -41,10 +46,12 @@ export function normalizeChannelSlug(value: string): string { .replace(/^-+|-+$/g, ""); } +/** Builds unique config lookup keys from optional channel/account identifiers. */ export function buildChannelKeyCandidates(...keys: Array): string[] { return normalizeUniqueSingleOrTrimmedStringList(keys); } +/** Finds a direct channel entry and separately carries a wildcard fallback candidate. */ export function resolveChannelEntryMatch(params: { entries?: Record; keys: string[]; @@ -67,6 +74,7 @@ export function resolveChannelEntryMatch(params: { return match; } +/** Resolves config entry precedence: direct, normalized direct, parent, normalized parent, wildcard. */ export function resolveChannelEntryMatchWithFallback(params: { entries?: Record; keys: string[]; @@ -151,6 +159,7 @@ export function resolveChannelEntryMatchWithFallback(params: { return direct; } +/** Resolves nested allowlists where an inner list only applies after the outer list matches. */ export function resolveNestedAllowlistDecision(params: { outerConfigured: boolean; outerMatched: boolean; diff --git a/src/channels/chat-meta-shared.ts b/src/channels/chat-meta-shared.ts index 50610fa64195..173155d3395d 100644 --- a/src/channels/chat-meta-shared.ts +++ b/src/channels/chat-meta-shared.ts @@ -5,6 +5,9 @@ import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js"; import { buildManifestChannelMeta } from "./plugins/channel-meta.js"; import type { ChannelMeta } from "./plugins/types.core.js"; +/** + * Metadata shown for built-in chat channels in setup, status, and selection UIs. + */ export type ChatChannelMeta = ChannelMeta; const CHAT_CHANNEL_ID_SET = new Set(CHAT_CHANNEL_ORDER); @@ -37,6 +40,8 @@ export function buildChatChannelMetaById(): Record(); for (const entry of listBundledChannelCatalogEntries()) { + // The catalog can contain non-chat bundled channels. Keep this map restricted to the + // generated chat-channel order so setup/status views stay stable. const rawId = normalizeOptionalString(entry.id); if (!rawId || !CHAT_CHANNEL_ID_SET.has(rawId)) { continue; diff --git a/src/channels/chat-meta.ts b/src/channels/chat-meta.ts index a30782fe47db..3db66e2978ac 100644 --- a/src/channels/chat-meta.ts +++ b/src/channels/chat-meta.ts @@ -3,6 +3,8 @@ import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "./ids.js"; let chatChannelMetaCache: Record | null = null; +// Built-in channel metadata is process-stable generated/catalog data; cache it so hot setup +// and status paths do not rebuild manifest-derived labels on every read. function getChatChannelMetaById(): Record { chatChannelMetaCache ??= buildChatChannelMetaById(); return chatChannelMetaCache; @@ -10,6 +12,9 @@ function getChatChannelMetaById(): Record { export type { ChatChannelMeta }; +/** + * Lists built-in chat channel metadata in configured display order. + */ export function listChatChannels(): ChatChannelMeta[] { const metaById = getChatChannelMetaById(); return CHAT_CHANNEL_ORDER.map((id) => metaById[id]).filter((meta): meta is ChatChannelMeta => @@ -17,6 +22,9 @@ export function listChatChannels(): ChatChannelMeta[] { ); } +/** + * Returns metadata for one built-in chat channel id. + */ export function getChatChannelMeta(id: ChatChannelId): ChatChannelMeta { return getChatChannelMetaById()[id]; } diff --git a/src/channels/chat-type.ts b/src/channels/chat-type.ts index 4fce0080123e..0c04576de7ec 100644 --- a/src/channels/chat-type.ts +++ b/src/channels/chat-type.ts @@ -1,7 +1,13 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +/** + * Normalized conversation kind shared by channel routing, sessions, and SDK helpers. + */ export type ChatType = "direct" | "group" | "channel"; +/** + * Normalizes channel-specific chat type labels into OpenClaw conversation kinds. + */ export function normalizeChatType(raw?: string): ChatType | undefined { const value = normalizeOptionalLowercaseString(raw); if (!value) { diff --git a/src/channels/command-gating.ts b/src/channels/command-gating.ts index 068db8328bec..d596a3fc854b 100644 --- a/src/channels/command-gating.ts +++ b/src/channels/command-gating.ts @@ -1,13 +1,28 @@ +/** + * Shared text-control command authorization policy for channel runtimes. + * + * These helpers are re-exported through the plugin SDK so built-in and external + * channels make the same access-groups decisions for native command text. + */ + +/** One channel-specific authorization source for text control commands. */ export type CommandAuthorizer = { + /** True when this channel/user identity has an access-group rule configured. */ configured: boolean; + /** True when the configured rule permits the command. Ignored when unconfigured. */ allowed: boolean; }; +/** Fallback policy for channels that have access groups globally disabled. */ export type CommandGatingModeWhenAccessGroupsOff = "allow" | "deny" | "configured"; +/** Resolves whether any configured authorizer permits a control command. */ export function resolveCommandAuthorizedFromAuthorizers(params: { + /** Global access-group switch for the channel/runtime. */ useAccessGroups: boolean; + /** Independent authorization sources, such as sender id and actor id. */ authorizers: CommandAuthorizer[]; + /** Policy used only when `useAccessGroups` is false. Defaults to open. */ modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff; }): boolean { const { useAccessGroups, authorizers } = params; @@ -19,6 +34,8 @@ export function resolveCommandAuthorizedFromAuthorizers(params: { if (mode === "deny") { return false; } + // `configured` preserves the old open-by-default behavior until a channel has at least one + // command authorizer configured, then enforces that configured source. const anyConfigured = authorizers.some((entry) => entry.configured); if (!anyConfigured) { return true; @@ -28,11 +45,17 @@ export function resolveCommandAuthorizedFromAuthorizers(params: { return authorizers.some((entry) => entry.configured && entry.allowed); } +/** Resolves command authorization and whether the current text command should be blocked. */ export function resolveControlCommandGate(params: { + /** Global access-group switch for the channel/runtime. */ useAccessGroups: boolean; + /** Authorization sources checked by this channel command. */ authorizers: CommandAuthorizer[]; + /** Channel setting that enables text commands as an input surface. */ allowTextCommands: boolean; + /** True when the current inbound message parsed as a control command. */ hasControlCommand: boolean; + /** Policy used only when `useAccessGroups` is false. Defaults to open. */ modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff; }): { commandAuthorized: boolean; shouldBlock: boolean } { const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ @@ -44,15 +67,25 @@ export function resolveControlCommandGate(params: { return { commandAuthorized, shouldBlock }; } +/** Convenience gate for channels that check primary and secondary text command identities. */ export function resolveDualTextControlCommandGate(params: { + /** Global access-group switch for the channel/runtime. */ useAccessGroups: boolean; + /** Whether the primary identity has an access-group rule. */ primaryConfigured: boolean; + /** Whether the primary configured rule permits the command. */ primaryAllowed: boolean; + /** Whether the secondary identity has an access-group rule. */ secondaryConfigured: boolean; + /** Whether the secondary configured rule permits the command. */ secondaryAllowed: boolean; + /** True when the current inbound message parsed as a control command. */ hasControlCommand: boolean; + /** Policy used only when `useAccessGroups` is false. Defaults to open. */ modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff; }): { commandAuthorized: boolean; shouldBlock: boolean } { + // Treat primary and secondary identities as independent authorization sources; channels use + // this when a text command can come from either a sender id or a platform-specific actor id. return resolveControlCommandGate({ useAccessGroups: params.useAccessGroups, authorizers: [ diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 02576f6f8e86..67dcd4db4a2e 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -29,6 +29,7 @@ type ChannelPresenceOptions = { }; }; +/** Source that made a channel look potentially configured. */ export type ChannelPresenceSignalSource = "config" | "env" | "persisted-auth"; type ChannelPresenceSignal = { @@ -36,13 +37,17 @@ type ChannelPresenceSignal = { source: ChannelPresenceSignalSource; }; +/** Returns true when a channel config entry contains settings beyond enabled/disabled state. */ export function hasMeaningfulChannelConfig(value: unknown): boolean { if (!isRecord(value)) { return false; } + // `enabled` alone is operator intent, not configuration material; setup/status code uses this + // distinction to avoid treating explicit disables as configured channels. return Object.keys(value).some((key) => key !== "enabled"); } +/** Lists channels explicitly disabled in config so activation logic can suppress auto-detection. */ export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig): string[] { const channels = isRecord(cfg.channels) ? cfg.channels : null; if (!channels) { @@ -57,6 +62,7 @@ export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig): function listChannelEnvPrefixes( channelIds: readonly string[], ): Array<[prefix: string, channelId: string]> { + // Match channel-owned env namespaces such as MATRIX_* without hardcoding bundled ids here. return channelIds.map((channelId) => [ `${channelId.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_`, channelId, @@ -80,6 +86,7 @@ function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): read if (persistedAuthStateChannelIds) { return persistedAuthStateChannelIds; } + // Bundled plugin metadata is process-stable; cache the static persisted-auth id list. persistedAuthStateChannelIds = listBundledChannelIdsWithPersistedAuthState(); return persistedAuthStateChannelIds; } @@ -102,6 +109,7 @@ function hasPersistedAuthState(params: { }); } +/** Lists channel ids detected from config, env vars, or persisted auth state. */ export function listPotentialConfiguredChannelIds( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -114,6 +122,7 @@ export function listPotentialConfiguredChannelIds( ); } +/** Lists deduplicated channel presence signals with their detection source. */ export function listPotentialConfiguredChannelPresenceSignals( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -138,6 +147,8 @@ export function listPotentialConfiguredChannelPresenceSignals( if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { continue; } + // Shared channel defaults are not concrete channel configuration; only per-channel entries + // with meaningful settings should produce presence signals. if (hasMeaningfulChannelConfig(value)) { configuredChannelIds.add(key); addSignal(key, "config"); @@ -158,6 +169,8 @@ export function listPotentialConfiguredChannelPresenceSignals( } if (options.includePersistedAuthState !== false && hasPersistedChannelState(env)) { + // Persisted auth can make a channel usable even when config/env is empty, but only probe it + // when the state directory exists to keep startup/status checks cheap. for (const channelId of listPersistedAuthStateChannelIds(options)) { if (hasPersistedAuthState({ channelId, cfg, env, options })) { configuredChannelIds.add(channelId); @@ -192,6 +205,7 @@ function hasEnvConfiguredChannel( ); } +/** Returns true when any channel appears configured from config, env, or persisted auth state. */ export function hasPotentialConfiguredChannels( cfg: OpenClawConfig | null | undefined, env: NodeJS.ProcessEnv = process.env, diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts index b0ae8b79207a..a7b4e50e78d9 100644 --- a/src/channels/conversation-binding-context.ts +++ b/src/channels/conversation-binding-context.ts @@ -1,9 +1,14 @@ +/** + * Conversation-binding key resolver shared by plugin commands and reply/session actions. + * Binding keys must use canonical routing ids so focus/unfocus targets survive aliases and hints. + */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveCommandConversationResolution, type ResolveCommandConversationResolutionInput, } from "./conversation-resolution.js"; +/** Canonical identity tuple used as the stable key for conversation binding state. */ type ConversationBindingContext = { channel: string; accountId: string; @@ -19,11 +24,15 @@ type ResolveConversationBindingContextInput = Omit< cfg: OpenClawConfig; }; +/** + * Resolves the canonical channel/account/conversation tuple used for conversation bindings. + */ export function resolveConversationBindingContext( params: ResolveConversationBindingContextInput, ): ConversationBindingContext | null { const resolution = resolveCommandConversationResolution({ ...params, + // Binding keys must stay canonical; placement hints are only user-facing routing guidance. includePlacementHint: false, }); if (!resolution) { diff --git a/src/channels/conversation-label.ts b/src/channels/conversation-label.ts index 2d87ea03184d..33d65d9f334d 100644 --- a/src/channels/conversation-label.ts +++ b/src/channels/conversation-label.ts @@ -5,6 +5,8 @@ import { import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeChatType } from "./chat-type.js"; +// Many channel ids are compound strings such as channel:thread:id. The label suffix only +// needs the final stable id segment when the friendly name alone may be ambiguous. function extractConversationId(from?: string): string | undefined { const trimmed = normalizeOptionalString(from); if (!trimmed) { @@ -14,6 +16,8 @@ function extractConversationId(from?: string): string | undefined { return parts.length > 0 ? parts[parts.length - 1] : trimmed; } +// Numeric ids and address-like ids are useful disambiguators. Human labels, hashtags, +// and handles are already readable enough and should not get redundant "id:" suffixes. function shouldAppendId(id: string): boolean { if (/^[0-9]+$/.test(id)) { return true; @@ -24,6 +28,9 @@ function shouldAppendId(id: string): boolean { return false; } +/** + * Resolves the most readable conversation label from normalized inbound message context. + */ export function resolveConversationLabel(ctx: MsgContext): string | undefined { const explicit = normalizeOptionalString(ctx.ConversationLabel); if (explicit) { diff --git a/src/channels/conversation-resolution.ts b/src/channels/conversation-resolution.ts index 6853a395f436..0a77c51c83e2 100644 --- a/src/channels/conversation-resolution.ts +++ b/src/channels/conversation-resolution.ts @@ -1,3 +1,7 @@ +/** + * Canonical conversation resolution for command and inbound channel flows. + * This module turns channel targets, thread ids, aliases, and plugin hooks into stable binding ids. + */ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -45,6 +49,9 @@ type ConversationResolution = { source: ConversationResolutionSource; }; +/** + * Command-side inputs used to resolve a canonical conversation binding target. + */ export type ResolveCommandConversationResolutionInput = { cfg: OpenClawConfig; channel?: string | null; @@ -282,6 +289,9 @@ function buildThreadingContext(params: { }; } +/** + * Resolves whether top-level bindings default to the current conversation or a child thread. + */ export function resolveChannelDefaultBindingPlacement( rawChannel?: string | null, ): "current" | "child" | undefined { @@ -294,6 +304,9 @@ export function resolveChannelDefaultBindingPlacement( return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel); } +/** + * Resolves command context into a canonical channel/account/conversation tuple. + */ export function resolveCommandConversationResolution( params: ResolveCommandConversationResolutionInput, ): ConversationResolution | null { @@ -401,6 +414,9 @@ export function resolveCommandConversationResolution( }); } +/** + * Resolves inbound message context into the canonical binding conversation tuple. + */ export function resolveInboundConversationResolution( params: ResolveInboundConversationResolutionInput, ): ConversationResolution | null { @@ -437,6 +453,8 @@ export function resolveInboundConversationResolution( plugin, }); if (providerResolution || providerConversation === null) { + // A null provider response is an explicit rejection, not a signal to try + // bundled/fallback parsing for the same inbound target. return providerResolution; } @@ -453,6 +471,7 @@ export function resolveInboundConversationResolution( plugin, }); if (artifactResolution || artifactConversation === null) { + // Lightweight bundled artifacts can also reject targets before full plugin loading. return artifactResolution; } diff --git a/src/channels/direct-dm-access.ts b/src/channels/direct-dm-access.ts index e3abd77e64e0..d9ffbfaacea8 100644 --- a/src/channels/direct-dm-access.ts +++ b/src/channels/direct-dm-access.ts @@ -14,6 +14,7 @@ import { import type { ChannelId } from "./plugins/types.public.js"; export type { AccessGroupMembershipResolver } from "../plugin-sdk/access-groups.js"; +/** Runtime hooks needed by the legacy direct-DM access resolver. */ export type DirectDmCommandAuthorizationRuntime = { shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; /** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */ @@ -24,7 +25,10 @@ export type DirectDmCommandAuthorizationRuntime = { }) => boolean; }; -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Legacy direct-DM ingress decision with command-authorization compatibility fields. + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export type ResolvedInboundDirectDmAccess = { access: { decision: "allow" | "block" | "pairing"; @@ -50,7 +54,10 @@ function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode { } } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolves legacy direct-DM access lists, pairing-store entries, and command authorization. + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export async function resolveInboundDirectDmAccessWithRuntime(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -79,6 +86,8 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { readStore: params.readStoreAllowFrom, }) : []; + // Pairing-mode store entries and configured allowlists both support access groups. + // Expand them separately so the access reason still reflects the source list. const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([ expandAllowFromWithAccessGroups({ cfg: params.cfg, @@ -112,6 +121,8 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { params.senderId, access.effectiveAllowFrom, ); + // Older channel plugins inject their command authorizer here. When absent, + // preserve the legacy direct-DM behavior: command access follows sender allowlist access. const commandAuthorized = shouldComputeAuth ? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({ useAccessGroups: params.cfg.commands?.useAccessGroups !== false, @@ -138,7 +149,10 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { }; } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Creates a pre-crypto authorizer that can issue pairing challenges before payload decryption. + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: ( senderId: string, @@ -164,6 +178,8 @@ export function createPreCryptoDirectDmAuthorizer(params: { } if (access.decision === "pairing") { if (params.issuePairingChallenge) { + // Pairing challenges happen before decrypting the DM payload; keep this branch + // side-effect free apart from the explicit reply hook. await params.issuePairingChallenge({ senderId: input.senderId, reply: input.reply, diff --git a/src/channels/direct-dm-guard-policy.ts b/src/channels/direct-dm-guard-policy.ts index 44c3cfcfc049..89ddeef00482 100644 --- a/src/channels/direct-dm-guard-policy.ts +++ b/src/channels/direct-dm-guard-policy.ts @@ -1,28 +1,41 @@ import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; +/** Runtime limits applied before direct-DM encrypted payloads are decrypted. */ export type DirectDmPreCryptoGuardPolicy = { + /** Accepted encrypted event kinds before decryption, e.g. Nostr kind 4. */ allowedKinds: readonly number[]; + /** Maximum sender timestamp skew allowed into the future. */ maxFutureSkewSec: number; + /** Maximum encrypted payload bytes accepted before decrypt work starts. */ maxCiphertextBytes: number; + /** Maximum decrypted plaintext bytes accepted after decrypt succeeds. */ maxPlaintextBytes: number; + /** Per-sender and global throttles for encrypted DM ingress. */ rateLimit: { + /** Fixed rate-limit window size. */ windowMs: number; + /** Maximum messages per sender key inside one window. */ maxPerSenderPerWindow: number; + /** Maximum messages across all sender keys inside one window. */ maxGlobalPerWindow: number; + /** Maximum sender keys retained by the in-memory limiter. */ maxTrackedSenderKeys: number; }; }; +/** Partial overrides for channel plugins that need stricter pre-crypto limits. */ export type DirectDmPreCryptoGuardPolicyOverrides = Partial< Omit > & { rateLimit?: Partial; }; -/** Shared policy object for DM-style pre-crypto guardrails. */ +/** Builds the shared policy object for DM-style pre-crypto guardrails. */ export function createDirectDmPreCryptoGuardPolicy( overrides: DirectDmPreCryptoGuardPolicyOverrides = {}, ): DirectDmPreCryptoGuardPolicy { + // Defaults must be conservative before decrypt: cheap shape/size/rate checks + // happen before channel plugins spend CPU or allocate plaintext buffers. const defaultMaxFutureSkewSec = 120; const defaultMaxCiphertextBytes = 16 * 1024; const defaultMaxPlaintextBytes = 8 * 1024; diff --git a/src/channels/draft-stream-controls.ts b/src/channels/draft-stream-controls.ts index 689b86fc0775..2f68f54fc073 100644 --- a/src/channels/draft-stream-controls.ts +++ b/src/channels/draft-stream-controls.ts @@ -1,6 +1,9 @@ import { formatErrorMessage } from "../infra/errors.js"; import { createDraftStreamLoop } from "./draft-stream-loop.js"; +/** + * Mutable finalization flags shared by draft stream controls and channel adapters. + */ export type FinalizableDraftStreamState = { stopped: boolean; final: boolean; @@ -29,6 +32,9 @@ type FinalizableDraftLifecycleParams = Omit< sendOrEditStreamMessage: (text: string) => Promise; }; +/** + * Creates controls for streaming preview messages that can be finalized, sealed, or cleared. + */ export function createFinalizableDraftStreamControls(params: { throttleMs: number; isStopped: () => boolean; @@ -44,6 +50,8 @@ export function createFinalizableDraftStreamControls(params: { }); const update = (text: string) => { + // Finalized or stopped streams must ignore late model deltas so a deleted/posted draft is + // not recreated by an in-flight throttle tick. if (params.isStopped() || params.isFinal()) { return; } @@ -51,17 +59,20 @@ export function createFinalizableDraftStreamControls(params: { }; const stop = async (): Promise => { + // stop finalizes by flushing the latest pending text into the preview message. params.markFinal(); await loop.flush(); }; const stopForClear = async (): Promise => { + // Clearing deletes the preview, so stop the loop without flushing another edit first. params.markStopped(); loop.stop(); await loop.waitForInFlight(); }; const seal = async (): Promise => { + // Sealing keeps the preview id for callers that already own final delivery/deletion. params.markFinal(); loop.stop(); await loop.waitForInFlight(); @@ -77,6 +88,9 @@ export function createFinalizableDraftStreamControls(params: { }; } +/** + * Creates finalizable draft controls backed by a shared mutable state object. + */ export function createFinalizableDraftStreamControlsForState(params: { throttleMs: number; state: FinalizableDraftStreamState; @@ -96,6 +110,9 @@ export function createFinalizableDraftStreamControlsForState(params: { }); } +/** + * Stops a draft stream, reads the current preview message id, then clears the stored id. + */ export async function takeMessageIdAfterStop( params: StopAndClearMessageIdParams, ): Promise { @@ -105,6 +122,9 @@ export async function takeMessageIdAfterStop( return messageId; } +/** + * Stops a draft stream and deletes its preview message when the stored id is valid. + */ export async function clearFinalizableDraftMessage( params: ClearFinalizableDraftMessageParams, ): Promise { @@ -124,6 +144,9 @@ export async function clearFinalizableDraftMessage( } } +/** + * Builds the standard draft lifecycle used by channel streaming preview implementations. + */ export function createFinalizableDraftLifecycle(params: FinalizableDraftLifecycleParams) { const controls = createFinalizableDraftStreamControlsForState({ throttleMs: params.throttleMs, diff --git a/src/channels/ids.ts b/src/channels/ids.ts index a1e752136605..5355be6480a8 100644 --- a/src/channels/ids.ts +++ b/src/channels/ids.ts @@ -2,6 +2,9 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../config/bundled-channel-config-metadata.generated.js"; import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js"; +/** + * Canonical chat channel id used by core routing, plugin config, and channel catalogs. + */ export type ChatChannelId = string; type BundledChatChannelEntry = { @@ -27,12 +30,21 @@ const BUNDLED_CHAT_CHANNEL_ENTRIES = Object.freeze(listBundledChatChannelEntries const CHAT_CHANNEL_ID_SET = new Set(BUNDLED_CHAT_CHANNEL_ENTRIES.map((entry) => entry.id)); let runtimeBundledChatChannelEntries: BundledChatChannelEntry[] | null = null; +/** + * Stable built-in channel order derived from generated bundled channel metadata. + */ export const CHAT_CHANNEL_ORDER = Object.freeze( BUNDLED_CHAT_CHANNEL_ENTRIES.map((entry) => entry.id), ); +/** + * Alias retained for callers that still refer to chat channel ordering as channel ids. + */ export const CHANNEL_IDS = CHAT_CHANNEL_ORDER; +/** + * Maps configured built-in channel aliases to canonical chat channel ids. + */ export const CHAT_CHANNEL_ALIASES: Record = Object.freeze( Object.fromEntries( BUNDLED_CHAT_CHANNEL_ENTRIES.flatMap((entry) => @@ -41,11 +53,16 @@ export const CHAT_CHANNEL_ALIASES: Record = Object.freeze ), ) as Record; +/** + * Lists configured built-in chat channel aliases. + */ export function listChatChannelAliases(): string[] { return Object.keys(CHAT_CHANNEL_ALIASES); } function listRuntimeBundledChatChannelEntries(): BundledChatChannelEntry[] { + // Generated metadata is the hot-path source. The runtime catalog fallback covers + // dynamically registered bundled metadata without repeated catalog reads. runtimeBundledChatChannelEntries ??= listBundledChannelCatalogEntries().map((entry) => ({ id: entry.id, aliases: entry.aliases, @@ -63,6 +80,9 @@ function normalizeRuntimeBundledChatChannelId(normalized: string): ChatChannelId return null; } +/** + * Normalizes a raw chat channel id or alias to a known canonical built-in channel id. + */ export function normalizeChatChannelId(raw?: string | null): ChatChannelId | null { const normalized = normalizeOptionalLowercaseString(raw); if (!normalized) { diff --git a/src/channels/inbound-debounce-policy.ts b/src/channels/inbound-debounce-policy.ts index 790fed8b8d42..302af7fa4bf8 100644 --- a/src/channels/inbound-debounce-policy.ts +++ b/src/channels/inbound-debounce-policy.ts @@ -8,6 +8,7 @@ import { } from "../auto-reply/inbound-debounce.js"; import type { OpenClawConfig } from "../config/types.js"; +/** Returns true when an inbound text event is safe to debounce before dispatch. */ export function shouldDebounceTextInbound(params: { text: string | null | undefined; cfg: OpenClawConfig; @@ -19,15 +20,20 @@ export function shouldDebounceTextInbound(params: { return false; } if (params.hasMedia) { + // Media payloads carry per-message attachments; merging them into a debounced text batch can + // detach the attachment metadata from the original inbound event. return false; } const text = normalizeOptionalString(params.text) ?? ""; if (!text) { return false; } + // Control commands must dispatch immediately so stop/abort/status requests are not delayed + // behind normal conversation text. return !isControlCommandMessage(text, params.cfg, params.commandOptions); } +/** Creates a channel-scoped inbound debouncer using config/default debounce timing. */ export function createChannelInboundDebouncer( params: Omit, "debounceMs"> & { cfg: OpenClawConfig; @@ -44,6 +50,8 @@ export function createChannelInboundDebouncer( overrideMs: params.debounceMsOverride, }); const { cfg: _cfg, channel: _channel, debounceMsOverride: _override, ...rest } = params; + // The lower-level debouncer only needs queue callbacks and timing. Strip config-only inputs so + // future helper options do not accidentally leak into its runtime shape. const debouncer = createInboundDebouncer({ debounceMs, ...rest, diff --git a/src/channels/inbound-event/classification.ts b/src/channels/inbound-event/classification.ts index dc275d8b296c..e4a805675ba8 100644 --- a/src/channels/inbound-event/classification.ts +++ b/src/channels/inbound-event/classification.ts @@ -3,6 +3,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ConversationFacts } from "../turn/types.js"; import type { InboundEventKind } from "./kind.js"; +/** + * Facts needed to classify whether inbound room activity should wake the agent. + */ export type ClassifyChannelInboundEventParams = { conversation: Pick; unmentionedGroupPolicy?: InboundEventKind; @@ -12,6 +15,9 @@ export type ClassifyChannelInboundEventParams = { commandSource?: "native" | "text"; }; +/** + * Classifies an inbound channel event as an actionable request or passive room event. + */ export function classifyChannelInboundEvent( params: ClassifyChannelInboundEventParams, ): InboundEventKind { @@ -21,6 +27,8 @@ export function classifyChannelInboundEvent( if (params.conversation.kind !== "group" && params.conversation.kind !== "channel") { return "user_request"; } + // Native commands, mentions, control commands, and aborts are explicit user intent even when + // unmentioned group traffic would otherwise be treated as passive room activity. if ( params.wasMentioned === true || params.hasControlCommand === true || @@ -32,6 +40,9 @@ export function classifyChannelInboundEvent( return "room_event"; } +/** + * Resolves the configured policy for unmentioned group/channel inbound events. + */ export function resolveUnmentionedGroupInboundPolicy(params: { cfg: OpenClawConfig; agentId?: string; diff --git a/src/channels/inbound-event/kind.ts b/src/channels/inbound-event/kind.ts index 1719db1995b2..94d6727eeadf 100644 --- a/src/channels/inbound-event/kind.ts +++ b/src/channels/inbound-event/kind.ts @@ -1 +1,4 @@ +/** + * High-level inbound event class used to separate actionable user requests from room activity. + */ export type InboundEventKind = "user_request" | "room_event"; diff --git a/src/channels/inbound-event/media.ts b/src/channels/inbound-event/media.ts index 73e497c55815..8df8c214b3f9 100644 --- a/src/channels/inbound-event/media.ts +++ b/src/channels/inbound-event/media.ts @@ -2,6 +2,9 @@ import { normalizeOptionalString as normalizeString } from "@openclaw/normalizat import type { HistoryMediaEntry } from "../../auto-reply/reply/history.types.js"; import type { InboundMediaFacts } from "../turn/types.js"; +/** + * Attachment metadata accepted from channel plugins before core normalization. + */ export type ChannelInboundMediaInput = { path?: string | null; url?: string | null; @@ -11,6 +14,9 @@ export type ChannelInboundMediaInput = { messageId?: string | null; }; +/** + * Environment payload fields consumed by prompt/context builders for inbound media attachments. + */ export type ChannelInboundMediaPayload = { MediaPath?: string; MediaUrl?: string; @@ -25,6 +31,8 @@ function alignedStrings(values: Array): string[] | undefined if (!values.some(Boolean)) { return undefined; } + // Preserve indexes across parallel Media* arrays so transcribed indexes and + // media metadata continue to refer to the same attachment. return values.map((value) => value ?? ""); } @@ -36,6 +44,9 @@ function mediaType(media: InboundMediaFacts): string | undefined { return media.contentType ?? media.kind; } +/** + * Normalizes plugin-provided attachment facts into the channel turn media shape. + */ export function toInboundMediaFacts( media: readonly ChannelInboundMediaInput[] | null | undefined, defaults: { @@ -57,6 +68,9 @@ export function toInboundMediaFacts( })); } +/** + * Projects inbound attachment facts into transcript history without transient turn-only flags. + */ export function toHistoryMediaEntries( media: readonly ChannelInboundMediaInput[] | null | undefined, defaults: { @@ -73,6 +87,9 @@ export function toHistoryMediaEntries( })); } +/** + * Builds prompt environment media fields while keeping single-item legacy fields populated. + */ export function buildChannelInboundMediaPayload( media: readonly InboundMediaFacts[] | null | undefined, ): ChannelInboundMediaPayload { diff --git a/src/channels/location.ts b/src/channels/location.ts index 6ecafed660c9..cab1db3dfad0 100644 --- a/src/channels/location.ts +++ b/src/channels/location.ts @@ -1,5 +1,7 @@ +/** Normalized source kind for channel-provided geographic locations. */ export type LocationSource = "pin" | "place" | "live"; +/** Channel-neutral location payload passed from plugins into shared prompt rendering. */ export type NormalizedLocation = { latitude: number; longitude: number; @@ -11,12 +13,14 @@ export type NormalizedLocation = { caption?: string; }; +/** Location payload after default source and live-state inference. */ type ResolvedLocation = NormalizedLocation & { source: LocationSource; isLive: boolean; }; function resolveLocation(location: NormalizedLocation): ResolvedLocation { + // Infer once so text formatting and structured context agree on pin/place/live semantics. const source = location.source ?? (location.isLive ? "live" : location.name || location.address ? "place" : "pin"); @@ -35,6 +39,12 @@ function formatCoords(latitude: number, longitude: number): string { return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`; } +/** + * Formats the safe inline location body shown to the model. + * + * Channel-provided labels, addresses, and captions are intentionally excluded + * here; `toLocationContext` carries them into the untrusted metadata block. + */ export function formatLocationText(location: NormalizedLocation): string { const resolved = resolveLocation(location); const coords = formatCoords(resolved.latitude, resolved.longitude); @@ -47,6 +57,7 @@ export function formatLocationText(location: NormalizedLocation): string { return `📍 ${coords}${accuracy}`; } +/** Converts a normalized location into template context fields for prompt metadata. */ export function toLocationContext(location: NormalizedLocation): { LocationLat: number; LocationLon: number; diff --git a/src/channels/logging.ts b/src/channels/logging.ts index 0e124a14dbc9..edf67d7048f5 100644 --- a/src/channels/logging.ts +++ b/src/channels/logging.ts @@ -1,5 +1,11 @@ +/** + * Shared channel diagnostic formatters exposed through the plugin SDK. + * Keep messages compact and stable enough for plugin logs without making them machine contracts. + */ +/** Minimal logger callback shape exposed through channel SDK helpers. */ export type LogFn = (message: string) => void; +/** Emits a normalized inbound-drop diagnostic for channel plugins. */ export function logInboundDrop(params: { log: LogFn; channel: string; @@ -10,6 +16,7 @@ export function logInboundDrop(params: { params.log(`${params.channel}: drop ${params.reason}${target}`); } +/** Emits a normalized typing-indicator failure diagnostic for channel plugins. */ export function logTypingFailure(params: { log: LogFn; channel: string; @@ -22,6 +29,7 @@ export function logTypingFailure(params: { params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`); } +/** Emits a normalized acknowledgement-cleanup failure diagnostic for channel plugins. */ export function logAckFailure(params: { log: LogFn; channel: string; diff --git a/src/channels/mention-pattern-policy.ts b/src/channels/mention-pattern-policy.ts index 84d364025e8b..6f7480a53705 100644 --- a/src/channels/mention-pattern-policy.ts +++ b/src/channels/mention-pattern-policy.ts @@ -2,6 +2,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import type { MentionPatternsMode, MentionPatternsPolicyConfig } from "../config/types.messages.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** + * Inputs for resolving whether mention-pattern matching is enabled in a conversation. + */ export type ResolveMentionPatternPolicyParams = { cfg?: OpenClawConfig; provider?: string; @@ -10,6 +13,9 @@ export type ResolveMentionPatternPolicyParams = { agentId?: string; }; +/** + * Effective mention-pattern policy after provider and conversation allow/deny rules. + */ export type ResolvedMentionPatternPolicy = { effectiveMode: MentionPatternsMode; allowMatched: boolean; @@ -48,6 +54,9 @@ function resolveProviderMentionPatternsPolicy( return isMentionPatternsPolicyConfig(policy) ? policy : undefined; } +/** + * Resolves provider-scoped mention-pattern policy for a single conversation. + */ export function resolveMentionPatternPolicy( params: ResolveMentionPatternPolicyParams, ): ResolvedMentionPatternPolicy { @@ -62,6 +71,8 @@ export function resolveMentionPatternPolicy( conversationId != null && normalizeIdList(providerPolicy?.allowIn).has(conversationId); const denyMatched = conversationId != null && normalizeIdList(providerPolicy?.denyIn).has(conversationId); + // Deny always wins. In allow mode everything is enabled except explicit denies; in deny mode + // only explicitly allowed conversations are enabled. const enabled = effectiveMode === "allow" ? !denyMatched : allowMatched && !denyMatched; return { effectiveMode, allowMatched, denyMatched, enabled }; diff --git a/src/channels/message-access/allowlist.ts b/src/channels/message-access/allowlist.ts index 24a554f2abd4..9369c1175e4b 100644 --- a/src/channels/message-access/allowlist.ts +++ b/src/channels/message-access/allowlist.ts @@ -8,6 +8,9 @@ import type { ResolvedIngressAllowlist, } from "./types.js"; +/** + * Returns the first access-group related failure reason for an allowlist. + */ export function allowlistFailureReason( allowlist: ResolvedIngressAllowlist, ): IngressReasonCode | null { @@ -23,6 +26,9 @@ export function allowlistFailureReason( return null; } +/** + * Projects an allowlist into redacted diagnostics safe for ingress access graphs. + */ export function redactedAllowlistDiagnostics( allowlist: ResolvedIngressAllowlist, reasonCode: IngressReasonCode, @@ -72,6 +78,9 @@ function mergeResolvedAllowlists( }; } +/** + * Applies mutable identifier matching policy to an already-resolved allowlist. + */ export function applyMutableIdentifierPolicy( allowlist: ResolvedIngressAllowlist, policy: ChannelIngressPolicyInput, @@ -87,6 +96,8 @@ export function applyMutableIdentifierPolicy( if (dangerousEntryIds.size === 0) { return allowlist; } + // Username-like mutable identifiers can be present for diagnostics, but when the policy + // disables them they must not authorize a sender. const matchedEntryIds = allowlist.matchedEntryIds.filter((id) => !dangerousEntryIds.has(id)); const disabledEntries: RedactedIngressEntryDiagnostic[] = [ ...allowlist.disabledEntries, @@ -109,6 +120,9 @@ export function applyMutableIdentifierPolicy( }; } +/** + * Resolves the sender allowlist used for group/channel ingress after route overrides. + */ export function effectiveGroupSenderAllowlist(params: { state: ChannelIngressState; policy: ChannelIngressPolicyInput; @@ -126,6 +140,7 @@ export function effectiveGroupSenderAllowlist(params: { effective = mergeResolvedAllowlists([effective, route.senderAllowlist]); continue; } + // Route sender policies other than inherit replace the channel-level sender allowlist. effective = route.senderAllowlist; } return applyMutableIdentifierPolicy(effective, params.policy); diff --git a/src/channels/message-access/decision.ts b/src/channels/message-access/decision.ts index 75c6807012b5..8c9346d607b9 100644 --- a/src/channels/message-access/decision.ts +++ b/src/channels/message-access/decision.ts @@ -31,6 +31,8 @@ function decisiveDecision(params: { } function routeGates(state: ChannelIngressState): AccessGraphGate[] { + // Route gates run first because a matched route can block dispatch before sender, + // command, or mention policy needs to evaluate. return state.routeFacts.map((route) => ({ id: route.id, phase: "route", @@ -43,6 +45,8 @@ function routeGates(state: ChannelIngressState): AccessGraphGate[] { } function routeSenderEmptyGate(state: ChannelIngressState): AccessGraphGate | null { + // deny-when-empty route sender policy means the route matched but has no sender list to + // authorize against, so it becomes an explicit route block. const route = state.routeFacts.find( (fact) => fact.senderPolicy === "deny-when-empty" && @@ -83,6 +87,8 @@ function commandGate(params: { }; } const useAccessGroups = command.useAccessGroups ?? true; + // Command authorization combines owner and group allowlists after mutable-id policy so + // command control cannot be granted by identifiers the current policy rejects. const owner = applyMutableIdentifierPolicy(params.state.allowlists.commandOwner, params.policy); const group = applyMutableIdentifierPolicy(params.state.allowlists.commandGroup, params.policy); const authorized = resolveCommandAuthorizedFromAuthorizers({ @@ -145,12 +151,15 @@ function eventGate(params: { return eventResult(true, "event_authorized"); } if (authMode === "command") { + // Command-auth events, such as button or slash command callbacks, inherit the command gate + // result instead of re-checking the sender allowlist. return eventResult( params.commandGate.allowed, params.commandGate.allowed ? "event_authorized" : "event_unauthorized", ); } if (authMode === "origin-subject") { + // Origin-subject mode is used for callbacks tied to a prior message/user identity. if (!params.state.event.hasOriginSubject) { return eventResult(false, "origin_subject_missing"); } @@ -228,6 +237,7 @@ function activationGate(params: { }), }); if (!activation || !mentionFacts) { + // Without activation policy or mention facts, sender/event authorization is enough. return activationResult({ shouldSkip: false, effectiveWasMentioned: @@ -267,6 +277,8 @@ export function decideChannelIngress( return decisiveDecision({ admission: "drop", decision: "block", gate: routeBlock, gates }); } + // Some channels want mention gating before sender checks so unmentioned room chatter can + // short-circuit without exposing sender allowlist diagnostics. const activationBeforeSender = policy.activation?.order === "before-sender" && !policy.activation.allowTextCommands ? activationGate({ @@ -291,6 +303,7 @@ export function decideChannelIngress( state.conversationKind === "direct" ? senderGateForDirect({ state, policy }) : senderGateForGroup({ state, policy }); + // Event auth mode can relax or tighten how sender gates affect non-message events. const eventModeSender = applyEventAuthModeToSenderGate({ state, senderGate: sender }); gates.push(eventModeSender); if (!eventModeSender.allowed) { diff --git a/src/channels/message-access/dm-allow-state.ts b/src/channels/message-access/dm-allow-state.ts index 18f67a1c1106..6e14f3a89c9e 100644 --- a/src/channels/message-access/dm-allow-state.ts +++ b/src/channels/message-access/dm-allow-state.ts @@ -2,6 +2,8 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm import type { ChannelId } from "../plugins/types.public.js"; import { readChannelIngressStoreAllowFromForDmPolicy } from "./runtime.js"; +// Builds the normalized DM allowlist state used by audits and setup prompts. +// Config and persisted ingress-store entries are merged before counting users. export async function resolveDmAllowAuditState(params: { provider: ChannelId; accountId: string; diff --git a/src/channels/message-access/index.ts b/src/channels/message-access/index.ts index 81de575841e9..a6bcba621299 100644 --- a/src/channels/message-access/index.ts +++ b/src/channels/message-access/index.ts @@ -1,3 +1,5 @@ +// Public channel ingress/message-access barrel. Keep this as the narrow import +// point for callers that need access decisions without plugin internals. export { decideChannelIngress } from "./decision.js"; export { defineStableChannelIngressIdentity } from "./runtime-identity.js"; export { diff --git a/src/channels/message-access/runtime-access-groups.ts b/src/channels/message-access/runtime-access-groups.ts index 0e0acdb97427..91a07062af9c 100644 --- a/src/channels/message-access/runtime-access-groups.ts +++ b/src/channels/message-access/runtime-access-groups.ts @@ -14,12 +14,18 @@ function accessGroupNames(entries: readonly (string | number)[]): string[] { ); } +/** + * Lists every access-group name referenced by grouped allowFrom entry arrays. + */ export function allReferencedAccessGroupNames( entries: Array, ): string[] { return uniqueStrings(entries.flatMap((entryGroup) => accessGroupNames(entryGroup))); } +/** + * Normalizes direct sender entries while preserving access-group references for runtime lookup. + */ export async function normalizeEffectiveEntries(params: { adapter: ChannelIngressAdapter; accountId: string; @@ -34,6 +40,8 @@ export async function normalizeEffectiveEntries(params: { if (directEntries.length === 0) { return accessGroupEntries; } + // Direct entries need adapter normalization for the current channel/account; access-group + // entries stay symbolic until membership facts are resolved. const normalized = await params.adapter.normalizeEntries({ entries: directEntries, context: params.context, @@ -45,6 +53,9 @@ export async function normalizeEffectiveEntries(params: { ]); } +/** + * Resolves dynamic access-group membership facts for referenced runtime access groups. + */ export async function resolveRuntimeAccessGroupMembershipFacts(params: { input: ResolveChannelMessageIngressParams; channelId: ChannelIngressChannelId; @@ -56,6 +67,8 @@ export async function resolveRuntimeAccessGroupMembershipFacts(params: { const facts: AccessGroupMembershipFact[] = []; for (const name of params.names) { const group = params.input.accessGroups?.[name]; + // Static message.senders groups are expanded during allowlist normalization; runtime + // membership hooks only evaluate dynamic/non-sender access-group types. if (!group || group.type === "message.senders") { continue; } diff --git a/src/channels/message-access/sender-gates.ts b/src/channels/message-access/sender-gates.ts index e47f55c244b3..00929f49278a 100644 --- a/src/channels/message-access/sender-gates.ts +++ b/src/channels/message-access/sender-gates.ts @@ -21,6 +21,8 @@ function senderGate(params: { policy: ChannelIngressPolicyInput["dmPolicy"] | ChannelIngressPolicyInput["groupPolicy"]; allowlistSource: ResolvedIngressAllowlist; }): AccessGraphGate { + // Sender gates always include redacted allowlist facts so diagnostics can explain an + // allow/block result without exposing raw sender ids. return { id: params.id, phase: "sender", @@ -34,6 +36,9 @@ function senderGate(params: { }; } +/** + * Evaluates direct-message sender policy against DM and pairing-store allowlists. + */ export function senderGateForDirect(params: { state: ChannelIngressState; policy: ChannelIngressPolicyInput; @@ -70,6 +75,8 @@ export function senderGateForDirect(params: { return block("dm_policy_disabled"); } if (params.policy.dmPolicy === "open") { + // Open DM policy still requires either wildcard or an explicit normalized entry so + // configured allowlists keep their narrowing effect. if (dm.hasWildcard) { return allow("dm_policy_open"); } @@ -82,6 +89,7 @@ export function senderGateForDirect(params: { return allow("dm_policy_allowlisted"); } if (params.policy.dmPolicy === "pairing" && pairingStore.match.matched) { + // Pairing-store matches are only valid for pairing policy, never for open/allowlist modes. return senderGate({ id: "sender:dm", kind: "dmSender", @@ -103,6 +111,9 @@ export function senderGateForDirect(params: { return block(reasonCode); } +/** + * Evaluates group/channel sender policy after route sender allowlist overrides are applied. + */ export function senderGateForGroup(params: { state: ChannelIngressState; policy: ChannelIngressPolicyInput; @@ -146,6 +157,9 @@ export function senderGateForGroup(params: { return block(allowlistFailureReason(group) ?? "group_policy_not_allowlisted"); } +/** + * Applies event auth mode to sender gates for non-message callbacks. + */ export function applyEventAuthModeToSenderGate(params: { state: ChannelIngressState; senderGate: AccessGraphGate; @@ -153,6 +167,8 @@ export function applyEventAuthModeToSenderGate(params: { if (params.state.event.authMode === "inbound" || params.senderGate.allowed) { return params.senderGate; } + // Non-inbound events can be authorized by command/origin/route gates, so a failed sender + // gate becomes an ignored diagnostic instead of a dispatch block. const reasonCode = "sender_not_required"; return { ...params.senderGate, diff --git a/src/channels/message/contracts.ts b/src/channels/message/contracts.ts index fae39cd940e6..4ad9bea62aa8 100644 --- a/src/channels/message/contracts.ts +++ b/src/channels/message/contracts.ts @@ -14,50 +14,89 @@ import { livePreviewFinalizerCapabilities, } from "./types.js"; +/** + * Proof callback used to verify one declared durable-final delivery capability. + */ export type DurableFinalCapabilityProof = () => Promise | void; +/** + * Proof callbacks keyed by durable-final delivery capability. + */ export type DurableFinalCapabilityProofMap = Partial< Record >; +/** + * Verification result for one durable-final delivery capability. + */ export type DurableFinalCapabilityProofResult = { capability: DurableFinalDeliveryCapability; status: "verified" | "not_declared"; }; +/** + * Proof callback used to verify one live-preview finalizer capability. + */ export type LivePreviewFinalizerCapabilityProof = () => Promise | void; +/** + * Proof callback used to verify one live message capability. + */ export type ChannelMessageLiveCapabilityProof = () => Promise | void; +/** + * Proof callback used to verify one receive acknowledgement policy. + */ export type ChannelMessageReceiveAckPolicyProof = () => Promise | void; +/** + * Proof callbacks keyed by live-preview finalizer capability. + */ export type LivePreviewFinalizerCapabilityProofMap = Partial< Record >; +/** + * Proof callbacks keyed by live message capability. + */ export type ChannelMessageLiveCapabilityProofMap = Partial< Record >; +/** + * Proof callbacks keyed by receive acknowledgement policy. + */ export type ChannelMessageReceiveAckPolicyProofMap = Partial< Record >; +/** + * Verification result for one live-preview finalizer capability. + */ export type LivePreviewFinalizerCapabilityProofResult = { capability: LivePreviewFinalizerCapability; status: "verified" | "not_declared"; }; +/** + * Verification result for one live message capability. + */ export type ChannelMessageLiveCapabilityProofResult = { capability: ChannelMessageLiveCapability; status: "verified" | "not_declared"; }; +/** + * Verification result for one receive acknowledgement policy. + */ export type ChannelMessageReceiveAckPolicyProofResult = { policy: ChannelMessageReceiveAckPolicy; status: "verified" | "not_declared"; }; +/** + * Lists declared durable-final delivery capabilities in stable contract order. + */ export function listDeclaredDurableFinalCapabilities( capabilities: DurableFinalDeliveryRequirementMap | undefined, ): DurableFinalDeliveryCapability[] { @@ -66,6 +105,9 @@ export function listDeclaredDurableFinalCapabilities( ); } +/** + * Lists declared live-preview finalizer capabilities in stable contract order. + */ export function listDeclaredLivePreviewFinalizerCapabilities( capabilities: LivePreviewFinalizerCapabilityMap | undefined, ): LivePreviewFinalizerCapability[] { @@ -74,12 +116,18 @@ export function listDeclaredLivePreviewFinalizerCapabilities( ); } +/** + * Lists declared live message capabilities in stable contract order. + */ export function listDeclaredChannelMessageLiveCapabilities( capabilities: Partial> | undefined, ): ChannelMessageLiveCapability[] { return channelMessageLiveCapabilities.filter((capability) => capabilities?.[capability] === true); } +/** + * Lists declared receive acknowledgement policies, including the default policy fallback. + */ export function listDeclaredReceiveAckPolicies( receive: ChannelMessageAdapterShape["receive"] | undefined, ): ChannelMessageReceiveAckPolicy[] { @@ -91,6 +139,9 @@ export function listDeclaredReceiveAckPolicies( return channelMessageReceiveAckPolicies.filter((policy) => declared.includes(policy)); } +/** + * Verifies proof callbacks for every declared durable-final delivery capability. + */ export async function verifyDurableFinalCapabilityProofs(params: { adapterName: string; capabilities?: DurableFinalDeliveryRequirementMap; @@ -98,6 +149,8 @@ export async function verifyDurableFinalCapabilityProofs(params: { }): Promise { const results: DurableFinalCapabilityProofResult[] = []; for (const capability of durableFinalDeliveryCapabilities) { + // Iterate over the canonical capability list so missing declarations still produce + // not_declared records and result ordering stays stable for tests and reports. if (params.capabilities?.[capability] !== true) { results.push({ capability, status: "not_declared" }); continue; @@ -114,6 +167,9 @@ export async function verifyDurableFinalCapabilityProofs(params: { return results; } +/** + * Verifies proof callbacks for every declared live-preview finalizer capability. + */ export async function verifyLivePreviewFinalizerCapabilityProofs(params: { adapterName: string; capabilities?: LivePreviewFinalizerCapabilityMap; @@ -137,6 +193,9 @@ export async function verifyLivePreviewFinalizerCapabilityProofs(params: { return results; } +/** + * Verifies proof callbacks for every declared live message capability. + */ export async function verifyChannelMessageLiveCapabilityProofs(params: { adapterName: string; capabilities?: Partial>; @@ -160,6 +219,9 @@ export async function verifyChannelMessageLiveCapabilityProofs(params: { return results; } +/** + * Verifies proof callbacks for every declared receive acknowledgement policy. + */ export async function verifyChannelMessageReceiveAckPolicyProofs(params: { adapterName: string; receive?: ChannelMessageAdapterShape["receive"]; @@ -184,6 +246,9 @@ export async function verifyChannelMessageReceiveAckPolicyProofs(params: { return results; } +/** + * Verifies durable-final proofs from a channel message adapter declaration. + */ export async function verifyChannelMessageAdapterCapabilityProofs(params: { adapterName: string; adapter: Pick; @@ -196,6 +261,9 @@ export async function verifyChannelMessageAdapterCapabilityProofs(params: { }); } +/** + * Verifies receive acknowledgement proofs from a channel message adapter declaration. + */ export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: { adapterName: string; adapter: Pick; @@ -208,6 +276,9 @@ export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: }); } +/** + * Verifies live-preview finalizer proofs from a channel message adapter declaration. + */ export async function verifyChannelMessageLiveFinalizerProofs(params: { adapterName: string; adapter: Pick; @@ -220,6 +291,9 @@ export async function verifyChannelMessageLiveFinalizerProofs(params: { }); } +/** + * Verifies live message capability proofs from a channel message adapter declaration. + */ export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: { adapterName: string; adapter: Pick; diff --git a/src/channels/message/index.ts b/src/channels/message/index.ts index e5e87e7f960a..a7984dad8591 100644 --- a/src/channels/message/index.ts +++ b/src/channels/message/index.ts @@ -1,3 +1,5 @@ +// Public barrel for channel message delivery, live preview, receipt, receive, and recovery +// contracts used by channel plugins and core delivery code. export { deriveDurableFinalDeliveryRequirements } from "./capabilities.js"; export { defineChannelMessageAdapter } from "./adapter.js"; export { createChannelMessageAdapterFromOutbound } from "./outbound-bridge.js"; diff --git a/src/channels/message/reply-pipeline.ts b/src/channels/message/reply-pipeline.ts index a6c60efd9ff8..e015650a44c9 100644 --- a/src/channels/message/reply-pipeline.ts +++ b/src/channels/message/reply-pipeline.ts @@ -27,9 +27,13 @@ export type { SourceReplyDeliveryMode }; /** Resolves whether a channel reply should use source delivery, message tools, or direct sending. */ export function resolveChannelSourceReplyDeliveryMode(params: { + /** Full config used to inspect source-reply delivery settings. */ cfg: OpenClawConfig; + /** Reply delivery context from the current channel turn. */ ctx: SourceReplyDeliveryModeContext; + /** Caller-requested delivery mode override. */ requested?: SourceReplyDeliveryMode; + /** Whether the message-send tool is available for this turn. */ messageToolAvailable?: boolean; }): SourceReplyDeliveryMode { return resolveSourceReplyDeliveryMode(params); @@ -37,18 +41,27 @@ export function resolveChannelSourceReplyDeliveryMode(params: { /** Reply pipeline options shared by core channel turns and plugin SDK callers. */ export type ChannelReplyPipeline = ReplyPrefixOptions & { + /** Optional typing lifecycle callbacks for reply generation. */ typingCallbacks?: TypingCallbacks; + /** Optional payload transform applied before channel delivery. */ transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }; /** Parameters for building a channel reply pipeline with prefix, typing, and payload transforms. */ export type CreateChannelReplyPipelineParams = { + /** Full config used for reply prefix and channel plugin transform resolution. */ cfg: Parameters[0]["cfg"]; + /** Agent id used in reply prefix context. */ agentId: string; + /** Optional channel id for prefix context and plugin transform lookup. */ channel?: string; + /** Optional channel account id for prefix context and plugin transform lookup. */ accountId?: string; + /** Typing callback factory input. */ typing?: CreateTypingCallbacksParams; + /** Prebuilt typing callbacks that take precedence over `typing`. */ typingCallbacks?: TypingCallbacks; + /** Explicit payload transform; avoids channel plugin lookup when provided. */ transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }; @@ -63,6 +76,8 @@ export function createChannelReplyPipeline( let pluginTransformResolved = false; const resolvePluginTransform = () => { // Load the channel plugin lazily so reply-pipeline construction stays cheap for hot turn paths. + // The resolved transform is process-stable for this pipeline; plugin registry + // changes require a new pipeline rather than repeated hot-path lookups. if (pluginTransformResolved) { return plugin?.messaging?.transformReplyPayload; } diff --git a/src/channels/message/runtime.ts b/src/channels/message/runtime.ts index 1032d9043f7a..122678fede87 100644 --- a/src/channels/message/runtime.ts +++ b/src/channels/message/runtime.ts @@ -1,3 +1,5 @@ +// Runtime-only barrel for durable message send helpers. Kept separate from the public message +// contract barrel so hot imports can choose delivery runtime without pulling every type export. export { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js"; export type { DurableMessageBatchSendParams, diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index 89e972592148..d76b02dd003d 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -21,6 +21,7 @@ import { resolveSessionConversationRef, } from "./plugins/session-conversation.js"; +/** Resolved model override for a channel conversation plus the config key that matched. */ export type ChannelModelOverride = { channel: string; model: string; @@ -75,6 +76,7 @@ function buildChannelCandidates( parentConversationId: rawParentConversation?.rawId, }) ?? []; const sessionConversation = resolveSessionConversationRef(params.parentSessionKey, { + // Bundled parsing is only a fallback when the loaded plugin did not provide candidates. bundledFallback: parentOverrideFallbacks.length === 0, }); const groupConversationKind = @@ -154,6 +156,7 @@ function resolveDirectChannelModelMatch(params: { return { model, matchKey: match.matchKey, matchSource: match.matchSource }; } +/** Resolves a channel-scoped model override from direct, parent, and wildcard config entries. */ export function resolveChannelModelOverride( params: ChannelModelOverrideParams, ): ChannelModelOverride | null { diff --git a/src/channels/native-command-session-targets.ts b/src/channels/native-command-session-targets.ts index d7e05733070a..6b01c1ee7629 100644 --- a/src/channels/native-command-session-targets.ts +++ b/src/channels/native-command-session-targets.ts @@ -1,5 +1,8 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** + * Inputs for resolving where a native channel command should attach session state. + */ export type ResolveNativeCommandSessionTargetsParams = { agentId: string; sessionPrefix: string; @@ -9,12 +12,17 @@ export type ResolveNativeCommandSessionTargetsParams = { lowercaseSessionKey?: boolean; }; +/** + * Resolves the storage session key and command target key for native command events. + */ export function resolveNativeCommandSessionTargets( params: ResolveNativeCommandSessionTargetsParams, ) { const rawSessionKey = params.boundSessionKey ?? `agent:${params.agentId}:${params.sessionPrefix}:${params.userId}`; return { + // Some providers normalize user ids case-insensitively; keep this opt-in so existing + // case-sensitive bindings are preserved for channels that need them. sessionKey: params.lowercaseSessionKey ? normalizeLowercaseStringOrEmpty(rawSessionKey) : rawSessionKey, diff --git a/src/channels/plugins/account-action-gate.ts b/src/channels/plugins/account-action-gate.ts index d90f454bd872..0a75ccdf6c50 100644 --- a/src/channels/plugins/account-action-gate.ts +++ b/src/channels/plugins/account-action-gate.ts @@ -1,8 +1,14 @@ +/** + * Resolves whether an account-scoped action is enabled. + */ export type ActionGate> = ( key: keyof T, defaultValue?: boolean, ) => boolean; +/** + * Creates an action gate where account-specific flags override channel-level defaults. + */ export function createAccountActionGate>(params: { baseActions?: T; accountActions?: T; @@ -12,6 +18,7 @@ export function createAccountActionGate.accounts is absent. if (options?.hasImplicitDefaultAccount?.(cfg)) { return true; } @@ -54,6 +59,7 @@ export function createAccountListHelpers( if (options?.allowUnlistedDefaultAccount) { return preferred; } + // Reject stale defaultAccount values unless the channel explicitly supports external ids. if (ids.some((id) => normalizeAccountId(id) === preferred)) { return preferred; } @@ -93,6 +99,9 @@ export function createAccountListHelpers( return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId }; } +/** + * Checks whether a config/env value should count as an account being configured. + */ export function hasConfiguredAccountValue(value: unknown): boolean { if (typeof value === "string") { return value.trim().length > 0; @@ -100,6 +109,9 @@ export function hasConfiguredAccountValue(value: unknown): boolean { return value !== undefined && value !== null; } +/** + * Combines configured, additional, implicit, and fallback account ids into stable order. + */ export function listCombinedAccountIds(params: { configuredAccountIds: Iterable; additionalAccountIds?: Iterable; @@ -128,6 +140,9 @@ export function listCombinedAccountIds(params: { return [...ids].toSorted((a, b) => a.localeCompare(b)); } +/** + * Resolves the default account id from a listed account set and optional configured preference. + */ export function resolveListedDefaultAccountId(params: { accountIds: readonly string[]; configuredDefaultAccountId?: string | undefined; @@ -153,6 +168,9 @@ export function resolveListedDefaultAccountId(params: { return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID; } +/** + * Merges channel-level config with account-level overrides. + */ export function mergeAccountConfig>(params: { channelConfig: TConfig | undefined; accountConfig: Partial | undefined; @@ -169,6 +187,7 @@ export function mergeAccountConfig>(para ...base, ...params.accountConfig, }; + // Some config subtrees are additive maps/options rather than replace-on-account override. for (const key of params.nestedObjectKeys ?? []) { const baseValue = base[key as keyof TConfig]; const accountValue = params.accountConfig?.[key as keyof TConfig]; @@ -189,6 +208,9 @@ export function mergeAccountConfig>(para return merged; } +/** + * Resolves an account config by id, then merges it over channel-level defaults. + */ export function resolveMergedAccountConfig>(params: { channelConfig: TConfig | undefined; accounts: Record> | undefined; @@ -214,6 +236,9 @@ type AccountSnapshotInput = { name?: string | null | undefined; }; +/** + * Builds a safe account snapshot for status/setup surfaces. + */ export function describeAccountSnapshot(params: { account: AccountSnapshotInput; configured?: boolean | undefined; @@ -228,6 +253,9 @@ export function describeAccountSnapshot(params: { }; } +/** + * Builds a webhook-mode account snapshot with the standard mode field. + */ export function describeWebhookAccountSnapshot(params: { account: AccountSnapshotInput; configured?: boolean | undefined; diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts index 7d903dbb41f5..305fe5f8329b 100644 --- a/src/channels/plugins/acp-configured-binding-consumer.ts +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -30,6 +30,7 @@ function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgen cwd?: string; backend?: string; } { + // ACP bindings inherit runtime defaults from the owning agent when that agent already runs ACP. const ownerAgentId = normalizeLowercaseStringOrEmpty(params.ownerAgentId); const agent = params.cfg.agents?.list?.find( (entry) => normalizeOptionalLowercaseString(entry.id) === ownerAgentId, @@ -49,6 +50,8 @@ function resolveConfiguredBindingWorkspaceCwd(params: { cfg: OpenClawConfig; agentId: string; }): string | undefined { + // Only bind cwd when the agent has an explicit workspace contract; otherwise let ACP choose + // its normal default instead of freezing an incidental process cwd. const explicitAgentWorkspace = normalizeText( resolveAgentConfig(params.cfg, params.agentId)?.workspace, ); @@ -98,6 +101,8 @@ function buildAcpTargetFactory(params: { if (params.binding.type !== "acp") { return null; } + // Binding config overrides agent runtime defaults; unresolved fields remain undefined so ACP + // session creation can apply backend-specific defaults. const runtimeDefaults = resolveAgentRuntimeAcpDefaults({ cfg: params.cfg, ownerAgentId: params.agentId, @@ -118,6 +123,8 @@ function buildAcpTargetFactory(params: { return { driverId: "acp", materialize: ({ accountId, conversation }) => { + // Materialization is account/conversation-specific because wildcard bindings resolve to + // stable ACP session keys only after the matched conversation is known. const spec = buildConfiguredAcpSpec({ channel: params.channel, accountId, @@ -144,6 +151,9 @@ function buildAcpTargetFactory(params: { }; } +/** + * Configured binding consumer that materializes ACP persistent or oneshot targets. + */ export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = { id: "acp", supports: (binding) => binding.type === "acp", diff --git a/src/channels/plugins/acp-stateful-target-reset.runtime.ts b/src/channels/plugins/acp-stateful-target-reset.runtime.ts index f3297e531973..ed2d0deb6385 100644 --- a/src/channels/plugins/acp-stateful-target-reset.runtime.ts +++ b/src/channels/plugins/acp-stateful-target-reset.runtime.ts @@ -1 +1,3 @@ +// Runtime facade for ACP stateful target reset. Kept isolated so binding helpers can depend on +// the reset seam without importing broader gateway runtime modules. export { performGatewaySessionReset } from "../../gateway/session-reset-service.js"; diff --git a/src/channels/plugins/actions/reaction-message-id.ts b/src/channels/plugins/actions/reaction-message-id.ts index d5c00578549c..d81ed003581d 100644 --- a/src/channels/plugins/actions/reaction-message-id.ts +++ b/src/channels/plugins/actions/reaction-message-id.ts @@ -4,6 +4,9 @@ type ReactionToolContext = { currentMessageId?: string | number; }; +/** + * Resolves the message id for reaction tools from explicit args or current tool context. + */ export function resolveReactionMessageId(params: { args: Record; toolContext?: ReactionToolContext; diff --git a/src/channels/plugins/actions/shared.ts b/src/channels/plugins/actions/shared.ts index 6a9f813d1320..52cd74d15d71 100644 --- a/src/channels/plugins/actions/shared.ts +++ b/src/channels/plugins/actions/shared.ts @@ -4,12 +4,18 @@ type TokenSourcedAccount = { tokenSource?: string | null; }; +/** + * Filters out accounts explicitly marked as tokenless. + */ export function listTokenSourcedAccounts( accounts: readonly TAccount[], ): TAccount[] { return accounts.filter((account) => account.tokenSource !== "none"); } +/** + * Creates an action gate that is enabled when any account-level gate enables the action. + */ export function createUnionActionGate( accounts: readonly TAccount[], createGate: (account: TAccount) => OptionalDefaultGate, diff --git a/src/channels/plugins/allowlist-match.ts b/src/channels/plugins/allowlist-match.ts index fdf4d1d7a999..e79b9a4a3011 100644 --- a/src/channels/plugins/allowlist-match.ts +++ b/src/channels/plugins/allowlist-match.ts @@ -1,2 +1,3 @@ +// Compatibility facade for channel-plugin allowlist match helpers. export type { AllowlistMatch, AllowlistMatchSource } from "../allowlist-match.js"; export { formatAllowlistMatchMeta } from "../allowlist-match.js"; diff --git a/src/channels/plugins/approval-native.types.ts b/src/channels/plugins/approval-native.types.ts index 69eae1df07ed..d45b7155d6ad 100644 --- a/src/channels/plugins/approval-native.types.ts +++ b/src/channels/plugins/approval-native.types.ts @@ -3,17 +3,32 @@ import type { ChannelApprovalKind } from "../../infra/approval-types.js"; import type { ExecApprovalRequest } from "../../infra/exec-approvals.js"; import type { PluginApprovalRequest } from "../../infra/plugin-approvals.js"; +/** + * Native channel surface that can receive approval prompts. + */ export type ChannelApprovalNativeSurface = "origin" | "approver-dm"; +/** + * Native channel destination for an approval prompt. + */ export type ChannelApprovalNativeTarget = { to: string; threadId?: string | number | null; }; +/** + * Preferred native delivery surface for approval prompts. + */ export type ChannelApprovalNativeDeliveryPreference = ChannelApprovalNativeSurface | "both"; +/** + * Approval request shapes supported by native channel approval delivery. + */ export type ChannelApprovalNativeRequest = ExecApprovalRequest | PluginApprovalRequest; +/** + * Capabilities returned by native channel approval delivery inspection. + */ export type ChannelApprovalNativeDeliveryCapabilities = { enabled: boolean; preferredSurface: ChannelApprovalNativeDeliveryPreference; @@ -22,6 +37,9 @@ export type ChannelApprovalNativeDeliveryCapabilities = { notifyOriginWhenDmOnly?: boolean; }; +/** + * Adapter implemented by channel plugins that support native approval delivery. + */ export type ChannelApprovalNativeAdapter = { describeDeliveryCapabilities: (params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/approvals.ts b/src/channels/plugins/approvals.ts index 3998a2dabe5c..53a6a2c0cbe8 100644 --- a/src/channels/plugins/approvals.ts +++ b/src/channels/plugins/approvals.ts @@ -1,12 +1,18 @@ import type { ChannelApprovalAdapter, ChannelApprovalCapability } from "./types.adapters.js"; import type { ChannelPlugin } from "./types.plugin.js"; +/** + * Returns the approval capability exposed by a channel plugin. + */ export function resolveChannelApprovalCapability( plugin?: Pick | null, ): ChannelApprovalCapability | undefined { return plugin?.approvalCapability; } +/** + * Projects a channel approval capability into the runtime approval adapter shape. + */ export function resolveChannelApprovalAdapter( plugin?: Pick | null, ): ChannelApprovalAdapter | undefined { @@ -20,6 +26,7 @@ export function resolveChannelApprovalAdapter( !capability.render && !capability.native ) { + // Auth-only capabilities are valid plugin metadata but do not form a delivery adapter. return undefined; } return { diff --git a/src/channels/plugins/binding-provider.ts b/src/channels/plugins/binding-provider.ts index 27dc5c49951c..5e216c3ec29d 100644 --- a/src/channels/plugins/binding-provider.ts +++ b/src/channels/plugins/binding-provider.ts @@ -1,6 +1,9 @@ import type { ChannelConfiguredBindingProvider } from "./types.adapters.js"; import type { ChannelPlugin } from "./types.plugin.js"; +/** + * Returns the configured binding provider exposed by a channel plugin, when present. + */ export function resolveChannelConfiguredBindingProvider( plugin: | Pick diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts index 0fc89ae6dc09..97edbaa8cfdd 100644 --- a/src/channels/plugins/binding-routing.ts +++ b/src/channels/plugins/binding-routing.ts @@ -15,6 +15,9 @@ import type { ConfiguredBindingResolution } from "./binding-types.js"; const CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS = 30_000; +/** + * Route resolution after applying a configured channel binding. + */ export type ConfiguredBindingRouteResult = { bindingResolution: ConfiguredBindingResolution | null; route: ResolvedAgentRoute; @@ -22,6 +25,9 @@ export type ConfiguredBindingRouteResult = { boundAgentId?: string; }; +/** + * Route resolution after applying a runtime conversation binding record. + */ export type RuntimeConversationBindingRouteResult = { bindingRecord: SessionBindingRecord | null; route: ResolvedAgentRoute; @@ -66,6 +72,9 @@ function isPluginOwnedRuntimeBindingRecord(record: SessionBindingRecord | null): ); } +/** + * Rewrites an agent route when the current conversation matches a configured binding. + */ export function resolveConfiguredBindingRoute( params: { cfg: OpenClawConfig; @@ -93,6 +102,8 @@ export function resolveConfiguredBindingRoute( } const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId; + // Configured bindings own the session key, so recompute last-route policy against that target + // before downstream delivery records the route. return { bindingResolution, boundSessionKey, @@ -110,6 +121,9 @@ export function resolveConfiguredBindingRoute( }; } +/** + * Rewrites an agent route using a persisted runtime conversation binding, when applicable. + */ export function resolveRuntimeConversationBindingRoute( params: { route: ResolvedAgentRoute; @@ -127,6 +141,7 @@ export function resolveRuntimeConversationBindingRoute( } if (isCronRunSessionKey(boundSessionKey)) { + // Cron run sessions are isolated and short-lived; never route live channel traffic into them. logVerbose( `ignored runtime conversation binding ${bindingRecord.bindingId} to isolated cron run session ${boundSessionKey}`, ); @@ -138,6 +153,8 @@ export function resolveRuntimeConversationBindingRoute( getSessionBindingService().touch(bindingRecord.bindingId); if (isPluginOwnedRuntimeBindingRecord(bindingRecord)) { + // Plugin-owned binding records are observed but not route-rewritten by core; the owning + // plugin is responsible for its runtime target handoff. return { bindingRecord, route: params.route, @@ -162,6 +179,9 @@ export function resolveRuntimeConversationBindingRoute( }; } +/** + * Ensures a configured binding target is ready without blocking route resolution indefinitely. + */ export async function ensureConfiguredBindingRouteReady(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution | null; @@ -179,6 +199,7 @@ export async function ensureConfiguredBindingRouteReady(params: { if (result !== timeoutToken) { return result; } + // Let late driver work finish for diagnostics, but return a bounded failure to the caller. logVerbose( `configured binding route ready check timed out after ${ CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS / 1_000 diff --git a/src/channels/plugins/binding-targets.ts b/src/channels/plugins/binding-targets.ts index fda5b08bb45e..e11f70423737 100644 --- a/src/channels/plugins/binding-targets.ts +++ b/src/channels/plugins/binding-targets.ts @@ -9,6 +9,9 @@ import { resolveStatefulBindingTargetBySessionKey, } from "./stateful-target-drivers.js"; +/** + * Ensures the stateful target driver for a configured binding is ready to receive traffic. + */ export async function ensureConfiguredBindingTargetReady(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution | null; @@ -18,6 +21,8 @@ export async function ensureConfiguredBindingTargetReady(params: { } const driverId = params.bindingResolution.statefulTarget.driverId; let driver = getStatefulBindingTargetDriver(driverId); + // Built-in drivers are registered lazily so normal channel startup does not load every + // stateful target implementation before a binding actually needs one. if (!driver && isStatefulTargetBuiltinDriverId(driverId)) { await ensureStatefulTargetBuiltinsRegistered(); driver = getStatefulBindingTargetDriver(driverId); @@ -34,6 +39,9 @@ export async function ensureConfiguredBindingTargetReady(params: { }); } +/** + * Resets a stateful configured binding target in place when its driver supports reset. + */ export async function resetConfiguredBindingTargetInPlace(params: { cfg: OpenClawConfig; sessionKey: string; @@ -52,6 +60,7 @@ export async function resetConfiguredBindingTargetInPlace(params: { }); } if (!resolved?.driver.resetInPlace) { + // A missing reset hook is a valid skip, not a hard routing failure. return { ok: false, skipped: true, @@ -63,6 +72,9 @@ export async function resetConfiguredBindingTargetInPlace(params: { }); } +/** + * Ensures the configured binding target session exists and returns its session key. + */ export async function ensureConfiguredBindingTargetSession(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution; diff --git a/src/channels/plugins/binding-types.ts b/src/channels/plugins/binding-types.ts index c8d683e9d5cd..b03cebfeac58 100644 --- a/src/channels/plugins/binding-types.ts +++ b/src/channels/plugins/binding-types.ts @@ -10,10 +10,24 @@ import type { } from "./types.adapters.js"; import type { ChannelId } from "./types.public.js"; +/** + * Normalized conversation facts used to match configured channel bindings. + */ export type ConfiguredBindingConversation = ConversationRef; + +/** + * Channel id used by configured binding rules. + */ export type ConfiguredBindingChannel = ChannelId; + +/** + * Raw binding config entry from OpenClaw config. + */ export type ConfiguredBindingRuleConfig = AgentBinding; +/** + * Stateful target descriptor produced by a binding consumer. + */ export type StatefulBindingTargetDescriptor = { kind: "stateful"; driverId: string; @@ -22,11 +36,17 @@ export type StatefulBindingTargetDescriptor = { label?: string; }; +/** + * Materialized binding record plus the stateful target it points at. + */ export type ConfiguredBindingRecordResolution = { record: SessionBindingRecord; statefulTarget: StatefulBindingTargetDescriptor; }; +/** + * Factory that materializes a configured binding for one account/conversation pair. + */ export type ConfiguredBindingTargetFactory = { driverId: string; materialize: (params: { @@ -35,6 +55,9 @@ export type ConfiguredBindingTargetFactory = { }) => ConfiguredBindingRecordResolution; }; +/** + * Compiled binding rule with provider matcher, target factory, and static target facts. + */ export type CompiledConfiguredBinding = { channel: ConfiguredBindingChannel; accountPattern?: string; @@ -46,6 +69,9 @@ export type CompiledConfiguredBinding = { targetFactory: ConfiguredBindingTargetFactory; }; +/** + * Full configured binding resolution used to rewrite routes and prepare target sessions. + */ export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & { conversation: ConfiguredBindingConversation; compiledBinding: CompiledConfiguredBinding; diff --git a/src/channels/plugins/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index fb4a1c742a67..7abad353fafa 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -10,6 +10,10 @@ import { import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; +/** + * Bootstrap registry for bundled channel plugins before runtime registry install. + */ + function resolveBootstrapChannelId(id: ChannelId): string { return normalizeOptionalString(id) ?? ""; } @@ -24,6 +28,8 @@ function mergePluginSection( typeof runtimeValue === "object" && typeof setupValue === "object" ) { + // Setup artifacts can add lightweight setup/docs/secrets fields on top of + // runtime artifacts; undefined setup values should not erase runtime data. const merged = { ...(runtimeValue as Record), }; @@ -59,11 +65,17 @@ function mergeBootstrapPlugin( } as ChannelPlugin; } +/** + * Lists bundled channel ids visible to bootstrap for the current root scope. + */ export function listBootstrapChannelPluginIds(): readonly string[] { const rootScope = resolveBundledChannelRootScope(); return listBundledChannelPluginIdsForRoot(rootScope.cacheKey); } +/** + * Iterates bundled bootstrap channel plugins that can be loaded successfully. + */ export function* iterateBootstrapChannelPlugins(): IterableIterator { for (const id of listBootstrapChannelPluginIds()) { const plugin = getBootstrapChannelPlugin(id); @@ -73,6 +85,9 @@ export function* iterateBootstrapChannelPlugins(): IterableIterator left.localeCompare(right)); } +/** + * Lists bundled channel ids for a package root/cache scope. + */ export function listBundledChannelIdsForRoot( _packageRoot: string, env: NodeJS.ProcessEnv = process.env, @@ -31,6 +37,9 @@ export function listBundledChannelIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +/** + * Lists bundled channel plugin ids for the current runtime root scope. + */ export function listBundledChannelPluginIds( env: NodeJS.ProcessEnv = process.env, discovery?: PluginDiscoveryResult, @@ -42,6 +51,9 @@ export function listBundledChannelPluginIds( ); } +/** + * Lists bundled channel ids for the current runtime root scope. + */ export function listBundledChannelIds( env: NodeJS.ProcessEnv = process.env, discovery?: PluginDiscoveryResult, diff --git a/src/channels/plugins/bundled-root.ts b/src/channels/plugins/bundled-root.ts index 0e6002b9ba04..23ae2a7c772f 100644 --- a/src/channels/plugins/bundled-root.ts +++ b/src/channels/plugins/bundled-root.ts @@ -28,6 +28,9 @@ function derivePackageRootFromExtensionsDir(extensionsDir: string): string { return parentDir; } +/** + * Resolves the package/cache scope used for bundled channel plugin metadata. + */ export function resolveBundledChannelRootScope( env: NodeJS.ProcessEnv = process.env, ): BundledChannelRootScope { @@ -39,6 +42,8 @@ export function resolveBundledChannelRootScope( }; } const resolvedPluginsDir = path.resolve(bundledPluginsDir); + // A direct extensions directory belongs to the package root; any other scan dir is its own + // cache scope so tests and packaged runtimes do not share stale metadata. return { packageRoot: path.basename(resolvedPluginsDir) === "extensions" diff --git a/src/channels/plugins/channel-config.ts b/src/channels/plugins/channel-config.ts index 9f3a5150b5b1..92b576edb1ef 100644 --- a/src/channels/plugins/channel-config.ts +++ b/src/channels/plugins/channel-config.ts @@ -1,3 +1,4 @@ +// Compatibility facade for channel config matching helpers used by plugin runtime APIs. export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js"; export { applyChannelMatchMeta, diff --git a/src/channels/plugins/channel-id.types.ts b/src/channels/plugins/channel-id.types.ts index 6cb63fac03e9..d70ceee789db 100644 --- a/src/channels/plugins/channel-id.types.ts +++ b/src/channels/plugins/channel-id.types.ts @@ -1,3 +1,6 @@ import type { ChatChannelId } from "../ids.js"; +/** + * Channel id accepted by plugin helpers, covering built-in chat ids and external plugin ids. + */ export type ChannelId = ChatChannelId | (string & {}); diff --git a/src/channels/plugins/channel-meta.ts b/src/channels/plugins/channel-meta.ts index 1c515ddb2cb7..6ca8ced2bea4 100644 --- a/src/channels/plugins/channel-meta.ts +++ b/src/channels/plugins/channel-meta.ts @@ -5,6 +5,9 @@ import type { ChannelMeta } from "./types.core.js"; type ArrayFieldMode = "defined" | "non-empty"; type OptionalStringMode = "defined" | "truthy"; +/** + * Builds normalized channel metadata from a plugin manifest channel declaration. + */ export function buildManifestChannelMeta(params: { id: string; channel: PluginPackageChannel; @@ -46,6 +49,8 @@ export function buildManifestChannelMeta(params: { ...(params.channel.markdownCapable !== undefined ? { markdownCapable: params.channel.markdownCapable } : {}), + // Exposure defaults and validation live in the shared exposure helper so setup and catalog + // metadata stay aligned across bundled and external channels. exposure: resolveChannelExposure(params.channel), ...(params.channel.quickstartAllowFrom !== undefined ? { quickstartAllowFrom: params.channel.quickstartAllowFrom } diff --git a/src/channels/plugins/chat-target-prefixes.ts b/src/channels/plugins/chat-target-prefixes.ts index 50332d71625e..fa1b256fe629 100644 --- a/src/channels/plugins/chat-target-prefixes.ts +++ b/src/channels/plugins/chat-target-prefixes.ts @@ -5,8 +5,18 @@ import { import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { parseStrictInteger } from "../../infra/parse-finite-number.js"; +/** + * Shared parsing helpers for chat ids, chat guids, and service-prefixed targets. + */ + +/** + * Prefix mapping for service-qualified target strings. + */ export type ServicePrefix = { prefix: string; service: TService }; +/** + * Normalized input used by chat target prefix parsers. + */ export type ChatTargetPrefixesParams = { trimmed: string; lower: string; @@ -15,13 +25,22 @@ export type ChatTargetPrefixesParams = { chatIdentifierPrefixes: string[]; }; +/** + * Parsed conversation target forms accepted by channel allowlists and target resolvers. + */ export type ParsedChatTarget = | { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | { kind: "chat_identifier"; chatIdentifier: string }; +/** + * Parsed allowlist target, including sender handles. + */ export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string }; +/** + * Sender metadata used for chat-aware allowlist checks. + */ export type ChatSenderAllowParams = { allowFrom: Array; sender: string; @@ -31,6 +50,9 @@ export type ChatSenderAllowParams = { allowConversationTargets?: boolean | null; }; +/** + * Checks whether a sender or current conversation matches an allowlist entry. + */ export function isAllowedParsedChatSender(params: { allowFrom: Array; sender: string; @@ -51,6 +73,8 @@ export function isAllowedParsedChatSender(params: { const senderNormalized = params.normalizeSender(params.sender); const allowConversationTargets = params.allowConversationTargets === true; + // Conversation ids are only considered when the channel opts in; otherwise + // allowlists stay sender-handle based for compatibility with older configs. const chatId = allowConversationTargets ? (params.chatId ?? undefined) : undefined; const chatGuid = allowConversationTargets ? normalizeOptionalString(params.chatGuid) : undefined; const chatIdentifier = allowConversationTargets @@ -91,6 +115,9 @@ function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolea return prefixes.some((prefix) => value.startsWith(prefix)); } +/** + * Resolves service-prefixed handle targets, delegating chat-shaped remainders. + */ export function resolveServicePrefixedTarget(params: { trimmed: string; lower: string; @@ -115,6 +142,9 @@ export function resolveServicePrefixedTarget(p return null; } +/** + * Resolves service-prefixed targets where chat ids should bypass handle parsing. + */ export function resolveServicePrefixedChatTarget(params: { trimmed: string; lower: string; @@ -140,6 +170,9 @@ export function resolveServicePrefixedChatTarget(params: { trimmed: string; lower: string; @@ -196,6 +232,9 @@ export function resolveServicePrefixedAllowTarget(params: { return null; } +/** + * Resolves service-prefixed allow targets before falling back to chat prefixes. + */ export function resolveServicePrefixedOrChatAllowTarget< TAllowTarget extends ParsedChatAllowTarget, >(params: { @@ -230,6 +269,9 @@ export function resolveServicePrefixedOrChatAllowTarget< return null; } +/** + * Creates a reusable sender matcher for chat-aware channel allowlists. + */ export function createAllowedChatSenderMatcher(params: { normalizeSender: (sender: string) => string; parseAllowTarget: (entry: string) => ParsedChatAllowTarget; @@ -249,6 +291,9 @@ export function createAllowedChatSenderMatcher(params: { }); } +/** + * Parses chat target prefixes for allowlist entries, ignoring malformed values. + */ export function parseChatAllowTargetPrefixes( params: ChatTargetPrefixesParams, ): ParsedChatTarget | null { diff --git a/src/channels/plugins/config-helpers.ts b/src/channels/plugins/config-helpers.ts index aa846fac5ea1..73fef9ec1c8d 100644 --- a/src/channels/plugins/config-helpers.ts +++ b/src/channels/plugins/config-helpers.ts @@ -13,6 +13,9 @@ function isConfiguredSecretValue(value: unknown): boolean { return Boolean(value); } +/** + * Updates an account enabled flag in a channel config section. + */ export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; @@ -25,6 +28,7 @@ export function setAccountEnabledInConfigSection(params: { const base = channels?.[params.sectionKey] as ChannelSection | undefined; const hasAccounts = Boolean(base?.accounts); if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) { + // Legacy single-account sections store enabled at the channel root until accounts exist. return { ...params.cfg, channels: { @@ -57,6 +61,9 @@ export function setAccountEnabledInConfigSection(params: { } as OpenClawConfig; } +/** + * Deletes one account from a channel config section, pruning empty channel/accounts objects. + */ export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; @@ -91,6 +98,8 @@ export function deleteAccountFromConfigSection(params: { if (baseAccounts && Object.keys(baseAccounts).length > 0) { delete baseAccounts[accountKey]; const baseRecord = { ...(base as Record) }; + // Deleting the default account can also clear root-level credential fields that represented + // the legacy default account. for (const field of params.clearBaseFields ?? []) { if (field in baseRecord) { baseRecord[field] = undefined; @@ -119,6 +128,9 @@ export function deleteAccountFromConfigSection(params: { return nextCfg; } +/** + * Clears selected fields from one account entry and reports whether configured data was removed. + */ export function clearAccountEntryFields(params: { accounts?: Record; accountId: string; @@ -157,6 +169,7 @@ export function clearAccountEntryFields(params: { if (isValueSet(nextEntry[field])) { cleared = true; } + // Preserve unrelated account fields; remove the account entry only if it becomes empty. delete nextEntry[field]; } diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 5a51366328d8..dc58712cdfb2 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -18,9 +18,12 @@ type ExtendableZodObject = ZodTypeAny & { extend: (shape: Record) => ZodTypeAny; }; +/** Shared allowlist entry shape for channel sender/user ids. */ export const AllowFromEntrySchema = z.union([z.string(), z.number()]); +/** Optional allowlist array used by channel config schema builders. */ export const AllowFromListSchema = z.array(AllowFromEntrySchema).optional(); +/** Build the common nested DM config block used by channel account schemas. */ export function buildNestedDmConfigSchema(extraShape?: ZodRawShape) { const baseShape = { enabled: z.boolean().optional(), @@ -30,6 +33,7 @@ export function buildNestedDmConfigSchema(extraShape?: ZodRawShape) { return z.object(extraShape ? { ...baseShape, ...extraShape } : baseShape).optional(); } +/** Add `accounts` catchall and `defaultAccount` fields to a channel account schema. */ export function buildCatchallMultiAccountChannelSchema( accountSchema: T, ): T { @@ -112,6 +116,7 @@ function safeParseJsonSchema( }; } +/** Build a channel config schema from JSON Schema with runtime validation/default support. */ export function buildJsonChannelConfigSchema( schema: JsonSchemaObject, options?: BuildJsonChannelConfigSchemaOptions, @@ -126,6 +131,7 @@ export function buildJsonChannelConfigSchema( }; } +/** Build a channel config schema from Zod, exporting JSON Schema when available. */ export function buildChannelConfigSchema( schema: ZodTypeAny, options?: BuildChannelConfigSchemaOptions, @@ -158,6 +164,7 @@ export function buildChannelConfigSchema( }; } +/** Return a channel config schema for channels that intentionally accept no config keys. */ export function emptyChannelConfigSchema(): ChannelConfigSchema { return { schema: { diff --git a/src/channels/plugins/config-write-policy-shared.ts b/src/channels/plugins/config-write-policy-shared.ts index 2fdd117e419b..9b57b8dc7472 100644 --- a/src/channels/plugins/config-write-policy-shared.ts +++ b/src/channels/plugins/config-write-policy-shared.ts @@ -14,17 +14,26 @@ type ConfigWritePolicyConfig = { channels?: Record; }; +/** + * Channel/account scope used to evaluate config write policy. + */ export type ConfigWriteScopeLike = { channelId?: TChannelId | null; accountId?: string | null; }; +/** + * Target affected by a config write command. + */ export type ConfigWriteTargetLike = | { kind: "global" } | { kind: "channel"; scope: { channelId: TChannelId } } | { kind: "account"; scope: { channelId: TChannelId; accountId: string } } | { kind: "ambiguous"; scopes: ConfigWriteScopeLike[] }; +/** + * Authorization result for a config write under channel configWrites policy. + */ export type ConfigWriteAuthorizationResultLike = | { allowed: true } | { @@ -68,6 +77,9 @@ function resolveChannelAccountConfig( return resolveAccountEntry(channelConfig.accounts, normalizeAccountId(accountId)); } +/** + * Resolves whether config writes are enabled for a channel/account scope. + */ export function resolveChannelConfigWritesShared(params: { cfg: ConfigWritePolicyConfig; channelId?: string | null; @@ -82,6 +94,9 @@ export function resolveChannelConfigWritesShared(params: { return value !== false; } +/** + * Authorizes a channel-initiated config write against origin and target policy. + */ export function authorizeConfigWriteShared(params: { cfg: ConfigWritePolicyConfig; origin?: ConfigWriteScopeLike; @@ -94,6 +109,7 @@ export function authorizeConfigWriteShared(params: { if (params.target?.kind === "ambiguous") { return { allowed: false, reason: "ambiguous-target" }; } + // Both the message origin and the target section can disable channel-initiated config writes. if ( params.origin?.channelId && !resolveChannelConfigWritesShared({ @@ -117,6 +133,7 @@ export function authorizeConfigWriteShared(params: { if (seen.has(key)) { continue; } + // Deduplicate account scopes so a broad path does not report the same block twice. seen.add(key); if ( !resolveChannelConfigWritesShared({ @@ -135,6 +152,9 @@ export function authorizeConfigWriteShared(params: { return { allowed: true }; } +/** + * Resolves an explicit channel/account scope into a config write target. + */ export function resolveExplicitConfigWriteTargetShared( scope: ConfigWriteScopeLike, ): ConfigWriteTargetLike { @@ -148,6 +168,9 @@ export function resolveExplicitConfigWriteTargetShared(params: { path: string[]; normalizeChannelId: (raw: string) => TChannelId | null | undefined; @@ -177,6 +200,9 @@ export function resolveConfigWriteTargetFromPathShared(params: { result: Exclude, { allowed: true }>; fallbackChannelId?: TChannelId | null; diff --git a/src/channels/plugins/config-writes.ts b/src/channels/plugins/config-writes.ts index c32c75e7bc0b..d846e039be6d 100644 --- a/src/channels/plugins/config-writes.ts +++ b/src/channels/plugins/config-writes.ts @@ -12,14 +12,29 @@ import { type ConfigWriteTargetLike, } from "./config-write-policy-shared.js"; import type { ChannelId } from "./types.core.js"; + +/** + * Channel/account scope used by channel config write checks. + */ export type ConfigWriteScope = ConfigWriteScopeLike; + +/** + * Target affected by a channel config write. + */ export type ConfigWriteTarget = ConfigWriteTargetLike; + +/** + * Authorization result for a channel config write. + */ export type ConfigWriteAuthorizationResult = ConfigWriteAuthorizationResultLike; function isInternalConfigWriteMessageChannel(channel?: string | null): boolean { return normalizeLowercaseStringOrEmpty(channel) === "webchat"; } +/** + * Resolves whether config writes are enabled for a channel/account scope. + */ export function resolveChannelConfigWrites(params: { cfg: OpenClawConfig; channelId?: ChannelId | null; @@ -28,6 +43,9 @@ export function resolveChannelConfigWrites(params: { return resolveChannelConfigWritesShared(params); } +/** + * Authorizes a channel config write under origin and target policy. + */ export function authorizeConfigWrite(params: { cfg: OpenClawConfig; origin?: ConfigWriteScope; @@ -37,10 +55,16 @@ export function authorizeConfigWrite(params: { return authorizeConfigWriteShared(params); } +/** + * Resolves an explicit channel/account scope into a config write target. + */ export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget { return resolveExplicitConfigWriteTargetShared(scope); } +/** + * Infers the channel config write target from a config path. + */ export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget { return resolveConfigWriteTargetFromPathShared({ path, @@ -48,6 +72,9 @@ export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTar }); } +/** + * Checks whether a gateway client can bypass channel config write policy. + */ export function canBypassConfigWritePolicy(params: { channel?: string | null; gatewayClientScopes?: string[] | null; @@ -58,6 +85,9 @@ export function canBypassConfigWritePolicy(params: { }); } +/** + * Formats the user-facing denial message for a blocked channel config write. + */ export function formatConfigWriteDeniedMessage(params: { result: Exclude; fallbackChannelId?: ChannelId | null; diff --git a/src/channels/plugins/configured-binding-builtins.ts b/src/channels/plugins/configured-binding-builtins.ts index 46de0b452a6e..a8585eeb78ef 100644 --- a/src/channels/plugins/configured-binding-builtins.ts +++ b/src/channels/plugins/configured-binding-builtins.ts @@ -1,6 +1,9 @@ import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js"; import { registerConfiguredBindingConsumer } from "./configured-binding-consumers.js"; +/** + * Registers configured binding consumers bundled with core. + */ export function ensureConfiguredBindingBuiltinsRegistered(): void { registerConfiguredBindingConsumer(acpConfiguredBindingConsumer); } diff --git a/src/channels/plugins/configured-binding-consumers.ts b/src/channels/plugins/configured-binding-consumers.ts index c10fe35e2f31..61b8ec8a584e 100644 --- a/src/channels/plugins/configured-binding-consumers.ts +++ b/src/channels/plugins/configured-binding-consumers.ts @@ -7,11 +7,17 @@ import type { } from "./binding-types.js"; import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js"; +/** + * Parsed session-key facts used by configured binding consumers. + */ export type ParsedConfiguredBindingSessionKey = { channel: string; accountId: string; }; +/** + * Consumer that knows how to compile and materialize one configured binding target family. + */ export type ConfiguredBindingConsumer = { id: string; supports: (binding: ConfiguredBindingRuleConfig) => boolean; @@ -34,10 +40,16 @@ export type ConfiguredBindingConsumer = { const registeredConfiguredBindingConsumers = new Map(); +/** + * Lists registered configured binding consumers in registration order. + */ export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] { return [...registeredConfiguredBindingConsumers.values()]; } +/** + * Finds the first configured binding consumer that supports a raw binding rule. + */ export function resolveConfiguredBindingConsumer( binding: ConfiguredBindingRuleConfig, ): ConfiguredBindingConsumer | null { @@ -49,6 +61,9 @@ export function resolveConfiguredBindingConsumer( return null; } +/** + * Registers a configured binding consumer idempotently by trimmed id. + */ export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void { const id = consumer.id.trim(); if (!id) { diff --git a/src/channels/plugins/configured-binding-match.ts b/src/channels/plugins/configured-binding-match.ts index 1f0f6df52705..d8c2a3f4f180 100644 --- a/src/channels/plugins/configured-binding-match.ts +++ b/src/channels/plugins/configured-binding-match.ts @@ -14,6 +14,13 @@ import type { ChannelConfiguredBindingMatch, } from "./types.adapters.js"; +/** + * Matching helpers for configured channel binding rules. + */ + +/** + * Ranks account pattern matches for configured binding rules. + */ export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { const trimmed = (match ?? "").trim(); if (!trimmed) { @@ -38,11 +45,17 @@ function matchCompiledBindingConversation(params: { }); } +/** + * Normalizes a raw channel id into a configured-binding channel id. + */ export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null { const normalized = normalizeOptionalLowercaseString(raw); return normalized ? (normalized as ConfiguredBindingChannel) : null; } +/** + * Converts an outbound conversation ref into configured-binding match input. + */ export function toConfiguredBindingConversationRef(conversation: ConversationRef): { channel: ConfiguredBindingChannel; accountId: string; @@ -62,6 +75,9 @@ export function toConfiguredBindingConversationRef(conversation: ConversationRef }; } +/** + * Materializes a configured binding record from the winning rule and conversation. + */ export function materializeConfiguredBindingRecord(params: { rule: CompiledConfiguredBinding; accountId: string; @@ -73,6 +89,9 @@ export function materializeConfiguredBindingRecord(params: { }); } +/** + * Resolves the best configured binding rule for a conversation. + */ export function resolveMatchingConfiguredBinding(params: { rules: CompiledConfiguredBinding[]; conversation: ReturnType; @@ -93,6 +112,8 @@ export function resolveMatchingConfiguredBinding(params: { rule.accountPattern, params.conversation.accountId, ); + // Exact account matches beat wildcard matches, but both still respect the + // provider's per-conversation match priority within that account tier. if (accountMatchPriority === 0) { continue; } diff --git a/src/channels/plugins/configured-binding-registry.ts b/src/channels/plugins/configured-binding-registry.ts index 137b5bbc139a..93b6be10fb6c 100644 --- a/src/channels/plugins/configured-binding-registry.ts +++ b/src/channels/plugins/configured-binding-registry.ts @@ -35,6 +35,8 @@ function resolveMaterializedConfiguredBinding(params: { if (!resolved) { return null; } + // Matching returns provider-specific target facts; materialization turns them into the + // persisted session binding record and stateful target descriptor used at runtime. return { conversation, resolved, @@ -46,6 +48,9 @@ function resolveMaterializedConfiguredBinding(params: { }; } +/** + * Warms and counts the compiled configured binding registry for a config snapshot. + */ export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): { bindingCount: number; channelCount: number; @@ -53,6 +58,9 @@ export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg)); } +/** + * Resolves a configured binding record from explicit channel/account/conversation ids. + */ export function resolveConfiguredBindingRecord(params: { cfg: OpenClawConfig; channel: string; @@ -75,6 +83,9 @@ export function resolveConfiguredBindingRecord(params: { }); } +/** + * Resolves a configured binding record from a normalized conversation reference. + */ export function resolveConfiguredBindingRecordForConversation(params: { cfg: OpenClawConfig; conversation: ConversationRef; @@ -86,6 +97,9 @@ export function resolveConfiguredBindingRecordForConversation(params: { return resolved.materializedTarget; } +/** + * Resolves the full configured binding match, including compiled rule and match diagnostics. + */ export function resolveConfiguredBinding(params: { cfg: OpenClawConfig; conversation: ConversationRef; @@ -102,6 +116,9 @@ export function resolveConfiguredBinding(params: { }; } +/** + * Resolves a configured binding record by the stateful target session key. + */ export function resolveConfiguredBindingRecordBySessionKey(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/channels/plugins/configured-binding-session-lookup.ts b/src/channels/plugins/configured-binding-session-lookup.ts index e4baa4057d8d..025eca899cb1 100644 --- a/src/channels/plugins/configured-binding-session-lookup.ts +++ b/src/channels/plugins/configured-binding-session-lookup.ts @@ -7,6 +7,9 @@ import { resolveCompiledBindingChannel, } from "./configured-binding-match.js"; +/** + * Resolves a configured binding record from a stateful target session key. + */ export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: { registry: CompiledConfiguredBindingRegistry; sessionKey: string; @@ -42,6 +45,8 @@ export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: { if (accountMatchPriority === 0) { continue; } + // Materialize candidate targets before matching because wildcard rules can derive + // provider-specific target session keys from parsed session-key facts. const materializedTarget = materializeConfiguredBindingRecord({ rule, accountId: parsed.accountId, @@ -56,6 +61,7 @@ export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: { }) ?? materializedTarget.record.targetSessionKey === sessionKey; if (matchesSessionKey) { if (accountMatchPriority === 2) { + // Exact account matches outrank wildcard account bindings for the same session key. exactMatch = materializedTarget; break; } diff --git a/src/channels/plugins/configured-state.ts b/src/channels/plugins/configured-state.ts index 82f238fd5391..7fd00ac6ffcc 100644 --- a/src/channels/plugins/configured-state.ts +++ b/src/channels/plugins/configured-state.ts @@ -5,12 +5,18 @@ import { listBundledChannelIdsForPackageState, } from "./package-state-probes.js"; +/** + * Lists bundled channel ids that expose configured-state detectors. + */ export function listBundledChannelIdsWithConfiguredState( discovery?: PluginDiscoveryResult, ): string[] { return listBundledChannelIdsForPackageState("configuredState", discovery); } +/** + * Checks whether a bundled channel reports configured state for the current config. + */ export function hasBundledChannelConfiguredState(params: { channelId: string; cfg: OpenClawConfig; diff --git a/src/channels/plugins/contracts/inbound-testkit.ts b/src/channels/plugins/contracts/inbound-testkit.ts index cd7b0bd5fdb2..6a3bdfe62adf 100644 --- a/src/channels/plugins/contracts/inbound-testkit.ts +++ b/src/channels/plugins/contracts/inbound-testkit.ts @@ -1,5 +1,7 @@ import { vi } from "vitest"; +// Test helper for inbound channel contract suites. It replaces dispatch with a +// capture mock while preserving the rest of the actual module surface. export function buildDispatchInboundCaptureMock>( actual: T, setCtx: (ctx: unknown) => void, diff --git a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts index 193e8f48b515..e8c87adf348e 100644 --- a/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts +++ b/src/channels/plugins/contracts/test-helpers/bundled-channel-plugin-loader.ts @@ -9,6 +9,8 @@ import { listBundledChannelPluginIds as listCatalogBundledChannelPluginIds } fro import type { ChannelId } from "../../channel-id.types.js"; import type { ChannelPlugin } from "../../types.js"; +// Loads bundled channel plugin public surfaces for core contract tests without +// reaching into extension-private source paths. type ChannelPluginApiModule = Record; type ChannelDirectoryContractModule = Record; @@ -212,6 +214,7 @@ export function listBundledChannelPluginIds(): readonly ChannelId[] { return listCatalogBundledChannelPluginIds() as ChannelId[]; } +/** Returns a bundled channel plugin from its generated public API artifact. */ export async function getBundledChannelPluginAsync( id: ChannelId, ): Promise { @@ -224,6 +227,8 @@ export async function getBundledChannelPluginAsync( return (await cachedPromise) ?? undefined; } + // Cache both resolved plugins and in-flight loads so sharded contract suites + // do not repeatedly import the same generated plugin artifact. const loading = loadBundledPluginPublicSurface({ pluginId: id, artifactBasename: "channel-plugin-api.js", diff --git a/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts b/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts index 27d45e5b02ff..c521827ffec4 100644 --- a/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts +++ b/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { resolvePreferredOpenClawTmpDir } from "../../../../infra/tmp-openclaw-dir.js"; import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "../../catalog.js"; +// Reusable catalog contract suites for channel plugins. Fixtures exercise the +// same manifest/catalog loading paths that setup uses for install-on-demand. type CatalogEntryMeta = { id: string; label: string; @@ -49,6 +51,7 @@ export function describeChannelCatalogEntryContract(params: { }); } +/** Verifies catalog entries that come only from bundled manifest metadata. */ export function describeBundledMetadataOnlyChannelCatalogContract(params: { pluginId: string; packageName: string; @@ -101,6 +104,7 @@ export function describeBundledMetadataOnlyChannelCatalogContract(params: { }); } +/** Verifies fallback ordering between bundled, official, and external catalogs. */ export function describeOfficialFallbackChannelCatalogContract(params: { channelId: string; npmSpec: string; diff --git a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts index 00f18adaa3c5..2c7f5d8adb54 100644 --- a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { resolvePreferredOpenClawTmpDir } from "../../../../infra/tmp-openclaw-dir.js"; import { listChannelPluginCatalogEntries } from "../../catalog.js"; +// Shared catalog contract suites for synthetic external/discovered channel +// plugins. Each fixture writes real manifest files to prove parser behavior. function createCatalogEntry(params: { packageName: string; channelId: string; @@ -116,6 +118,7 @@ function expectCatalogEntryMatch(params: { ).toMatchObject(params.expected); } +/** Installs catalog entry tests shared by plugin registry and manifest suites. */ export function describeChannelPluginCatalogEntriesContract() { describe("channel plugin catalog entries contract", () => { it.each([ @@ -473,6 +476,7 @@ export function describeChannelPluginCatalogEntriesContract() { }); } +/** Installs catalog path resolution tests that depend on env/home/state paths. */ export function describeChannelPluginCatalogPathResolutionContract() { describe("channel plugin catalog path resolution contract", () => { it.each([ diff --git a/src/channels/plugins/contracts/test-helpers/config-write-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/config-write-contract-suites.ts index 4d5e41833fa5..692b4cb165b0 100644 --- a/src/channels/plugins/contracts/test-helpers/config-write-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/config-write-contract-suites.ts @@ -8,6 +8,8 @@ import { resolveConfigWriteTargetFromPath, } from "../../config-writes.js"; +// Reusable config-write authorization contract suites. They prove origin and +// target channel/account policy are resolved before plugin writes mutate config. const demoOriginChannelId = "demo-origin"; const demoTargetChannelId = "demo-target"; @@ -85,6 +87,7 @@ function expectFormattedDeniedMessage( ).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`); } +/** Installs policy tests for allowed, denied, and bypassed config writes. */ export function describeChannelConfigWritePolicyContract() { describe("authorizeConfigWrite policy contract", () => { it.each([ @@ -136,6 +139,7 @@ export function describeChannelConfigWritePolicyContract() { }); } +/** Installs target-resolution tests for path-derived and explicit writes. */ export function describeChannelConfigWriteTargetContract() { describe("authorizeConfigWrite target contract", () => { it.each([ diff --git a/src/channels/plugins/contracts/test-helpers/group-policy-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/group-policy-contract-suites.ts index 74bf7fea44a3..d24921e85953 100644 --- a/src/channels/plugins/contracts/test-helpers/group-policy-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/group-policy-contract-suites.ts @@ -1,10 +1,13 @@ import { expect, it } from "vitest"; import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../config/runtime-group-policy.js"; +// Shared runtime group-policy contract for open-provider channels. Missing +// provider config must fail closed to allowlist regardless of open defaults. export type RuntimeGroupPolicyResolver = ( params: Parameters[0], ) => ReturnType; +/** Installs fallback-policy tests for a channel-specific resolver wrapper. */ export function installChannelRuntimeGroupPolicyFallbackSuite(params: { configuredLabel: string; defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open"; diff --git a/src/channels/plugins/contracts/test-helpers/group-policy-contract.ts b/src/channels/plugins/contracts/test-helpers/group-policy-contract.ts index 784e1de8110b..bb91716f75b3 100644 --- a/src/channels/plugins/contracts/test-helpers/group-policy-contract.ts +++ b/src/channels/plugins/contracts/test-helpers/group-policy-contract.ts @@ -1,5 +1,7 @@ import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../config/runtime-group-policy.js"; +// Channel-specific exports for contract tests that need stable resolver names +// while still exercising the shared open-provider policy implementation. const resolveWhatsAppRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy; const resolveZaloRuntimeGroupPolicy = resolveOpenProviderRuntimeGroupPolicy; diff --git a/src/channels/plugins/contracts/test-helpers/manifest.ts b/src/channels/plugins/contracts/test-helpers/manifest.ts index 932620d7b0fe..4e2e7e60d520 100644 --- a/src/channels/plugins/contracts/test-helpers/manifest.ts +++ b/src/channels/plugins/contracts/test-helpers/manifest.ts @@ -1,3 +1,5 @@ +// Shared manifest contract constants for bundled channel plugin surface tests. +// Keep these lists narrow so each suite checks only the surfaces it owns. export const channelPluginSurfaceKeys = [ "actions", "setup", @@ -17,4 +19,5 @@ export const sessionBindingContractChannelIds = [ "telegram", ] as const; +/** Channel id union for bundled session-binding contract fixtures. */ export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number]; diff --git a/src/channels/plugins/contracts/test-helpers/registry-plugin.ts b/src/channels/plugins/contracts/test-helpers/registry-plugin.ts index 67b03d53de48..43e773ba94a7 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-plugin.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-plugin.ts @@ -1,6 +1,8 @@ import type { ChannelId } from "../../channel-id.types.js"; import { listBundledChannelPluginIds } from "./bundled-channel-plugin-loader.js"; +// Shard helper for plugin contract registry tests. It keeps shard assignment +// deterministic by using the bundled channel catalog order. type PluginContractRef = { id: ChannelId; }; @@ -14,6 +16,7 @@ function getBundledChannelPluginIdsForShard(params: { ); } +/** Returns bundled plugin refs assigned to one contract-test shard. */ export function getPluginContractRegistryShardRefs(params: { shardIndex: number; shardCount: number; diff --git a/src/channels/plugins/contracts/test-helpers/runtime-artifacts.ts b/src/channels/plugins/contracts/test-helpers/runtime-artifacts.ts index aa8ba9fcc1b8..dcf7a1782ad2 100644 --- a/src/channels/plugins/contracts/test-helpers/runtime-artifacts.ts +++ b/src/channels/plugins/contracts/test-helpers/runtime-artifacts.ts @@ -7,6 +7,8 @@ import { resolvePluginRuntimeRecord, } from "../../../../plugins/runtime/runtime-plugin-boundary.js"; +// Resolves generated bundled channel artifacts for contract tests. Prefer the +// runtime record, with workspace source fallback for local unbuilt checkouts. const REPO_ROOT = fileURLToPath(new URL("../../../../../", import.meta.url)); function resolveBundledChannelWorkspaceArtifactPath( @@ -50,6 +52,7 @@ export function resolveBundledChannelContractArtifactUrl( return pathToFileURL(modulePath).href; } +/** Imports a generated bundled channel artifact through the contract boundary. */ export async function importBundledChannelContractArtifact( pluginId: string, entryBaseName: string, diff --git a/src/channels/plugins/contracts/test-helpers/surface-contract-registry.ts b/src/channels/plugins/contracts/test-helpers/surface-contract-registry.ts index 15232b4a050e..81ebceeab74a 100644 --- a/src/channels/plugins/contracts/test-helpers/surface-contract-registry.ts +++ b/src/channels/plugins/contracts/test-helpers/surface-contract-registry.ts @@ -1,6 +1,8 @@ import type { ChannelId } from "../../channel-id.types.js"; import { listBundledChannelPluginIds } from "./bundled-channel-plugin-loader.js"; +// Registry selectors for bundled channel contract shards. Explicit allowlists +// keep optional threading/directory suites off channels without that surface. type ThreadingContractRef = { id: ChannelId; }; @@ -48,6 +50,7 @@ function getBundledChannelPluginIdsForShard(params: { ); } +/** Returns all bundled channel ids assigned to one surface-contract shard. */ export function getSurfaceContractRegistryShardIds(params: { shardIndex: number; shardCount: number; @@ -55,6 +58,7 @@ export function getSurfaceContractRegistryShardIds(params: { return getBundledChannelPluginIdsForShard(params); } +/** Returns shard refs for bundled channels expected to expose threading hooks. */ export function getThreadingContractRegistryShardRefs(params: { shardIndex: number; shardCount: number; @@ -66,6 +70,7 @@ export function getThreadingContractRegistryShardRefs(params: { const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]); +/** Returns shard refs for bundled channels expected to expose directory hooks. */ export function getDirectoryContractRegistryShardRefs(params: { shardIndex: number; shardCount: number; diff --git a/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts b/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts index 07b008842da5..9d521da3b092 100644 --- a/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts +++ b/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts @@ -1,6 +1,9 @@ import { expect } from "vitest"; import type { ChannelPlugin } from "../../types.js"; +// Shape assertions for optional channel plugin surfaces. These tests validate +// public adapter contracts without invoking channel-specific runtime behavior. +/** Asserts the minimum callable shape for one declared channel plugin surface. */ export function expectChannelSurfaceContract(params: { plugin: Pick< ChannelPlugin, @@ -77,6 +80,8 @@ export function expectChannelSurfaceContract(params: { ].some((value) => typeof value === "function"), ).toBe(true); if (messaging?.targetResolver) { + // Target resolvers are optional but, when present, must expose either a + // callable resolver or stable hint metadata for tool UX. if (messaging.targetResolver.looksLikeId) { expect(typeof messaging.targetResolver.looksLikeId).toBe("function"); } diff --git a/src/channels/plugins/contracts/test-helpers/threading-directory-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/threading-directory-contract-suites.ts index 43c065e5e8d1..f6a74a4f8916 100644 --- a/src/channels/plugins/contracts/test-helpers/threading-directory-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/threading-directory-contract-suites.ts @@ -9,6 +9,8 @@ import type { } from "../../types.core.js"; import type { ChannelPlugin } from "../../types.js"; +// Shared threading/directory contract assertions for bundled channel plugins. +// The runtime proxy fails fast if a directory helper unexpectedly needs IO. const contractRuntime = new Proxy(Object.create(null), { get(_target, property) { throw new Error(`Directory contract unexpectedly accessed runtime.${String(property)}`); @@ -77,12 +79,14 @@ function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) { expect(binding.labelNoun.trim()).not.toBe(""); } +/** Asserts that a plugin declares the threading adapter under test. */ export function expectChannelThreadingBaseContract( plugin: Pick, ) { expect(plugin.threading).toBeDefined(); } +/** Exercises optional threading hooks and checks normalized return shapes. */ export function expectChannelThreadingReturnValuesNormalized( plugin: Pick, ) { @@ -168,6 +172,7 @@ export function expectChannelThreadingReturnValuesNormalized( } } +/** Exercises directory lookups and checks normalized entry shapes when possible. */ export async function expectChannelDirectoryBaseContract(params: { plugin: Pick; coverage?: "lookups" | "presence"; @@ -186,6 +191,8 @@ export async function expectChannelDirectoryBaseContract(params: { const accountId = params.accountId ?? "default"; if (params.coverage === "presence") { + // Presence-only channels advertise a directory surface but cannot perform + // deterministic offline lookup calls in shared contract fixtures. return; } const self = await directory?.self?.({ diff --git a/src/channels/plugins/conversation-bindings.ts b/src/channels/plugins/conversation-bindings.ts index ce5b4324507e..ad387e40f9df 100644 --- a/src/channels/plugins/conversation-bindings.ts +++ b/src/channels/plugins/conversation-bindings.ts @@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getChannelPlugin } from "./registry.js"; import type { ChannelId } from "./types.public.js"; +/** + * Starts the optional per-channel conversation binding manager. + * + * Channels without binding state return `null` so callers can install + * lifecycle hooks without special-casing plugins that do not support them. + */ export async function createChannelConversationBindingManager(params: { channelId: ChannelId; cfg: OpenClawConfig; @@ -17,6 +23,12 @@ export async function createChannelConversationBindingManager(params: { }); } +/** + * Updates the idle timeout for bindings that match a session key. + * + * Missing plugin support is a no-op because session commands fan out through + * generic channel helpers while only some channels keep conversation bindings. + */ export function setChannelConversationBindingIdleTimeoutBySessionKey(params: { channelId: ChannelId; targetSessionKey: string; @@ -40,6 +52,12 @@ export function setChannelConversationBindingIdleTimeoutBySessionKey(params: { }); } +/** + * Updates the max age for bindings that match a session key. + * + * Returns the modified binding snapshots so command handlers can report the + * concrete sessions affected by the generic channel command. + */ export function setChannelConversationBindingMaxAgeBySessionKey(params: { channelId: ChannelId; targetSessionKey: string; diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 37e129b3df25..53d5bfe263e4 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -7,6 +7,10 @@ import type { OpenClawConfig } from "../../config/types.js"; import type { DirectoryConfigParams } from "./directory-types.js"; import type { ChannelDirectoryEntry } from "./types.public.js"; +/** + * Shared directory helpers for plugin config-derived user/group listings. + */ + function resolveDirectoryQuery(query?: string | null): string { return normalizeLowercaseStringOrEmpty(query); } @@ -15,6 +19,9 @@ function resolveDirectoryLimit(limit?: number | null): number | undefined { return typeof limit === "number" && limit > 0 ? limit : undefined; } +/** + * Applies case-insensitive query filtering and a positive result limit to ids. + */ export function applyDirectoryQueryAndLimit( ids: string[], params: { query?: string | null; limit?: number | null }, @@ -34,6 +41,9 @@ export function applyDirectoryQueryAndLimit( return filtered; } +/** + * Converts normalized ids into channel directory entries of one kind. + */ export function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { const entries: ChannelDirectoryEntry[] = []; for (const id of ids) { @@ -79,6 +89,9 @@ function dedupeDirectoryIds(ids: string[]): string[] { return uniqueStrings(ids); } +/** + * Collects unique normalized ids from multiple raw config sources. + */ export function collectNormalizedDirectoryIds(params: { sources: Iterable[]; normalizeId: (entry: string) => string | null | undefined; @@ -100,6 +113,13 @@ export function collectNormalizedDirectoryIds(params: { return Array.from(ids); } +/** + * Lists directory entries from arbitrary config sources. + * + * Callers supply source iterables and an id normalizer so channel-specific + * config shapes share the same wildcard filtering, dedupe, query, and limit + * behavior. + */ export function listDirectoryEntriesFromSources(params: { kind: "user" | "group"; sources: Iterable[]; @@ -114,6 +134,9 @@ export function listDirectoryEntriesFromSources(params: { return toDirectoryEntries(params.kind, applyDirectoryQueryAndLimit(ids, params)); } +/** + * Lists directory entries for channels that inspect optional configured accounts. + */ export function listInspectedDirectoryEntriesFromSources( params: DirectoryConfigParams & { kind: "user" | "group"; @@ -126,6 +149,8 @@ export function listInspectedDirectoryEntriesFromSources( }, ): ChannelDirectoryEntry[] { const account = params.inspectAccount(params.cfg, params.accountId); + // Missing optional accounts produce an empty directory instead of forcing + // setup callers to special-case unconfigured channel state. if (!account) { return []; } @@ -138,6 +163,9 @@ export function listInspectedDirectoryEntriesFromSources( }); } +/** + * Builds an async lister around an inspected-account directory source. + */ export function createInspectedDirectoryEntriesLister(params: { kind: "user" | "group"; inspectAccount: ( @@ -154,6 +182,9 @@ export function createInspectedDirectoryEntriesLister(params: }); } +/** + * Lists directory entries for channels whose account resolver always returns a config object. + */ export function listResolvedDirectoryEntriesFromSources( params: DirectoryConfigParams & { kind: "user" | "group"; @@ -172,6 +203,9 @@ export function listResolvedDirectoryEntriesFromSources( }); } +/** + * Builds an async lister around a required resolved-account directory source. + */ export function createResolvedDirectoryEntriesLister(params: { kind: "user" | "group"; resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; @@ -185,6 +219,9 @@ export function createResolvedDirectoryEntriesLister(params: { }); } +/** + * Lists user directory entries from an allowlist-style config array. + */ export function listDirectoryUserEntriesFromAllowFrom(params: { allowFrom?: readonly unknown[]; query?: string | null; @@ -200,6 +237,9 @@ export function listDirectoryUserEntriesFromAllowFrom(params: { return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } +/** + * Lists user entries from both direct allowlists and map-key config. + */ export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: { allowFrom?: readonly unknown[]; map?: Record; @@ -221,6 +261,9 @@ export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: { return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } +/** + * Lists group directory entries from map-key config. + */ export function listDirectoryGroupEntriesFromMapKeys(params: { groups?: Record; query?: string | null; @@ -236,6 +279,9 @@ export function listDirectoryGroupEntriesFromMapKeys(params: { return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } +/** + * Lists group entries from both map-key config and allowlist values. + */ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: { groups?: Record; allowFrom?: readonly unknown[]; @@ -257,6 +303,9 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: { return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } +/** + * Lists resolved-account user entries from an allowlist selector. + */ export function listResolvedDirectoryUserEntriesFromAllowFrom( params: DirectoryConfigParams & { resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; @@ -273,6 +322,9 @@ export function listResolvedDirectoryUserEntriesFromAllowFrom( }); } +/** + * Lists resolved-account group entries from a group-map selector. + */ export function listResolvedDirectoryGroupEntriesFromMapKeys( params: DirectoryConfigParams & { resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; diff --git a/src/channels/plugins/directory-types.ts b/src/channels/plugins/directory-types.ts index 9adcbcad6848..2a338dd647dc 100644 --- a/src/channels/plugins/directory-types.ts +++ b/src/channels/plugins/directory-types.ts @@ -1,5 +1,11 @@ import type { OpenClawConfig } from "../../config/types.js"; +/** + * Shared input for channel directory lookups. + * + * Directory-capable plugins receive the active config plus optional account + * scope, search text, and result limit from setup or command surfaces. + */ export type DirectoryConfigParams = { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/channels/plugins/dm-access.ts b/src/channels/plugins/dm-access.ts index 53c2c869dff0..5c396940ede1 100644 --- a/src/channels/plugins/dm-access.ts +++ b/src/channels/plugins/dm-access.ts @@ -1,13 +1,30 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +/** + * Shared DM access helpers for channel config, doctor migration, and SDK facade exports. + */ + +/** + * Selects whether canonical DM fields live at the top level or under `dm`. + */ export type ChannelDmAllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; + +/** + * Supported direct-message policy values for channel account config. + */ export type ChannelDmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +/** + * Normalized DM access view consumed by channel setup and reply gates. + */ export type ChannelDmAccess = { dmPolicy?: ChannelDmPolicy; allowFrom?: Array; }; +/** + * Mutable config record used while migrating channel account DM fields. + */ export type DmAccessRecord = Record; type DmFieldKind = "policy" | "allowFrom"; @@ -17,11 +34,17 @@ type DmFieldPaths = { legacyPath: readonly string[]; }; +/** + * Result returned by compatibility helpers after optional DM config mutation. + */ export type CompatMutationResult = { entry: DmAccessRecord; changed: boolean; }; +/** + * Narrows a raw string to a supported channel DM policy. + */ export function normalizeChannelDmPolicy(value: string | undefined): ChannelDmPolicy | undefined { return value === "pairing" || value === "allowlist" || value === "open" || value === "disabled" ? value @@ -42,6 +65,8 @@ function cloneDm(entry: DmAccessRecord): DmAccessRecord | null { function resolveDmFieldPaths(mode: ChannelDmAllowFromMode, kind: DmFieldKind): DmFieldPaths { const topKey = kind === "policy" ? "dmPolicy" : "allowFrom"; const nestedKey = kind === "policy" ? "policy" : "allowFrom"; + // Some channels kept DM access under `dm.*`, while newer config uses top + // fields. Resolve both names here so read/write/migration logic stays paired. if (mode === "nestedOnly") { return { canonicalPath: ["dm", nestedKey], @@ -122,6 +147,9 @@ function readCanonicalOrLegacy( return readPath(entry, paths.canonicalPath) ?? readPath(entry, paths.legacyPath); } +/** + * Resolves the effective DM policy from account, parent account, and default policy. + */ export function resolveChannelDmPolicy(params: { account?: DmAccessRecord | null; parent?: DmAccessRecord | null; @@ -136,6 +164,9 @@ export function resolveChannelDmPolicy(params: { return typeof value === "string" ? normalizeChannelDmPolicy(value) : undefined; } +/** + * Resolves the effective DM allowlist from account or parent account config. + */ export function resolveChannelDmAllowFrom(params: { account?: DmAccessRecord | null; parent?: DmAccessRecord | null; @@ -148,6 +179,9 @@ export function resolveChannelDmAllowFrom(params: { return Array.isArray(value) ? (value as Array) : undefined; } +/** + * Resolves policy and allowlist together for channel access checks. + */ export function resolveChannelDmAccess(params: { account?: DmAccessRecord | null; parent?: DmAccessRecord | null; @@ -160,6 +194,9 @@ export function resolveChannelDmAccess(params: { }; } +/** + * Writes a canonical DM allowlist and removes the matching legacy alias. + */ export function setCanonicalDmAllowFrom(params: { entry: DmAccessRecord; mode: ChannelDmAllowFromMode; @@ -178,6 +215,9 @@ export function setCanonicalDmAllowFrom(params: { params.changes?.push(`- ${formatPath(params.pathPrefix, paths.canonicalPath)}: ${params.reason}`); } +/** + * Migrates legacy `dm.*` aliases into the canonical DM access fields. + */ export function normalizeLegacyDmAliases(params: { entry: DmAccessRecord; pathPrefix: string; @@ -190,6 +230,8 @@ export function normalizeLegacyDmAliases(params: { const dm = cloneDm(updated); let dmChanged = false; + // Preserve an explicit canonical value when it exists, but remove a matching + // legacy alias so doctor does not keep reporting the same repair. const topDmPolicy = updated.dmPolicy; const legacyDmPolicy = dm?.policy; if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { @@ -213,6 +255,8 @@ export function normalizeLegacyDmAliases(params: { } if (params.promoteAllowFrom !== false) { + // `allowFrom` promotion is optional because some channels keep nested DM + // allowlists as the canonical shape until their config schema moves. const topAllowFrom = updated.allowFrom; const legacyAllowFrom = dm?.allowFrom; if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { @@ -260,6 +304,9 @@ function hasWildcard(list?: Array) { return list?.some((value) => String(value).trim() === "*") ?? false; } +/** + * Ensures `dmPolicy="open"` has the wildcard allowlist required by access gates. + */ export function ensureOpenDmPolicyAllowFromWildcard(params: { entry: DmAccessRecord; mode: ChannelDmAllowFromMode; @@ -277,6 +324,8 @@ export function ensureOpenDmPolicyAllowFromWildcard(params: { const policyPaths = resolveDmFieldPaths(params.mode, "policy"); const canonicalPolicy = readPath(params.entry, policyPaths.canonicalPath); const legacyPolicy = readPath(params.entry, policyPaths.legacyPath); + // Open policy may have arrived through the legacy nested path; move it before + // adding the wildcard so all repair output points at canonical config. if (canonicalPolicy === undefined && legacyPolicy === "open") { writePath(params.entry, policyPaths.canonicalPath, "open"); deletePath(params.entry, policyPaths.legacyPath); diff --git a/src/channels/plugins/doctor-contract-api.ts b/src/channels/plugins/doctor-contract-api.ts index 69657674b945..9978b17c90c5 100644 --- a/src/channels/plugins/doctor-contract-api.ts +++ b/src/channels/plugins/doctor-contract-api.ts @@ -2,11 +2,20 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; import type { OpenClawConfig } from "../../config/types.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +/** + * Config returned after a bundled channel normalizes legacy compatibility state. + */ type BundledChannelDoctorCompatibilityMutation = { config: OpenClawConfig; changes: string[]; }; +/** + * Public doctor hooks exported by bundled channel plugins. + * + * Doctor keeps these hooks channel-owned so core can run config repair without + * importing plugin internals. + */ type BundledChannelDoctorContractApi = { legacyConfigRules?: readonly LegacyConfigRule[]; normalizeCompatibilityConfig?: (params: { @@ -25,6 +34,8 @@ function loadBundledChannelPublicArtifact( artifactBasename, }); } catch (error) { + // Only a missing public artifact is optional. Other loader errors mean a + // channel's doctor contract is present but broken, so surface them. if ( error instanceof Error && error.message.startsWith("Unable to resolve bundled plugin public surface ") @@ -36,6 +47,12 @@ function loadBundledChannelPublicArtifact( return undefined; } +/** + * Loads a bundled channel's public doctor contract. + * + * `doctor-contract-api.js` is the canonical file; `contract-api.js` remains a + * shipped fallback for channels that exposed doctor hooks before the split. + */ export function loadBundledChannelDoctorContractApi( channelId: string, ): BundledChannelDoctorContractApi | undefined { diff --git a/src/channels/plugins/exec-approval-local.ts b/src/channels/plugins/exec-approval-local.ts index 912392a43940..2f50a2ecc1a1 100644 --- a/src/channels/plugins/exec-approval-local.ts +++ b/src/channels/plugins/exec-approval-local.ts @@ -3,6 +3,8 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasActiveApprovalNativeRouteRuntime } from "../../infra/approval-native-route-coordinator.js"; import { getChannelPlugin, normalizeChannelId } from "./registry.js"; +// Lets channel plugins suppress the generic local exec approval prompt when a +// native approval route is already active for the same channel/account. export function shouldSuppressLocalExecApprovalPrompt(params: { channel?: string | null; cfg: OpenClawConfig; @@ -13,6 +15,8 @@ export function shouldSuppressLocalExecApprovalPrompt(params: { if (!channel) { return false; } + // Native-route state is process-local and transient. Pass it as a hint so the + // channel owns the UX decision without duplicating route lookup logic. return ( getChannelPlugin(channel)?.outbound?.shouldSuppressLocalPayloadPrompt?.({ cfg: params.cfg, diff --git a/src/channels/plugins/exposure.ts b/src/channels/plugins/exposure.ts index fa34408b5478..1b7587179df9 100644 --- a/src/channels/plugins/exposure.ts +++ b/src/channels/plugins/exposure.ts @@ -1,8 +1,13 @@ import type { ChannelMeta } from "./types.core.js"; +/** + * Resolves where a channel should appear in configured, setup, and docs views. + */ export function resolveChannelExposure( meta: Pick, ) { + // `showConfigured` and `showInSetup` are legacy metadata fields; keep them + // as fallback inputs so older bundled manifests keep their visibility. return { configured: meta.exposure?.configured ?? meta.showConfigured ?? true, setup: meta.exposure?.setup ?? meta.showInSetup ?? true, @@ -10,12 +15,18 @@ export function resolveChannelExposure( }; } +/** + * Returns whether the channel should be listed for already configured agents. + */ export function isChannelVisibleInConfiguredLists( meta: Pick, ): boolean { return resolveChannelExposure(meta).configured; } +/** + * Returns whether the channel should be offered during setup/onboarding. + */ export function isChannelVisibleInSetup( meta: Pick, ): boolean { diff --git a/src/channels/plugins/gateway-auth-bypass.ts b/src/channels/plugins/gateway-auth-bypass.ts index 9d51b480c583..fe199da09eba 100644 --- a/src/channels/plugins/gateway-auth-bypass.ts +++ b/src/channels/plugins/gateway-auth-bypass.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +/** + * Lightweight public artifact contract for channel gateway auth bypass paths. + */ type GatewayAuthBypassApi = { resolveGatewayAuthBypassPaths?: (params: { cfg: OpenClawConfig }) => readonly unknown[]; }; @@ -15,6 +18,8 @@ function loadBundledChannelGatewayAuthApi(channelId: string): GatewayAuthBypassA artifactBasename: GATEWAY_AUTH_API_ARTIFACT_BASENAME, }); } catch (error) { + // Missing gateway auth artifacts are optional. Any other load failure means + // the artifact exists but cannot be trusted, so propagate it to callers. if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { return undefined; } @@ -22,6 +27,9 @@ function loadBundledChannelGatewayAuthApi(channelId: string): GatewayAuthBypassA } } +/** + * Resolves configured gateway auth bypass paths from a bundled channel artifact. + */ export function resolveBundledChannelGatewayAuthBypassPaths(params: { channelId: string; cfg: OpenClawConfig; diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 3ec7836c8e24..9123066b1ade 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -1,3 +1,5 @@ +// Runtime channel-plugin entrypoint for registry and config matching helpers. +// Keep plugin-facing type exports narrow; broader SDK barrels live elsewhere. export { getChannelPlugin, getLoadedChannelPlugin, diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index 784d65f0886b..9211cc4a1a52 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -5,6 +5,8 @@ import { getBootstrapChannelPlugin } from "./bootstrap-registry.js"; import { loadBundledChannelDoctorContractApi } from "./doctor-contract-api.js"; import type { ChannelId } from "./types.public.js"; +// Collects channel-owned legacy config rules for validator fast paths. Bundled +// contract APIs are checked before plugin doctor hooks so startup stays cheap. function collectConfiguredChannelIds(raw: unknown): ChannelId[] { if (!raw || typeof raw !== "object") { return []; @@ -25,6 +27,8 @@ function shouldIncludeLegacyRuleForTouchedPaths( if (!touchedPaths || touchedPaths.length === 0) { return true; } + // A rule is relevant when either side is a prefix of the other. This lets a + // changed parent path include child rules without scanning all config rules. return touchedPaths.some((touchedPath) => { const sharedLength = Math.min(rulePath.length, touchedPath.length); for (let index = 0; index < sharedLength; index += 1) { @@ -61,6 +65,8 @@ function collectRelevantChannelIdsForTouchedPaths(params: { if (second === "defaults") { continue; } + // Channel ids are the second segment under channels.*; deeper touched paths + // still map back to the owning channel for rule collection. touchedChannelIds.add(second as ChannelId); } @@ -99,6 +105,8 @@ export function collectChannelLegacyConfigRules( continue; } + // Unknown configured channels may be externally installed plugins. Ask the + // plugin doctor registry only after bundled/bootstrap lookups miss. unresolvedChannelIds.push(channelId); } if (unresolvedChannelIds.length > 0) { diff --git a/src/channels/plugins/lifecycle-startup.ts b/src/channels/plugins/lifecycle-startup.ts index 58355b703ba8..951f85a70ec7 100644 --- a/src/channels/plugins/lifecycle-startup.ts +++ b/src/channels/plugins/lifecycle-startup.ts @@ -1,11 +1,18 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { listChannelPlugins } from "./registry.js"; +/** + * Startup maintenance runner for optional channel plugin lifecycle hooks. + */ + type ChannelStartupLogger = { info?: (message: string) => void; warn?: (message: string) => void; }; +/** + * Runs startup maintenance hooks for all loaded channel plugins. + */ export async function runChannelPluginStartupMaintenance(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -21,6 +28,8 @@ export async function runChannelPluginStartupMaintenance(params: { try { await runStartupMaintenance(params); } catch (err) { + // Startup maintenance is best-effort. One channel failing repair or + // cleanup must not stop the gateway from starting other channel plugins. params.log.warn?.( `${params.logPrefix?.trim() || "gateway"}: ${plugin.id} startup maintenance failed; continuing: ${String(err)}`, ); diff --git a/src/channels/plugins/media-payload.ts b/src/channels/plugins/media-payload.ts index 035e0082143a..9afc4e326164 100644 --- a/src/channels/plugins/media-payload.ts +++ b/src/channels/plugins/media-payload.ts @@ -1,8 +1,14 @@ +/** + * Input media item used by channel outbound payload builders. + */ export type MediaPayloadInput = { path: string; contentType?: string; }; +/** + * Legacy-compatible media payload shape consumed by plugin send helpers. + */ export type MediaPayload = { MediaPath?: string; MediaType?: string; @@ -12,6 +18,9 @@ export type MediaPayload = { MediaTypes?: string[]; }; +/** + * Builds single-item and list media fields for channel outbound helpers. + */ export function buildMediaPayload( mediaList: MediaPayloadInput[], opts?: { preserveMediaTypeCardinality?: boolean }, @@ -19,6 +28,8 @@ export function buildMediaPayload( const first = mediaList[0]; const mediaPaths = mediaList.map((media) => media.path); const rawMediaTypes = mediaList.map((media) => media.contentType ?? ""); + // Some callers need MediaTypes to stay aligned with MediaPaths, including + // blank entries. Others use the compact legacy list of present content types. const mediaTypes = opts?.preserveMediaTypeCardinality ? rawMediaTypes : rawMediaTypes.filter((value): value is string => Boolean(value)); diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 343f6b3d4897..2d73a7ced6e3 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -18,6 +18,9 @@ import type { ChannelMessageToolSchemaContribution, } from "./types.public.js"; +/** + * Input used to discover channel message actions for agent tool schemas. + */ export type ChannelMessageActionDiscoveryInput = { cfg?: OpenClawConfig; channel?: string | null; @@ -43,10 +46,16 @@ type ChannelMessageToolMediaSourceParamKeyInput = ChannelMessageActionDiscoveryP const loggedMessageActionErrors = new Set(); +/** + * Normalizes a raw channel/provider id before consulting action discovery hooks. + */ export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined { return normalizeAnyChannelId(raw) ?? normalizeOptionalString(raw); } +/** + * Builds the context object passed to plugin message-tool discovery hooks. + */ export function createMessageActionDiscoveryContext( params: ChannelMessageActionDiscoveryInput, ): ChannelMessageActionDiscoveryContext { @@ -75,6 +84,8 @@ function logMessageActionError(params: { }) { const message = formatErrorMessage(params.error); const key = `${params.pluginId}:${params.operation}:${message}`; + // Discovery runs while building tool schemas, so log each plugin/error pair + // once and let the agent continue with the remaining channel capabilities. if (loggedMessageActionErrors.has(key)) { return; } @@ -102,6 +113,9 @@ function describeMessageToolSafely(params: { } } +/** + * Normalizes plugin schema contributions into a list for merge callers. + */ function normalizeToolSchemaContributions( value: | ChannelMessageToolSchemaContribution @@ -124,6 +138,9 @@ type ResolvedChannelMessageActionDiscovery = { type MessageToolMediaSourceParamMap = Partial>; +/** + * Resolves media-source parameter names, optionally scoped to one action. + */ function normalizeMessageToolMediaSourceParams( mediaSourceParams: ChannelMessageToolDiscovery["mediaSourceParams"], action?: ChannelMessageActionName, @@ -144,6 +161,9 @@ function normalizeMessageToolMediaSourceParams( ); } +/** + * Finds the lightest available message-tool discovery adapter for one channel. + */ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: string | null): { pluginId: string; actions: ChannelMessageToolDiscoveryAdapter; @@ -159,6 +179,8 @@ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: strin actions: loadedPlugin.actions, }; } + // Prefer the bundled public artifact before full plugin materialization so + // schema construction stays cheap on hot agent/tool paths. const bundledActions = resolveBundledChannelMessageToolDiscoveryAdapter(channelId); if (bundledActions) { return { @@ -176,6 +198,9 @@ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: strin }; } +/** + * Resolves one plugin's message action metadata with caller-selected fields. + */ export function resolveMessageActionDiscoveryForPlugin(params: { pluginId: string; actions?: ChannelMessageToolDiscoveryAdapter; @@ -217,6 +242,9 @@ export function resolveMessageActionDiscoveryForPlugin(params: { }; } +/** + * Lists message actions available across registered channel plugins. + */ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { const actions = new Set(["send", "broadcast"]); for (const plugin of listChannelPlugins()) { @@ -232,6 +260,9 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc return Array.from(actions); } +/** + * Lists actions whose schemas do not block cross-channel tool usage. + */ export function listCrossChannelSchemaSupportedMessageActions( params: ChannelMessageActionDiscoveryParams & { channel?: string; @@ -254,6 +285,8 @@ export function listCrossChannelSchemaSupportedMessageActions( }); const schemaBlockedActions = new Set(); for (const contribution of resolved.schemaContributions) { + // Current-channel-only schema params are not safe for cross-channel tool + // calls unless the plugin explicitly leaves an action without that schema. if ((contribution.visibility ?? "current-channel") !== "current-channel") { continue; } @@ -274,6 +307,9 @@ export function listCrossChannelSchemaSupportedMessageActions( return resolved.actions.filter((action) => !schemaBlockedActions.has(action)); } +/** + * Lists message capabilities advertised across registered channel plugins. + */ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { const capabilities = new Set(); for (const plugin of listChannelPlugins()) { @@ -289,6 +325,9 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess return Array.from(capabilities); } +/** + * Lists message capabilities advertised by the current channel. + */ export function listChannelMessageCapabilitiesForChannel( params: ChannelMessageActionDiscoveryParams, ): ChannelMessageCapability[] { @@ -306,6 +345,9 @@ export function listChannelMessageCapabilitiesForChannel( ); } +/** + * Merges schema properties while preserving the first plugin to define a key. + */ function mergeToolSchemaProperties( target: Record, source: Record | undefined, @@ -320,6 +362,9 @@ function mergeToolSchemaProperties( } } +/** + * Resolves extra message-tool schema properties from channel discovery hooks. + */ export function resolveChannelMessageToolSchemaProperties( params: ChannelMessageActionDiscoveryParams, ): Record { @@ -350,6 +395,8 @@ export function resolveChannelMessageToolSchemaProperties( } } if (currentChannel && !seenPluginIds.has(currentChannel)) { + // The active channel may be bundled but not configured/registered yet; use + // its lightweight discovery artifact so current-channel schemas still work. const currentActions = resolveCurrentChannelMessageToolDiscoveryAdapter(currentChannel); if (currentActions?.actions) { for (const contribution of resolveMessageActionDiscoveryForPlugin({ @@ -369,6 +416,9 @@ export function resolveChannelMessageToolSchemaProperties( return properties; } +/** + * Resolves tool parameter names that should be treated as media source selectors. + */ export function resolveChannelMessageToolMediaSourceParamKeys( params: ChannelMessageToolMediaSourceParamKeyInput, ): string[] { @@ -386,6 +436,9 @@ export function resolveChannelMessageToolMediaSourceParamKeys( return uniqueStrings(described.mediaSourceParams); } +/** + * Returns whether any registered channel advertises a message capability. + */ export function channelSupportsMessageCapability( cfg: OpenClawConfig, capability: ChannelMessageCapability, @@ -393,6 +446,9 @@ export function channelSupportsMessageCapability( return listChannelMessageCapabilities(cfg).includes(capability); } +/** + * Returns whether the current channel advertises a message capability. + */ export function channelSupportsMessageCapabilityForChannel( params: ChannelMessageActionDiscoveryParams, capability: ChannelMessageCapability, diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts index d30b8ab117c0..33c95f1804db 100644 --- a/src/channels/plugins/message-action-dispatch.ts +++ b/src/channels/plugins/message-action-dispatch.ts @@ -2,6 +2,10 @@ import type { AgentToolResult } from "../../agents/runtime/index.js"; import { getChannelPlugin } from "./index.js"; import type { ChannelMessageActionContext } from "./types.public.js"; +/** + * Dispatches plugin-owned message actions from the shared agent tool. + */ + function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { const plugin = getChannelPlugin(ctx.channel); return Boolean( @@ -12,9 +16,14 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole ); } +/** + * Runs a channel message action if the target plugin supports it. + */ export async function dispatchChannelMessageAction( ctx: ChannelMessageActionContext, ): Promise | null> { + // Some plugin actions depend on the sender identity to enforce channel-local + // trust. Reject tool-driven calls before invoking the plugin without it. if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { throw new Error( `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, @@ -24,6 +33,8 @@ export async function dispatchChannelMessageAction( if (!plugin?.actions?.handleAction) { return null; } + // `handleAction` may be broad; `supportsAction` lets plugins cheaply decline + // action names before the dispatcher enters channel-specific behavior. if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { return null; } diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 0c2085bec5d2..db75d416fe14 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -1,3 +1,6 @@ +/** + * Canonical message action names accepted by channel message tool dispatch. + */ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "send", "broadcast", @@ -58,4 +61,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "upload-file", ] as const; +/** + * Message action name union derived from the canonical action list. + */ export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number]; diff --git a/src/channels/plugins/message-capabilities.ts b/src/channels/plugins/message-capabilities.ts index 7b64faff5428..4ef766b14fa9 100644 --- a/src/channels/plugins/message-capabilities.ts +++ b/src/channels/plugins/message-capabilities.ts @@ -1,3 +1,9 @@ +/** + * Channel message capabilities advertised through plugin discovery hooks. + */ export const CHANNEL_MESSAGE_CAPABILITIES = ["presentation", "delivery-pin"] as const; +/** + * Message capability union derived from the canonical capability list. + */ export type ChannelMessageCapability = (typeof CHANNEL_MESSAGE_CAPABILITIES)[number]; diff --git a/src/channels/plugins/message-tool-api.ts b/src/channels/plugins/message-tool-api.ts index 56113fb83305..216e1aaba814 100644 --- a/src/channels/plugins/message-tool-api.ts +++ b/src/channels/plugins/message-tool-api.ts @@ -1,11 +1,17 @@ import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.public.js"; +/** + * Narrow adapter surface used for message-tool schema discovery. + */ export type ChannelMessageToolDiscoveryAdapter = Pick< ChannelMessageActionAdapter, "describeMessageTool" >; +/** + * Lightweight public artifact shape for bundled channel message-tool hooks. + */ type MessageToolApi = { describeMessageTool?: ChannelMessageToolDiscoveryAdapter["describeMessageTool"]; }; @@ -21,6 +27,8 @@ function loadBundledChannelMessageToolApi(channelId: string): MessageToolApi | u artifactBasename: MESSAGE_TOOL_API_ARTIFACT_BASENAME, }); } catch (error) { + // Missing artifacts are optional; present-but-broken artifacts should fail + // so discovery does not silently hide invalid bundled plugin contracts. if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { return undefined; } @@ -28,6 +36,9 @@ function loadBundledChannelMessageToolApi(channelId: string): MessageToolApi | u } } +/** + * Resolves a bundled channel's message-tool discovery adapter without loading the full plugin. + */ export function resolveBundledChannelMessageToolDiscoveryAdapter( channelId: string, ): ChannelMessageToolDiscoveryAdapter | undefined { @@ -38,6 +49,9 @@ export function resolveBundledChannelMessageToolDiscoveryAdapter( return { describeMessageTool }; } +/** + * Runs a bundled channel's message-tool discovery hook through its public artifact. + */ export function describeBundledChannelMessageTool(params: { channelId: string; context: Parameters>[0]; diff --git a/src/channels/plugins/meta-normalization.ts b/src/channels/plugins/meta-normalization.ts index 07cabf75c79e..7785f45a048d 100644 --- a/src/channels/plugins/meta-normalization.ts +++ b/src/channels/plugins/meta-normalization.ts @@ -1,6 +1,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { ChannelMeta } from "./types.public.js"; +// Normalizes partially declared channel metadata while preserving optional +// extension-owned fields from an existing manifest or registry entry. function stripRequiredChannelMeta(meta?: Partial | null) { const { id: _ignoredId, @@ -37,6 +39,8 @@ export function normalizeChannelMeta(params: { const blurb = normalizeOptionalString(next?.blurb) ?? normalizeOptionalString(existing?.blurb) ?? ""; + // Required fields are recomputed from normalized precedence above. Spreading + // only optional leftovers prevents stale ids or labels from winning later. return { ...stripRequiredChannelMeta(existing), ...stripRequiredChannelMeta(next), diff --git a/src/channels/plugins/module-loader.ts b/src/channels/plugins/module-loader.ts index f16a0c2994c4..0a3f5c85a7d4 100644 --- a/src/channels/plugins/module-loader.ts +++ b/src/channels/plugins/module-loader.ts @@ -14,6 +14,9 @@ const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]); const jitiLoaders: PluginModuleLoaderCache = new Map(); let channelPluginModuleLoaderFactoryForTest: PluginModuleLoaderFactory | undefined; +/** + * Installs a test-only module loader factory for source channel plugin modules. + */ export function setChannelPluginModuleLoaderFactoryForTest( factory?: PluginModuleLoaderFactory, ): void { @@ -51,6 +54,8 @@ function loadModuleWithJiti(modulePath: string): unknown { function loadModule(modulePath: string): unknown { if (!isJavaScriptModulePath(modulePath) && !hasNativeSourceRequireHook(modulePath)) { if (isSourceModulePath(modulePath)) { + // Local source plugins need the TS loader unless the current runtime has + // installed a native source require hook for that extension. return loadModuleWithJiti(modulePath); } throw new Error(`channel plugin module must be built JavaScript: ${modulePath}`); @@ -59,6 +64,8 @@ function loadModule(modulePath: string): unknown { return nodeRequire(modulePath); } catch (error) { if (isSourceModulePath(modulePath)) { + // Native source hooks can still fail on ESM/TS edge cases; fall back to + // the cached loader before surfacing the error. return loadModuleWithJiti(modulePath); } throw new Error(`failed to load channel plugin module with native require: ${modulePath}`, { @@ -85,6 +92,9 @@ function resolvePluginModuleCandidates(rootDir: string, specifier: string): stri ]; } +/** + * Resolves a plugin-relative module specifier to an existing candidate path. + */ export function resolveExistingPluginModulePath(rootDir: string, specifier: string): string { for (const candidate of resolvePluginModuleCandidates(rootDir, specifier)) { if (fs.existsSync(candidate)) { @@ -94,6 +104,9 @@ export function resolveExistingPluginModulePath(rootDir: string, specifier: stri return path.resolve(rootDir, specifier); } +/** + * Loads a channel plugin module after enforcing plugin-root file boundaries. + */ export function loadChannelPluginModule(params: { modulePath: string; rootDir: string; @@ -113,6 +126,8 @@ export function loadChannelPluginModule(params: { ); } const safePath = opened.path; + // The boundary check opens the file to verify the path; close before loading + // through require/jiti so module evaluation owns its own descriptor lifecycle. fs.closeSync(opened.fd); return loadModule(safePath); } diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index a2b3851a2123..d39568b6d61c 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -7,6 +7,10 @@ import type { OutboundMediaAccess } from "../../../media/load-options.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.adapters.js"; +/** + * Shared direct text/media outbound adapter factory for SDK-backed channels. + */ + type DirectSendOptions = { cfg: OpenClawConfig; accountId?: string | null; @@ -45,6 +49,9 @@ export { sendTextMediaPayload, } from "openclaw/plugin-sdk/reply-payload"; +/** + * Resolves an account-scoped channel media byte limit. + */ export function resolveScopedChannelMediaMaxBytes(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -57,6 +64,9 @@ export function resolveScopedChannelMediaMaxBytes(params: { }); } +/** + * Builds a media byte-limit resolver for channels with `mediaMaxMb` config. + */ export function createScopedChannelMediaMaxBytesResolver(channel: string) { return (params: { cfg: OpenClawConfig; accountId?: string | null }) => resolveScopedChannelMediaMaxBytes({ @@ -73,6 +83,9 @@ export function createScopedChannelMediaMaxBytesResolver(channel: string) { }); } +/** + * Creates a channel outbound adapter backed by direct text/media send functions. + */ export function createDirectTextMediaOutbound< TOpts extends Record, TResult extends DirectSendResult, @@ -155,6 +168,8 @@ export function createDirectTextMediaOutbound< to, text, mediaUrl, + // Older callers pass local media access as split roots/readFile fields; + // normalize them into the newer mediaAccess object before option building. mediaAccess: mediaAccess ?? (mediaLocalRoots || mediaReadFile diff --git a/src/channels/plugins/outbound/load.types.ts b/src/channels/plugins/outbound/load.types.ts index 9d1655b87de1..4e5eda26851f 100644 --- a/src/channels/plugins/outbound/load.types.ts +++ b/src/channels/plugins/outbound/load.types.ts @@ -1,6 +1,9 @@ import type { ChannelId } from "../channel-id.types.js"; import type { ChannelOutboundAdapter } from "../outbound.types.js"; +/** + * Lazy loader contract for channel outbound adapters. + */ export type LoadChannelOutboundAdapter = ( id: ChannelId, ) => Promise; diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index 3e0e51a9feef..58b8f9b7da86 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -17,6 +17,10 @@ import { } from "../../plugins/plugin-module-loader-cache.js"; import { loadChannelPluginModule, resolveExistingPluginModulePath } from "./module-loader.js"; +/** + * Package-state probes for bundled channel configured/auth state metadata. + */ + type ChannelPackageStateChecker = (params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -31,6 +35,9 @@ type ChannelPackageStateMetadata = { }; }; +/** + * Metadata keys that can declare a lightweight package-state checker. + */ export type ChannelPackageStateMetadataKey = "configuredState" | "persistedAuthState"; const log = createSubsystemLogger("channels"); @@ -52,6 +59,8 @@ function loadChannelPackageStateModule(params: { modulePath: string; rootDir: st if (!isSourceModulePath(params.modulePath)) { throw error; } + // Local source checkers can run through the cached TS loader; built JS + // paths must still load through the boundary-safe module loader above. const loader = getCachedPluginModuleLoader({ cache: sourcePackageStateLoaderCache, modulePath: params.modulePath, @@ -100,6 +109,8 @@ function listBuiltBundledPackageStateModules(params: { specifier: string; }): ChannelPackageStateModuleLocation[] { if (isBundledSourceOverlayPluginRoot(params.rootDir)) { + // Source overlays intentionally shadow built artifacts; probing dist would + // mix old built code with the active source overlay. return []; } const sourceRoot = resolveSourceBundledPluginRoot(params.rootDir); @@ -134,6 +145,8 @@ function listChannelPackageStateModuleLocations(params: { specifier: string; }): ChannelPackageStateModuleLocation[] { const source = resolveChannelPackageStateModuleLocation(params); + // Prefer built bundled artifacts when present so probes match shipped runtime + // behavior, then fall back to source for local development. const built = listBuiltBundledPackageStateModules({ rootDir: params.entry.rootDir, specifier: params.specifier, @@ -155,6 +168,8 @@ function resolveChannelPackageStateMetadata( const allOf = normalizeTrimmedStringList(envMetadata?.allOf); const anyOf = normalizeTrimmedStringList(envMetadata?.anyOf); const env = allOf.length > 0 || anyOf.length > 0 ? { allOf, anyOf } : undefined; + // A checker can be module-backed or env-backed. Ignore empty metadata so + // catalog entries without usable probes do not appear as state-capable. if ((!specifier || !exportName) && !env) { return null; } @@ -188,6 +203,8 @@ function resolveChannelPackageStateChecker(params: { return ({ env }) => { const allOf = metadata.env?.allOf ?? []; const anyOf = metadata.env?.anyOf ?? []; + // `allOf` expresses required credentials; `anyOf` expresses alternatives + // where at least one non-empty value proves package state. return ( allOf.every((key) => hasNonEmptyEnvValue(env, key)) && (anyOf.length === 0 || anyOf.some((key) => hasNonEmptyEnvValue(env, key))) @@ -228,6 +245,9 @@ function resolvePackageStateChannelId(entry: PluginChannelCatalogEntry): string return normalizeOptionalString(entry.channel.id); } +/** + * Lists bundled channel ids that declare the requested package-state metadata. + */ export function listBundledChannelIdsForPackageState( metadataKey: ChannelPackageStateMetadataKey, discovery?: PluginDiscoveryResult, @@ -238,6 +258,9 @@ export function listBundledChannelIdsForPackageState( .toSorted((left, right) => left.localeCompare(right)); } +/** + * Returns whether a bundled channel reports configured/auth package state. + */ export function hasBundledChannelPackageState(params: { metadataKey: ChannelPackageStateMetadataKey; channelId: string; diff --git a/src/channels/plugins/pairing-adapters.ts b/src/channels/plugins/pairing-adapters.ts index 7e925ebee250..2672591f0ab7 100644 --- a/src/channels/plugins/pairing-adapters.ts +++ b/src/channels/plugins/pairing-adapters.ts @@ -1,7 +1,14 @@ import type { ChannelPairingAdapter } from "./types.adapters.js"; +/** + * Shared pairing adapter helpers for channel SDK/runtime facades. + */ + type PairingNotifyParams = Parameters>[0]; +/** + * Creates an allowlist normalizer that strips a channel-specific target prefix. + */ export function createPairingPrefixStripper( prefixRe: RegExp, map: (entry: string) => string = (entry) => entry, @@ -9,6 +16,9 @@ export function createPairingPrefixStripper( return (entry) => map(entry.trim().replace(prefixRe, "").trim()); } +/** + * Creates a pairing notifier that logs a formatted approval message. + */ export function createLoggedPairingApprovalNotifier( format: string | ((params: PairingNotifyParams) => string), log: (message: string) => void = console.log, @@ -18,6 +28,9 @@ export function createLoggedPairingApprovalNotifier( }; } +/** + * Creates a text-message pairing adapter with optional allowlist normalization. + */ export function createTextPairingAdapter(params: { idLabel: string; message: string; diff --git a/src/channels/plugins/pairing-message.ts b/src/channels/plugins/pairing-message.ts index 9cc81456a517..aaccd02eae43 100644 --- a/src/channels/plugins/pairing-message.ts +++ b/src/channels/plugins/pairing-message.ts @@ -1,2 +1,5 @@ +/** + * Default approval message sent after channel pairing succeeds. + */ export const PAIRING_APPROVED_MESSAGE = "✅ OpenClaw access approved. Send a message to start chatting."; diff --git a/src/channels/plugins/pairing.types.ts b/src/channels/plugins/pairing.types.ts index 54b5c1c15e9f..238537688917 100644 --- a/src/channels/plugins/pairing.types.ts +++ b/src/channels/plugins/pairing.types.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RuntimeEnv } from "../../runtime.js"; +/** + * Channel pairing hooks used by setup and allowlist approval flows. + */ export type ChannelPairingAdapter = { idLabel: string; normalizeAllowEntry?: (entry: string) => string; diff --git a/src/channels/plugins/persisted-auth-state.ts b/src/channels/plugins/persisted-auth-state.ts index 69b98ac02940..91c2b43717af 100644 --- a/src/channels/plugins/persisted-auth-state.ts +++ b/src/channels/plugins/persisted-auth-state.ts @@ -5,12 +5,22 @@ import { listBundledChannelIdsForPackageState, } from "./package-state-probes.js"; +/** + * Persisted-auth state probes backed by bundled channel package metadata. + */ + +/** + * Lists bundled channels that declare persisted-auth state metadata. + */ export function listBundledChannelIdsWithPersistedAuthState( discovery?: PluginDiscoveryResult, ): string[] { return listBundledChannelIdsForPackageState("persistedAuthState", discovery); } +/** + * Returns whether a bundled channel reports persisted auth state. + */ export function hasBundledChannelPersistedAuthState(params: { channelId: string; cfg: OpenClawConfig; diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts index e268ccaa6ad9..4a7c92964790 100644 --- a/src/channels/plugins/read-only-command-defaults.ts +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -6,8 +6,15 @@ import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import type { ChannelPlugin } from "./types.plugin.js"; +/** + * Read-only command default resolution from installed plugin manifests. + */ + const SAFE_MANIFEST_CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +/** + * Native command/skill auto-enable defaults exposed by channel manifests. + */ export type ChannelCommandDefaults = Pick< NonNullable, "nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled" @@ -15,10 +22,16 @@ export type ChannelCommandDefaults = Pick< type ManifestChannelConfigRecord = NonNullable[string]; +/** + * Returns whether a manifest channel id is safe for own-property lookup. + */ export function isSafeManifestChannelId(channelId: string): boolean { return SAFE_MANIFEST_CHANNEL_ID_PATTERN.test(channelId) && !isBlockedObjectKey(channelId); } +/** + * Reads an own record property while blocking prototype-polluting keys. + */ export function readOwnRecordValue(record: Record, key: string): unknown { if (isBlockedObjectKey(key) || !Object.hasOwn(record, key)) { return undefined; @@ -26,6 +39,9 @@ export function readOwnRecordValue(record: Record, key: string) return record[key]; } +/** + * Normalizes manifest command defaults down to supported boolean fields. + */ export function normalizeChannelCommandDefaults( value: ChannelCommandDefaults | undefined, ): ChannelCommandDefaults | undefined { @@ -51,6 +67,9 @@ export function normalizeChannelCommandDefaults( return defaults; } +/** + * Resolves command defaults from enabled installed plugin metadata without loading plugins. + */ export function resolveReadOnlyChannelCommandDefaults( channelId: string, options: { @@ -79,6 +98,8 @@ export function resolveReadOnlyChannelCommandDefaults( if (!isInstalledPluginEnabled(resolvedSnapshot.index, record.id, options.config)) { continue; } + // Manifest channelConfigs are untrusted object data, so read the channel key + // through the guarded helper instead of indexing directly. const channelConfigValue = record.channelConfigs ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) : undefined; diff --git a/src/channels/plugins/registry-loaded-read.ts b/src/channels/plugins/registry-loaded-read.ts index c03970f98b82..19064c5cf55c 100644 --- a/src/channels/plugins/registry-loaded-read.ts +++ b/src/channels/plugins/registry-loaded-read.ts @@ -4,6 +4,10 @@ import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-c import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; +/** + * Minimal loaded-plugin reader for hot outbound/read paths. + */ + function coerceLoadedChannelPlugin( plugin: ActiveChannelPluginRuntimeShape | null | undefined, ): ChannelPlugin | undefined { @@ -12,11 +16,16 @@ function coerceLoadedChannelPlugin( return undefined; } if (!plugin.meta || typeof plugin.meta !== "object") { + // Normalize optional metadata for callers that inspect labels/capabilities + // without requiring a full registry view materialization. plugin.meta = {}; } return plugin as ChannelPlugin; } +/** + * Reads one loaded channel plugin directly from active runtime state. + */ export function getLoadedChannelPluginForRead(id: ChannelId): ChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { diff --git a/src/channels/plugins/registry-loaded.ts b/src/channels/plugins/registry-loaded.ts index b4fc3f6217c1..385225eae85b 100644 --- a/src/channels/plugins/registry-loaded.ts +++ b/src/channels/plugins/registry-loaded.ts @@ -6,11 +6,21 @@ import type { import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js"; import { CHAT_CHANNEL_ORDER } from "../registry.js"; +/** + * Loaded channel plugin registry view derived from active plugin runtime state. + */ + +/** + * Loaded channel plugin shape after id/meta normalization. + */ export type LoadedChannelPlugin = ActiveChannelPluginRuntimeShape & { id: string; meta: NonNullable; }; +/** + * Loaded channel registry entry with a normalized plugin payload. + */ export type LoadedChannelPluginEntry = ActivePluginChannelRegistration & { plugin: LoadedChannelPlugin; }; @@ -29,6 +39,8 @@ function coerceLoadedChannelPlugin( return null; } if (!plugin.meta || typeof plugin.meta !== "object") { + // Loaded plugin metadata is optional at the runtime-state boundary, but + // channel sorting expects an object so normalize it once at read time. plugin.meta = {}; } return plugin as LoadedChannelPlugin; @@ -66,6 +78,8 @@ function resolveChannelPlugins(): ChannelPluginView { const sorted = dedupeChannels(channelPlugins).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id); + // Explicit plugin order wins; known built-ins keep their product order; + // unknown extension channels sort after them by id for deterministic lists. const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB); if (orderA !== orderB) { @@ -91,10 +105,16 @@ function resolveChannelPlugins(): ChannelPluginView { }; } +/** + * Lists loaded channel plugins in deterministic display/runtime order. + */ export function listLoadedChannelPlugins(): LoadedChannelPlugin[] { return resolveChannelPlugins().sorted.slice(); } +/** + * Returns a loaded channel plugin by normalized id. + */ export function getLoadedChannelPluginById(id: string): LoadedChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { @@ -103,6 +123,9 @@ export function getLoadedChannelPluginById(id: string): LoadedChannelPlugin | un return resolveChannelPlugins().byId.get(resolvedId); } +/** + * Returns the loaded channel registry entry by normalized plugin id. + */ export function getLoadedChannelPluginEntryById(id: string): LoadedChannelPluginEntry | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index 7dec6ab5ff41..1cb5d6dd03ae 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -2,10 +2,17 @@ import type { PluginChannelRegistration } from "../../plugins/registry-types.js" import { getActivePluginChannelRegistry, getActivePluginRegistry } from "../../plugins/runtime.js"; import type { ChannelId } from "./channel-id.types.js"; +/** + * Generic channel registry loader for lazily resolved plugin sub-surfaces. + */ + type ChannelRegistryValueResolver = ( entry: PluginChannelRegistration, ) => TValue | undefined; +/** + * Creates a lazy loader that resolves one value from the active channel registry. + */ export function createChannelRegistryLoader( resolveValue: ChannelRegistryValueResolver, ): (id: ChannelId) => Promise { @@ -25,6 +32,8 @@ export function createChannelRegistryLoader( const activeRegistry = getActivePluginRegistry(); if (activeRegistry && activeRegistry !== channelRegistry) { + // During startup some callers see a narrower channel registry first. + // Fall back to the full active registry when it is a distinct object. return resolveFromRegistry(activeRegistry); } diff --git a/src/channels/plugins/registry.ts b/src/channels/plugins/registry.ts index 7cbfb816997b..11b5c3ee88c9 100644 --- a/src/channels/plugins/registry.ts +++ b/src/channels/plugins/registry.ts @@ -9,10 +9,20 @@ import { import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; +/** + * Runtime channel plugin registry facade used by core command/gateway paths. + */ + +/** + * Lists currently loaded channel plugins in registry order. + */ export function listChannelPlugins(): ChannelPlugin[] { return listLoadedChannelPlugins() as ChannelPlugin[]; } +/** + * Returns a loaded channel plugin without falling back to bundled metadata. + */ export function getLoadedChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { @@ -21,6 +31,9 @@ export function getLoadedChannelPlugin(id: ChannelId): ChannelPlugin | undefined return getLoadedChannelPluginById(resolvedId) as ChannelPlugin | undefined; } +/** + * Returns the package/install origin for a loaded channel plugin. + */ export function getLoadedChannelPluginOrigin(id: ChannelId): string | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { @@ -29,14 +42,22 @@ export function getLoadedChannelPluginOrigin(id: ChannelId): string | undefined return normalizeOptionalString(getLoadedChannelPluginEntryById(resolvedId)?.origin) ?? undefined; } +/** + * Returns the active channel plugin, with bundled fallback for built-in channels. + */ export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { return undefined; } + // Loaded plugins win over bundled fallbacks so installed plugin state can pin + // or override a bundled channel during runtime. return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId); } +/** + * Normalizes user-facing channel aliases to canonical channel ids. + */ export function normalizeChannelId(raw?: string | null): ChannelId | null { return normalizeAnyChannelId(raw); } diff --git a/src/channels/plugins/runtime-forwarders.ts b/src/channels/plugins/runtime-forwarders.ts index 5bb96c8571b6..fa8a085e3c22 100644 --- a/src/channels/plugins/runtime-forwarders.ts +++ b/src/channels/plugins/runtime-forwarders.ts @@ -1,5 +1,9 @@ import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.adapters.js"; +/** + * Runtime delegate factories for plugin adapters that load heavy runtimes lazily. + */ + type MaybePromise = T | Promise; type DirectoryMethod = "self" | "listPeersLive" | "listGroupsLive" | "listGroupMembers"; @@ -28,9 +32,14 @@ async function resolveForwardedMethod(params: { if (method) { return method; } + // Fail at call time instead of registration time so optional runtime methods + // can stay absent until the caller actually invokes that capability. throw new Error(params.unavailableMessage ?? "Runtime method is unavailable"); } +/** + * Creates a directory adapter whose methods forward to a lazily resolved runtime. + */ export function createRuntimeDirectoryLiveAdapter(params: { getRuntime: () => MaybePromise; self?: (runtime: Runtime) => ChannelDirectoryAdapter["self"] | null | undefined; @@ -82,6 +91,9 @@ export function createRuntimeDirectoryLiveAdapter(params: { return adapter; } +/** + * Creates outbound delegates whose methods forward to a lazily resolved runtime. + */ export function createRuntimeOutboundDelegates(params: { getRuntime: () => MaybePromise; renderPresentation?: { diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts index e5303dca659d..8fba4ff4c797 100644 --- a/src/channels/plugins/session-conversation.ts +++ b/src/channels/plugins/session-conversation.ts @@ -14,6 +14,13 @@ import { import { normalizeChannelId as normalizeChatChannelId } from "../registry.js"; import { getLoadedChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js"; +/** + * Session-key conversation resolution helpers for threaded channel targets. + */ + +/** + * Normalized conversation id details for one channel raw id. + */ export type ResolvedSessionConversation = { id: string; threadId: string | undefined; @@ -21,6 +28,9 @@ export type ResolvedSessionConversation = { parentConversationCandidates: string[]; }; +/** + * Parsed session-key conversation reference with parent/thread metadata. + */ export type ResolvedSessionConversationRef = { channel: string; kind: "group" | "channel"; @@ -88,6 +98,8 @@ function buildGenericConversationResolution(rawId: string): ResolvedSessionConve } const parsed = parseThreadSessionSuffix(trimmed); + // Generic parsing treats `:thread:*` suffixes as child thread metadata while + // preserving the base conversation id for parent lookups. const id = (parsed.baseSessionKey ?? trimmed).trim(); if (!id) { return null; @@ -113,6 +125,8 @@ function normalizeSessionConversationResolution( return { id: resolved.id.trim(), threadId: normalizeOptionalString(resolved.threadId), + // When plugins omit an explicit base id, prefer the last declared parent + // candidate so nested topic/thread routes still collapse to their parent. baseConversationId: normalizeOptionalString(resolved.baseConversationId) ?? dedupeConversationIds(resolved.parentConversationCandidates ?? []).at(-1) ?? @@ -143,6 +157,8 @@ function resolveBundledSessionConversationFallback(params: { artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, }); } catch { + // Missing or inactive bundled artifacts are optional; callers still have + // plugin hooks and generic `:thread:` parsing as fallbacks. return null; } const resolveSessionConversationLocal = loaded?.resolveSessionConversation; @@ -196,6 +212,8 @@ function resolveSessionConversationResolution(params: { params.bundledFallback !== false && !messaging && shouldProbeBundledSessionConversationFallback(rawId); + // Prefer loaded plugin messaging hooks. Bundled public artifacts are only a + // lightweight fallback before registry bootstrap; generic parsing is last. const resolved = pluginResolved ?? (shouldTryBundledFallback @@ -228,6 +246,9 @@ function resolveSessionConversationResolution(params: { }; } +/** + * Resolves one raw channel conversation id into base/thread conversation metadata. + */ export function resolveSessionConversation(params: { channel: string; kind: "group" | "channel"; @@ -270,6 +291,9 @@ export function resolveSessionConversationRef( }; } +/** + * Resolves thread suffix metadata from a session key, using channel hooks when available. + */ export function resolveSessionThreadInfo( sessionKey: string | undefined | null, opts: SessionConversationResolutionOptions = {}, @@ -287,6 +311,9 @@ export function resolveSessionThreadInfo( }; } +/** + * Resolves the parent session key for a threaded child session. + */ export function resolveSessionParentSessionKey( sessionKey: string | undefined | null, ): string | null { diff --git a/src/channels/plugins/session-thread-info-loaded.ts b/src/channels/plugins/session-thread-info-loaded.ts index 2ec8e1a44f25..e002d5203b01 100644 --- a/src/channels/plugins/session-thread-info-loaded.ts +++ b/src/channels/plugins/session-thread-info-loaded.ts @@ -6,6 +6,10 @@ import { } from "../../sessions/session-key-utils.js"; import { getLoadedChannelPluginForRead } from "./registry-loaded-read.js"; +/** + * Hot-path thread info resolver that consults only already loaded channel plugins. + */ + type SessionConversationHookResult = { id: string; threadId?: string | null; @@ -30,6 +34,8 @@ function resolveLoadedSessionConversationThreadInfo( if (!resolved?.id?.trim()) { return null; } + // Loaded-plugin read paths avoid bundled fallback/materialization; if the + // channel hook has no thread id, preserve the original session key. const id = resolved.id.trim(); const threadId = normalizeOptionalString(resolved.threadId); return { @@ -38,6 +44,9 @@ function resolveLoadedSessionConversationThreadInfo( }; } +/** + * Resolves thread suffix metadata using loaded plugin hooks or generic parsing. + */ export function resolveLoadedSessionThreadInfo( sessionKey: string | undefined | null, ): ParsedThreadSessionSuffix { diff --git a/src/channels/plugins/setup-group-access-configure.ts b/src/channels/plugins/setup-group-access-configure.ts index ca0041ec4cfe..8f319aa34510 100644 --- a/src/channels/plugins/setup-group-access-configure.ts +++ b/src/channels/plugins/setup-group-access-configure.ts @@ -2,6 +2,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./setup-group-access.js"; +/** + * Applies prompted group access config through channel-specific policy/allowlist hooks. + */ export async function configureChannelAccessWithAllowlist(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -29,6 +32,8 @@ export async function configureChannelAccessWithAllowlist(params: { return next; } if (accessConfig.policy !== "allowlist") { + // Non-allowlist policies intentionally bypass resolver hooks so stale + // allowlist entries are not re-applied after choosing open/disabled. return params.setPolicy(next, accessConfig.policy); } if (params.skipAllowlistEntries || !params.resolveAllowlist || !params.applyAllowlist) { diff --git a/src/channels/plugins/setup-group-access.ts b/src/channels/plugins/setup-group-access.ts index 6982087abc62..b00b9d63f338 100644 --- a/src/channels/plugins/setup-group-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,16 +1,32 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { WizardPrompter } from "../../wizard/prompts.js"; +/** + * Shared setup prompts for channel group access allowlists. + */ + +/** + * Group access policy selected during channel setup. + */ export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; +/** + * Parses comma, semicolon, or newline separated allowlist entries. + */ export function parseAllowlistEntries(raw: string): string[] { return normalizeStringEntries(raw.split(/[\n,;]+/g)); } +/** + * Formats allowlist entries for setup prompt initial values. + */ export function formatAllowlistEntries(entries: string[]): string { return normalizeStringEntries(entries).join(", "); } +/** + * Prompts for the group access policy allowed by the channel setup flow. + */ export async function promptChannelAccessPolicy(params: { prompter: WizardPrompter; label: string; @@ -35,6 +51,9 @@ export async function promptChannelAccessPolicy(params: { }); } +/** + * Prompts for group allowlist entries and normalizes the response. + */ export async function promptChannelAllowlist(params: { prompter: WizardPrompter; label: string; @@ -53,6 +72,9 @@ export async function promptChannelAllowlist(params: { return parseAllowlistEntries(raw); } +/** + * Prompts for the full group access config, including allowlist entries when needed. + */ export async function promptChannelAccessConfig(params: { prompter: WizardPrompter; label: string; @@ -84,6 +106,8 @@ export async function promptChannelAccessConfig(params: { allowDisabled: params.allowDisabled, }); if (policy !== "allowlist") { + // Open/disabled policies do not carry allowlist entries, so clear entries + // at the prompt boundary before callers write config. return { policy, entries: [] }; } if (params.skipAllowlistEntries) { diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 937d58b466d7..84382eb82f7b 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -110,6 +110,7 @@ export function applyAccountNameToChannelSection(params: { } as OpenClawConfig; } +/** Moves a root-level channel name into `accounts.default` before adding named accounts. */ export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; @@ -144,6 +145,7 @@ export function migrateBaseNameToDefaultAccount(params: { } as OpenClawConfig; } +/** Applies setup-time account naming and optional root-name migration in one step. */ export function prepareScopedSetupConfig(params: { cfg: OpenClawConfig; channelKey: string; @@ -169,6 +171,7 @@ export function prepareScopedSetupConfig(params: { }); } +/** Applies a setup patch using account-scoped config semantics. */ export function applySetupAccountConfigPatch(params: { cfg: OpenClawConfig; channelKey: string; @@ -183,6 +186,7 @@ export function applySetupAccountConfigPatch(params: { }); } +/** Creates a setup adapter that turns validated setup input into an account config patch. */ export function createPatchedAccountSetupAdapter(params: { channelKey: string; alwaysUseAccounts?: boolean; @@ -226,6 +230,7 @@ export function createPatchedAccountSetupAdapter(params: { }; } +/** Creates a Zod-backed setup input validator with an optional typed semantic check. */ export function createZodSetupInputValidator(params: { schema: ZodType; validate?: (params: { cfg: OpenClawConfig; accountId: string; input: T }) => string | null; @@ -295,6 +300,7 @@ export function createSetupInputPresenceValidator(params: { }); } +/** Creates a setup adapter that supports env-backed default account auth and patched credentials. */ export function createEnvPatchedAccountSetupAdapter(params: { channelKey: string; alwaysUseAccounts?: boolean; @@ -324,6 +330,7 @@ export function createEnvPatchedAccountSetupAdapter(params: { }); } +/** Patches channel config at root for default accounts or under `accounts.` for named accounts. */ export function patchScopedAccountConfig(params: { cfg: OpenClawConfig; channelKey: string; @@ -348,6 +355,7 @@ export function patchScopedAccountConfig(params: { const patch = params.patch; const accountPatch = params.accountPatch ?? patch; if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) { + // Default accounts historically live at channel root unless the channel opts into accounts.default. return { ...params.cfg, channels: { @@ -363,6 +371,7 @@ export function patchScopedAccountConfig(params: { const accounts = base?.accounts ?? {}; const existingAccount = accounts[accountId] ?? {}; + // Preserve an explicit disabled account while enabling newly created accounts by default. return { ...params.cfg, channels: { @@ -475,9 +484,9 @@ function resolveSingleAccountPromotionTarget(params: { channel: ChannelSectionBa return namedAccounts.length === 1 ? namedAccounts[0] : DEFAULT_ACCOUNT_ID; } -// When promoting a single-account channel config to multi-account, -// move top-level account settings into accounts.default so the original -// account keeps working without duplicate account values at channel root. +/** + * Promotes legacy single-account channel fields into the account map for multi-account setup. + */ export function moveSingleAccountChannelSectionToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; @@ -503,6 +512,7 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const targetAccountId = resolveSingleAccountPromotionTarget({ channel: base, }); + // Reuse the existing account key spelling so configs like `accounts.Ops` keep their shape. const resolvedTargetAccountKey = resolveExistingAccountKey(accounts, targetAccountId); return moveSingleAccountKeysIntoAccount({ cfg: params.cfg, diff --git a/src/channels/plugins/setup-promotion-helpers.ts b/src/channels/plugins/setup-promotion-helpers.ts index d87772a5f08c..5ce7f3331ae4 100644 --- a/src/channels/plugins/setup-promotion-helpers.ts +++ b/src/channels/plugins/setup-promotion-helpers.ts @@ -7,6 +7,10 @@ import { isCommonSingleAccountPromotionKey, } from "./setup-promotion-keys.js"; +/** + * Helpers for promoting legacy single-account channel config into `accounts`. + */ + type ChannelSectionBase = { defaultAccount?: string; accounts?: Record>; @@ -39,10 +43,15 @@ function getBundledChannelSetupPromotionSurface( return asPromotionSurface(getBundledChannelPlugin(channelKey)?.setup); } +/** + * Returns whether one root-level channel key should move into account config. + */ export function shouldMoveSingleAccountChannelKey(params: { channelKey: string; key: string; }): boolean { + // Common keys move for every channel; channel-owned setup surfaces can add + // plugin-specific keys without teaching core about that channel's schema. if (isCommonSingleAccountPromotionKey(params.key)) { return true; } @@ -61,6 +70,9 @@ export function shouldMoveSingleAccountChannelKey(params: { return false; } +/** + * Resolves all root-level keys eligible for single-account promotion. + */ export function resolveSingleAccountKeysToMove(params: { channelKey: string; channel: Record; @@ -94,6 +106,8 @@ export function resolveSingleAccountKeysToMove(params: { return keysToMove; } + // Once named accounts exist, only keys explicitly allowed for named-account + // promotion should move. This avoids flattening root-only channel settings. const namedAccountPromotionKeys = resolveLoadedSetupSurface()?.namedAccountPromotionKeys ?? resolveBundledSetupSurface()?.namedAccountPromotionKeys; @@ -103,6 +117,9 @@ export function resolveSingleAccountKeysToMove(params: { return keysToMove.filter((key) => namedAccountPromotionKeys.includes(key)); } +/** + * Resolves the account id that should receive promoted single-account config. + */ export function resolveSingleAccountPromotionTarget(params: { channelKey: string; channel: ChannelSectionBase; @@ -116,6 +133,8 @@ export function resolveSingleAccountPromotionTarget(params: { return matchedAccountId ?? normalizedTargetAccountId; }; const loadedSurface = getLoadedChannelSetupPromotionSurface(params.channelKey); + // Prefer loaded plugin setup hooks. Only consult bundled setup metadata when + // no loaded plugin supplied a target resolver for this channel. const bundledSurface = loadedSurface?.resolveSingleAccountPromotionTarget ? undefined : getBundledChannelSetupPromotionSurface(params.channelKey); diff --git a/src/channels/plugins/setup-promotion-keys.ts b/src/channels/plugins/setup-promotion-keys.ts index 6bb354c60a16..a16e670f0615 100644 --- a/src/channels/plugins/setup-promotion-keys.ts +++ b/src/channels/plugins/setup-promotion-keys.ts @@ -1,3 +1,6 @@ +/** + * Common root-level channel config keys safe to promote into a single account. + */ const COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS = [ "name", "token", @@ -31,6 +34,9 @@ const COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS = [ "defaultTo", ] as const; +/** + * Setup-only config keys that can move during single-account migration. + */ const SETUP_SINGLE_ACCOUNT_PROMOTION_KEYS = [ ...COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS, "streaming", @@ -64,14 +70,23 @@ const SETUP_SINGLE_ACCOUNT_PROMOTION_KEYS = [ const commonSingleAccountPromotionKeys = new Set(COMMON_SINGLE_ACCOUNT_PROMOTION_KEYS); const setupSingleAccountPromotionKeys = new Set(SETUP_SINGLE_ACCOUNT_PROMOTION_KEYS); +/** + * Returns whether a config key is part of the channel-agnostic promotion set. + */ export function isCommonSingleAccountPromotionKey(key: string): boolean { return commonSingleAccountPromotionKeys.has(key); } +/** + * Returns whether a config key can be promoted by setup migration flows. + */ export function isSetupSingleAccountPromotionKey(key: string): boolean { return setupSingleAccountPromotionKeys.has(key); } +/** + * Lists root-level channel keys that could be promoted into account config. + */ export function collectSingleAccountPromotionEntries(channel: Record): { entries: string[]; hasNamedAccounts: boolean; diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index 3be3ce4c9745..bce3eba9d464 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -8,6 +8,10 @@ import { listBundledChannelSetupPlugins } from "./bundled.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; +/** + * Setup plugin registry facade for channel onboarding flows. + */ + type ChannelSetupPluginView = { sorted: ChannelPlugin[]; byId: Map; @@ -31,6 +35,8 @@ function sortChannelSetupPlugins(plugins: readonly ChannelPlugin[]): ChannelPlug return dedupeSetupPlugins(plugins).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); + // Keep setup screens in explicit plugin order, then known built-in order, + // then stable extension id order. const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB); if (orderA !== orderB) { @@ -44,6 +50,8 @@ function resolveChannelSetupPlugins(): ChannelSetupPluginView { const registry = requireActivePluginRegistry(); const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); + // Before the registry has setup plugins, bundled setup plugins provide the + // onboarding catalog so first-run setup can still render. const sorted = sortChannelSetupPlugins( registryPlugins.length > 0 ? registryPlugins : listBundledChannelSetupPlugins(), ); @@ -58,15 +66,24 @@ function resolveChannelSetupPlugins(): ChannelSetupPluginView { }; } +/** + * Lists setup-capable channel plugins, falling back to bundled setup metadata. + */ export function listChannelSetupPlugins(): ChannelPlugin[] { return resolveChannelSetupPlugins().sorted.slice(); } +/** + * Lists setup plugins from the active channel registry only. + */ export function listActiveChannelSetupPlugins(): ChannelPlugin[] { const registry = getActivePluginChannelRegistry(); return sortChannelSetupPlugins((registry?.channelSetups ?? []).map((entry) => entry.plugin)); } +/** + * Returns one setup-capable channel plugin by id. + */ export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { diff --git a/src/channels/plugins/setup-wizard-binary.ts b/src/channels/plugins/setup-wizard-binary.ts index a64935414460..d14ff6cc2b2c 100644 --- a/src/channels/plugins/setup-wizard-binary.ts +++ b/src/channels/plugins/setup-wizard-binary.ts @@ -9,6 +9,9 @@ import type { type SetupTextInputParams = Parameters>[0]; type SetupStatusParams = Parameters>[0]; +/** + * Creates setup status resolvers for channels backed by a required local binary. + */ export function createDetectedBinaryStatus(params: { channelLabel: string; binaryLabel: string; @@ -38,6 +41,8 @@ export function createDetectedBinaryStatus(params: { async resolveStatusLines({ cfg, accountId, configured }: SetupStatusParams): Promise { const binaryPath = params.resolveBinaryPath({ cfg, accountId }); const detected = await detectBinary(binaryPath); + // Report config state and binary detection separately; users can be + // configured but still missing the CLI binary required for runtime use. return [ `${params.channelLabel}: ${configured ? params.configuredLabel : params.unconfiguredLabel}`, `${params.binaryLabel}: ${detected ? "found" : "missing"} (${binaryPath})`, @@ -70,6 +75,9 @@ export function createDetectedBinaryStatus(params: { }; } +/** + * Creates a setup text input that records or reuses a CLI path. + */ export function createCliPathTextInput(params: { inputKey: ChannelSetupWizardTextInput["inputKey"]; message: string; @@ -91,6 +99,9 @@ export function createCliPathTextInput(params: { }; } +/** + * Creates delegated status resolvers backed by a lazily loaded setup wizard. + */ export function createDelegatedSetupWizardStatusResolvers( loadWizard: () => Promise, ): Pick< @@ -110,6 +121,9 @@ export function createDelegatedSetupWizardStatusResolvers( }; } +/** + * Delegates a text input's `shouldPrompt` check to a lazily loaded setup wizard. + */ export function createDelegatedTextInputShouldPrompt(params: { loadWizard: () => Promise; inputKey: ChannelSetupWizardTextInput["inputKey"]; diff --git a/src/channels/plugins/setup-wizard-proxy.ts b/src/channels/plugins/setup-wizard-proxy.ts index 25616181e6cb..08dc5b9de568 100644 --- a/src/channels/plugins/setup-wizard-proxy.ts +++ b/src/channels/plugins/setup-wizard-proxy.ts @@ -15,16 +15,25 @@ type ResolveGroupAllowlistParams = Parameters< NonNullable["resolveAllowlist"]> >[0]; +/** + * Delegates setup configured-state checks to a lazily loaded wizard. + */ export function createDelegatedResolveConfigured(loadWizard: () => Promise) { return async ({ cfg, accountId }: ResolveConfiguredParams) => await (await loadWizard()).status.resolveConfigured({ cfg, accountId }); } +/** + * Delegates setup preparation to a lazily loaded wizard. + */ export function createDelegatedPrepare(loadWizard: () => Promise) { return async (params: Parameters>[0]) => await (await loadWizard()).prepare?.(params); } +/** + * Delegates setup finalization to a lazily loaded wizard. + */ export function createDelegatedFinalize(loadWizard: () => Promise) { return async (params: Parameters>[0]) => await (await loadWizard()).finalize?.(params); @@ -35,6 +44,9 @@ type DelegatedStatusBase = Omit< "resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore" >; +/** + * Creates a setup wizard facade with selected hooks delegated to a lazy wizard. + */ export function createDelegatedSetupWizardProxy(params: { channel: string; loadWizard: () => Promise; @@ -56,6 +68,8 @@ export function createDelegatedSetupWizardProxy(params: { resolveConfigured: createDelegatedResolveConfigured(params.loadWizard), ...createDelegatedSetupWizardStatusResolvers(params.loadWizard), }, + // Keep static setup metadata available immediately, while expensive + // prepare/finalize/status behavior loads only when the wizard needs it. ...(params.resolveShouldPromptAccountIds ? { resolveShouldPromptAccountIds: params.resolveShouldPromptAccountIds } : {}), @@ -70,6 +84,9 @@ export function createDelegatedSetupWizardProxy(params: { } satisfies ChannelSetupWizard; } +/** + * Creates a setup wizard proxy that delegates allowlist resolution when available. + */ export function createAllowlistSetupWizardProxy(params: { loadWizard: () => Promise; createBase: (handlers: { @@ -92,6 +109,8 @@ export function createAllowlistSetupWizardProxy(params: { resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { const wizard = await params.loadWizard(); if (!wizard.allowFrom) { + // A base wizard may expose allowlist UI before the delegated wizard has + // resolver support. Preserve raw entries as unresolved instead of failing. return entries.map((input) => ({ input, resolved: false, id: null })); } return await wizard.allowFrom.resolveEntries({ @@ -104,6 +123,8 @@ export function createAllowlistSetupWizardProxy(params: { resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { const wizard = await params.loadWizard(); if (!wizard.groupAccess?.resolveAllowlist) { + // Group allowlists are channel-specific; callers provide the safe + // fallback representation when the delegated wizard has no resolver. return params.fallbackResolvedGroupAllowlist(entries); } return (await wizard.groupAccess.resolveAllowlist({ diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index ab8b00dcebad..22328f1b1bba 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -11,6 +11,9 @@ import type { ChannelSetupInput, } from "./types.core.js"; +// Public setup wizard contract shared by bundled channel plugins and setup +// orchestration. Keep these types declarative; runtime behavior lives in +// setup-wizard.ts. export type ChannelSetupPlugin = { id: ChannelId; meta: ChannelMeta; @@ -20,6 +23,7 @@ export type ChannelSetupPlugin = { setupWizard?: ChannelSetupWizard | ChannelSetupWizardAdapter; }; +/** Status block shown before users select channels during setup. */ export type ChannelSetupWizardStatus = { configuredLabel: string; unconfiguredLabel: string; @@ -48,6 +52,7 @@ export type ChannelSetupWizardStatus = { }) => number | undefined | Promise; }; +/** Snapshot of one credential before prompting or reusing existing config. */ export type ChannelSetupWizardCredentialState = { accountConfigured: boolean; hasConfiguredValue: boolean; @@ -57,6 +62,7 @@ export type ChannelSetupWizardCredentialState = { export type ChannelSetupWizardCredentialValues = Partial>; +/** Optional explanatory note shown when its owning step is reached. */ export type ChannelSetupWizardNote = { title: string; lines: string[]; @@ -67,6 +73,7 @@ export type ChannelSetupWizardNote = { }) => boolean | Promise; }; +/** Lets a wizard configure an account entirely from existing environment. */ export type ChannelSetupWizardEnvShortcut = { prompt: string; preferredEnvVar?: string; @@ -77,6 +84,7 @@ export type ChannelSetupWizardEnvShortcut = { }) => OpenClawConfig | Promise; }; +/** Declarative secret/input step for a channel account credential. */ export type ChannelSetupWizardCredential = { inputKey: keyof ChannelSetupInput; providerHint: string; @@ -112,6 +120,7 @@ export type ChannelSetupWizardCredential = { }) => OpenClawConfig | Promise; }; +/** Declarative non-secret text step that can depend on resolved credentials. */ export type ChannelSetupWizardTextInput = { inputKey: keyof ChannelSetupInput; message: string; @@ -164,6 +173,7 @@ export type ChannelSetupWizardAllowFromEntry = { id: string | null; }; +/** Channel-specific resolver for user-entered allowlist targets. */ export type ChannelSetupWizardAllowFrom = { helpTitle?: string; helpLines?: string[]; @@ -186,6 +196,7 @@ export type ChannelSetupWizardAllowFrom = { }) => OpenClawConfig | Promise; }; +/** Declarative group/DM access policy step used by interactive setup. */ export type ChannelSetupWizardGroupAccess = { label: string; placeholder: string; @@ -214,6 +225,7 @@ export type ChannelSetupWizardGroupAccess = { }) => OpenClawConfig; }; +/** Optional pre-step hook for deriving helper config or credential values. */ export type ChannelSetupWizardPrepare = (params: { cfg: OpenClawConfig; accountId: string; @@ -232,6 +244,7 @@ export type ChannelSetupWizardPrepare = (params: { credentialValues?: ChannelSetupWizardCredentialValues; } | void>; +/** Optional post-step hook for final validation, writes, or post prompts. */ export type ChannelSetupWizardFinalize = (params: { cfg: OpenClawConfig; accountId: string; @@ -251,6 +264,7 @@ export type ChannelSetupWizardFinalize = (params: { credentialValues?: ChannelSetupWizardCredentialValues; } | void>; +/** Full declarative setup wizard consumed by the generic setup adapter. */ export type ChannelSetupWizard = { channel: string; status: ChannelSetupWizardStatus; @@ -283,6 +297,7 @@ export type ChannelSetupWizard = { onAccountRecorded?: ChannelSetupWizardAdapter["onAccountRecorded"]; }; +/** Runtime options for selecting and configuring one or more channels. */ export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; @@ -321,12 +336,14 @@ export type ChannelSetupStatus = { quickstartScore?: number; }; +/** Shared context for status checks before channel selection. */ export type ChannelSetupStatusContext = { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; }; +/** Shared context for applying setup changes for a selected channel. */ export type ChannelSetupConfigureContext = { cfg: OpenClawConfig; runtime: RuntimeEnv; @@ -337,6 +354,7 @@ export type ChannelSetupConfigureContext = { forceAllowFrom: boolean; }; +/** Context passed after setup has written config to disk. */ export type ChannelOnboardingPostWriteContext = { previousCfg: OpenClawConfig; cfg: OpenClawConfig; @@ -344,6 +362,7 @@ export type ChannelOnboardingPostWriteContext = { runtime: RuntimeEnv; }; +/** Deferred hook for channel work that must run after config persistence. */ export type ChannelOnboardingPostWriteHook = { channel: ChannelId; accountId: string; @@ -362,6 +381,7 @@ export type ChannelSetupInteractiveContext = ChannelSetupConfigureContext & { label: string; }; +/** Optional direct-message policy contract exposed by setup adapters. */ export type ChannelSetupDmPolicy = { label: string; channel: ChannelId; @@ -380,6 +400,7 @@ export type ChannelSetupDmPolicy = { }) => Promise; }; +/** Imperative adapter consumed by onboarding and setup flows. */ export type ChannelSetupWizardAdapter = { channel: ChannelId; getStatus: (ctx: ChannelSetupStatusContext) => Promise; diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 7e3bdf19886e..38dc3cc557c8 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -19,6 +19,8 @@ import type { } from "./setup-wizard-types.js"; import type { ChannelSetupInput } from "./types.core.js"; +// Adapts declarative channel setup wizard definitions into the imperative +// setup adapter used by onboarding and channel configuration flows. export type { ChannelSetupWizard, ChannelSetupWizardAllowFrom, @@ -71,6 +73,8 @@ async function buildStatus( }; } +// Legacy setup adapters still own the canonical config write path. Wizard +// inputs funnel through them unless a field supplies a narrower writer. function applySetupInput(params: { plugin: ChannelSetupWizardPlugin; cfg: OpenClawConfig; @@ -133,6 +137,8 @@ function collectCredentialValues(params: { return values; } +// Text inputs can either update custom config state or reuse the same generic +// setup input contract as credential steps. async function applyWizardTextInputValue(params: { plugin: ChannelSetupWizardPlugin; input: ChannelSetupWizardTextInput; @@ -211,6 +217,8 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { }); let usedEnvShortcut = false; + // The env shortcut is all-or-nothing. Once accepted, skip credential + // prompts so the user does not overwrite env-backed setup accidentally. if (wizard.envShortcut?.isAvailable({ cfg: next, accountId })) { const useEnvShortcut = await prompter.confirm({ message: wizard.envShortcut.prompt, @@ -240,6 +248,8 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { await prompter.note(wizard.introNote.lines.join("\n"), wizard.introNote.title); } + // Prepare/finalize hooks may derive helper values from credentials. + // Keep credentialValues current so later optional steps can reuse them. if (wizard.prepare) { const prepared = await wizard.prepare({ cfg: next, @@ -277,6 +287,8 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { }) : true; if (!shouldPrompt) { + // A skipped credential can still expose a resolved value for later + // text inputs, allowlist resolution, or finalize hooks. if (resolvedCredentialValue) { credentialValues[credential.inputKey] = resolvedCredentialValue; } else { @@ -388,6 +400,8 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { if (currentValue) { credentialValues[textInput.inputKey] = currentValue; if (textInput.applyCurrentValue) { + // Some inputs are derived from existing config but still need + // normalization written back before dependent steps run. next = await applyWizardTextInputValue({ plugin, input: textInput, @@ -543,6 +557,8 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: { if (forceAllowFrom && wizard.allowFrom) { const allowFrom = wizard.allowFrom; + // Allowlist resolution often needs the freshly entered credential, not + // only the persisted config, because setup may not have been written yet. const allowFromCredentialValue = normalizeOptionalString( credentialValues[allowFrom.credentialInputKey ?? wizard.credentials[0]?.inputKey], ); diff --git a/src/channels/plugins/stateful-target-builtins.ts b/src/channels/plugins/stateful-target-builtins.ts index 04275eeff94e..18b118be52e2 100644 --- a/src/channels/plugins/stateful-target-builtins.ts +++ b/src/channels/plugins/stateful-target-builtins.ts @@ -1,5 +1,7 @@ import { registerStatefulBindingTargetDriver } from "./stateful-target-drivers.js"; +// Lazily registers built-in stateful binding target drivers. Keep imports +// dynamic so non-ACP channel flows do not load the ACP runtime boundary. type AcpStatefulTargetDriverModule = typeof import("./acp-stateful-target-driver.js"); let builtinsRegisteredPromise: Promise | null = null; @@ -26,6 +28,8 @@ export async function ensureStatefulTargetBuiltinsRegistered(): Promise { try { await builtinsRegisteredPromise; } catch (error) { + // Retry after failed dynamic import/registration; a rejected singleton would + // otherwise permanently disable later setup or binding attempts. builtinsRegisteredPromise = null; throw error; } diff --git a/src/channels/plugins/stateful-target-drivers.ts b/src/channels/plugins/stateful-target-drivers.ts index 8630748e060d..6a210c87911d 100644 --- a/src/channels/plugins/stateful-target-drivers.ts +++ b/src/channels/plugins/stateful-target-drivers.ts @@ -4,6 +4,8 @@ import type { StatefulBindingTargetDescriptor, } from "./binding-types.js"; +// Registry for binding targets that carry external mutable session state, such +// as ACP-backed channels that need per-session reset and lookup behavior. export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string }; export type StatefulBindingTargetSessionResult = | { ok: true; sessionKey: string } @@ -12,6 +14,7 @@ export type StatefulBindingTargetResetResult = | { ok: true } | { ok: false; skipped?: boolean; error?: string }; +/** Driver contract for lifecycle operations on one stateful target family. */ export type StatefulBindingTargetDriver = { id: string; ensureReady: (params: { @@ -49,6 +52,8 @@ export function registerStatefulBindingTargetDriver(driver: StatefulBindingTarge const normalized = { ...driver, id }; const existing = registeredStatefulBindingTargetDrivers.get(id); if (existing) { + // Builtins and tests may register through multiple load paths. First writer + // wins so process-local sessions keep using the same driver instance. return; } registeredStatefulBindingTargetDrivers.set(id, normalized); @@ -74,6 +79,8 @@ export function resolveStatefulBindingTargetBySessionKey(params: { if (!sessionKey) { return null; } + // Session keys are globally opaque to callers. Ask each registered driver so + // channel-specific encodings stay private to their owner. for (const driver of listStatefulBindingTargetDrivers()) { const bindingTarget = driver.resolveTargetBySessionKey?.({ cfg: params.cfg, diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index c4cfdd43fdd2..61bde8df063d 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -3,10 +3,20 @@ import { isRecord } from "../../../utils.js"; import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.public.js"; export { isRecord }; +/** + * Shared helpers for channel status issue collectors. + */ + +/** + * Normalizes optional string metadata in status issue helpers. + */ export function asString(value: unknown): string | undefined { return typeof value === "string" ? normalizeOptionalString(value) : undefined; } +/** + * Formats optional match metadata for status issue messages. + */ export function formatMatchMetadata(params: { matchKey?: unknown; matchSource?: unknown; @@ -25,6 +35,9 @@ export function formatMatchMetadata(params: { return parts.length > 0 ? parts.join(" ") : undefined; } +/** + * Appends formatted match metadata to a status issue message. + */ export function appendMatchMetadata( message: string, params: { matchKey?: unknown; matchSource?: unknown }, @@ -33,6 +46,9 @@ export function appendMatchMetadata( return meta ? `${message} (${meta})` : message; } +/** + * Resolves the account id for enabled, configured account snapshots. + */ export function resolveEnabledConfiguredAccountId(account: { accountId?: unknown; enabled?: unknown; @@ -44,6 +60,9 @@ export function resolveEnabledConfiguredAccountId(account: { return enabled && configured ? accountId : null; } +/** + * Collects status issues only for enabled account snapshots. + */ export function collectIssuesForEnabledAccounts< T extends { accountId?: unknown; enabled?: unknown }, >(params: { @@ -54,6 +73,8 @@ export function collectIssuesForEnabledAccounts< const issues: ChannelStatusIssue[] = []; for (const entry of params.accounts) { const account = params.readAccount(entry); + // Disabled accounts should not produce missing credential/runtime issues in + // status output; they are intentionally inactive. if (!account || account.enabled === false) { continue; } diff --git a/src/channels/plugins/status-state.ts b/src/channels/plugins/status-state.ts index 150214f59a33..453059e0527f 100644 --- a/src/channels/plugins/status-state.ts +++ b/src/channels/plugins/status-state.ts @@ -1,3 +1,6 @@ +/** + * Human-readable channel status-state labels for status output. + */ export function formatChannelStatusState(statusState: string): string { switch (statusState) { case "linked": diff --git a/src/channels/plugins/target-resolvers.ts b/src/channels/plugins/target-resolvers.ts index 81bdd82fd6c3..40fa79287800 100644 --- a/src/channels/plugins/target-resolvers.ts +++ b/src/channels/plugins/target-resolvers.ts @@ -1,5 +1,12 @@ import type { ChannelResolveResult } from "./types.adapters.js"; +/** + * Shared helpers for channel target resolution flows. + */ + +/** + * Builds unresolved target results with one common note. + */ export function buildUnresolvedTargetResults( inputs: string[], note: string, @@ -11,6 +18,9 @@ export function buildUnresolvedTargetResults( })); } +/** + * Resolves targets only when a required token is available. + */ export async function resolveTargetsWithOptionalToken(params: { token?: string | null; inputs: string[]; @@ -20,6 +30,8 @@ export async function resolveTargetsWithOptionalToken(params: { }): Promise { const token = params.token?.trim(); if (!token) { + // Preserve one output row per input so setup UIs can show which entries + // could not be resolved while credentials are missing. return buildUnresolvedTargetResults(params.inputs, params.missingTokenNote); } const resolved = await params.resolveWithToken({ diff --git a/src/channels/plugins/thread-binding-api.ts b/src/channels/plugins/thread-binding-api.ts index 761904a1be25..e7b79fcb77d1 100644 --- a/src/channels/plugins/thread-binding-api.ts +++ b/src/channels/plugins/thread-binding-api.ts @@ -1,6 +1,10 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; +/** + * Lightweight thread-binding public artifact loader for bundled channels. + */ + type ThreadBindingPlacement = "current" | "child"; type ThreadBindingInboundConversationParams = { @@ -35,6 +39,8 @@ function loadBundledChannelThreadBindingApi(channelId: string): ThreadBindingApi artifactBasename: THREAD_BINDING_API_ARTIFACT_BASENAME, }); } catch (error) { + // Missing artifacts are optional; broken artifacts should surface so + // bundled thread-binding contracts do not fail silently. if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { return undefined; } @@ -47,6 +53,9 @@ function normalizeThreadBindingPlacement(value: unknown): ThreadBindingPlacement return normalized === "current" || normalized === "child" ? normalized : undefined; } +/** + * Resolves the default top-level thread-binding placement for a bundled channel. + */ export function resolveBundledChannelThreadBindingDefaultPlacement( channelId: string, ): ThreadBindingPlacement | undefined { @@ -55,6 +64,9 @@ export function resolveBundledChannelThreadBindingDefaultPlacement( ); } +/** + * Resolves inbound conversation refs from a bundled channel thread-binding artifact. + */ export function resolveBundledChannelThreadBindingInboundConversation( params: ThreadBindingInboundConversationParams & { channelId: string }, ): ThreadBindingConversationRef | null | undefined { diff --git a/src/channels/plugins/threading-helpers.ts b/src/channels/plugins/threading-helpers.ts index 4454444ced27..1e3a798fd2c3 100644 --- a/src/channels/plugins/threading-helpers.ts +++ b/src/channels/plugins/threading-helpers.ts @@ -2,12 +2,22 @@ import type { ReplyToMode } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ChannelThreadingAdapter } from "./types.core.js"; +/** + * Shared reply-threading resolver helpers for channel plugins. + */ + type ReplyToModeResolver = NonNullable; +/** + * Creates a reply-to-mode resolver that always returns one mode. + */ export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver { return () => mode; } +/** + * Creates a resolver that reads reply-to mode from top-level channel config. + */ export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver { return ({ cfg }) => { const channelConfig = ( @@ -17,6 +27,9 @@ export function createTopLevelChannelReplyToModeResolver(channelId: string): Rep }; } +/** + * Creates a resolver that reads reply-to mode from account-scoped config. + */ export function createScopedAccountReplyToModeResolver(params: { resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount; resolveReplyToMode: ( diff --git a/src/channels/plugins/tts-capabilities.ts b/src/channels/plugins/tts-capabilities.ts index c3a1a6ffcfb4..e5f9b61f567c 100644 --- a/src/channels/plugins/tts-capabilities.ts +++ b/src/channels/plugins/tts-capabilities.ts @@ -2,6 +2,8 @@ import { normalizeChannelId } from "./registry.js"; import { getChannelPlugin } from "./registry.js"; import type { ChannelTtsVoiceDeliveryCapabilities } from "./types.core.js"; +// Resolves channel-advertised TTS voice delivery support for prompt/runtime +// routing without exposing the full plugin object to callers. export function resolveChannelTtsVoiceDelivery( channel: string | undefined, ): ChannelTtsVoiceDeliveryCapabilities | undefined { diff --git a/src/channels/plugins/types.config.ts b/src/channels/plugins/types.config.ts index 8bf869c5ad46..5f56478a7036 100644 --- a/src/channels/plugins/types.config.ts +++ b/src/channels/plugins/types.config.ts @@ -1,5 +1,8 @@ import type { JsonSchemaObject } from "../../shared/json-schema.types.js"; +// Channel config schema metadata consumed by setup, docs, and validation UI. +// Runtime schemas stay duck-typed so plugins can provide zod-like validators. +/** Optional UI metadata for a JSON Schema property. */ export type ChannelConfigUiHint = { label?: string; help?: string; @@ -10,12 +13,14 @@ export type ChannelConfigUiHint = { itemTemplate?: unknown; }; +/** Normalized validation issue emitted by a channel runtime parser. */ export type ChannelConfigRuntimeIssue = { path?: Array; message?: string; code?: string; } & Record; +/** Minimal safeParse result shape accepted from channel-owned validators. */ export type ChannelConfigRuntimeParseResult = | { success: true; @@ -26,10 +31,12 @@ export type ChannelConfigRuntimeParseResult = issues: ChannelConfigRuntimeIssue[]; }; +/** Runtime validator contract paired with the JSON Schema config surface. */ export type ChannelConfigRuntimeSchema = { safeParse: (value: unknown) => ChannelConfigRuntimeParseResult; }; +/** Complete channel config schema description exposed to host tooling. */ export type ChannelConfigSchema = { schema: JsonSchemaObject; uiHints?: Record; diff --git a/src/channels/plugins/types.public.ts b/src/channels/plugins/types.public.ts index a2fc5729319e..052de92052ac 100644 --- a/src/channels/plugins/types.public.ts +++ b/src/channels/plugins/types.public.ts @@ -1,9 +1,12 @@ import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js"; +// Public channel-plugin type barrel used by plugin-facing facades. Keep exports +// type-only unless the value is part of the stable plugin contract. export { CHANNEL_MESSAGE_ACTION_NAMES } from "./message-action-names.js"; export type * from "./types.core.js"; export type * from "./types.adapters.js"; export type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelPlugin } from "./types.plugin.js"; +/** Stable message action name union derived from the registered action list. */ export type ChannelMessageActionName = ChannelMessageActionNameFromList; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index 2b1d49ba8689..ef53d80f9e30 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -1,8 +1,11 @@ import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js"; +// Curated internal channel-plugin type barrel. Keep this list explicit so core +// imports do not accidentally expose helper-only implementation types. export { CHANNEL_MESSAGE_ACTION_NAMES } from "./message-action-names.js"; export { CHANNEL_MESSAGE_CAPABILITIES } from "./message-capabilities.js"; +/** Stable message action name union derived from the registered action list. */ export type ChannelMessageActionName = ChannelMessageActionNameFromList; export type { ChannelMessageCapability } from "./message-capabilities.js"; diff --git a/src/channels/progress-draft-compositor.ts b/src/channels/progress-draft-compositor.ts index 87fe649be1f3..358bf52e0146 100644 --- a/src/channels/progress-draft-compositor.ts +++ b/src/channels/progress-draft-compositor.ts @@ -18,6 +18,8 @@ import { type StreamingMode, } from "./streaming.js"; +// Composes transient channel progress drafts from tool, reasoning, and +// commentary updates. It owns draft lifecycle state before the final reply wins. export type ChannelProgressDraftMode = StreamingMode; export type ChannelProgressDraftCompositor = ReturnType< @@ -25,6 +27,7 @@ export type ChannelProgressDraftCompositor = ReturnType< >; type ProgressDraftLine = string | ChannelProgressDraftLine; +/** Creates a stateful compositor for one streaming channel reply. */ export function createChannelProgressDraftCompositor(params: { entry: StreamingCompatEntry | null | undefined; mode: ChannelProgressDraftMode; @@ -139,6 +142,8 @@ export function createChannelProgressDraftCompositor(params: { return false; } if (shouldStoreLine && params.tryNativeUpdate) { + // Native draft updates get unformatted text; if the channel accepts it, + // keep local state aligned without sending a generic draft message. const text = formatDraftText(nextLines, { formatted: false }); if (text && (await params.tryNativeUpdate(text))) { lines = nextLines; @@ -228,6 +233,8 @@ export function createChannelProgressDraftCompositor(params: { return false; } if (previewToolProgressEnabled) { + // Reasoning streams usually arrive as deltas. Replace the previous + // reasoning line so the draft stays compact instead of appending noise. const priorIndex = lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine); if (priorIndex >= 0) { @@ -258,6 +265,8 @@ export function createChannelProgressDraftCompositor(params: { const normalized = normalizeCommentaryProgressText(text ?? ""); const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : ""; if (!normalized) { + // Empty commentary with an item id means the producer retracted that + // item; remove its draft line if it was already rendered. if (lineId) { await clearLine(lineId); } @@ -311,6 +320,8 @@ const REASONING_PROGRESS_TAG_PREFIXES = REASONING_PROGRESS_TAG_NAMES.flatMap((na function readReasoningProgressTextOutsideCode(text: string): string | undefined { if (isPartialReasoningProgressTagPrefix(text)) { + // Hold partial tags until more bytes arrive; otherwise a streaming "( lines: TLine[], id: string, @@ -11,5 +16,6 @@ export function removeChannelProgressDraftLine( return lines; } const next = lines.filter((line) => typeof line !== "object" || line.id?.trim() !== lineId); + // Reference equality is part of the caller contract; redraw/delete work only runs after a real removal. return next.length === lines.length ? lines : next; } diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index 108415158ee9..81dc8fe8c053 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -3,8 +3,11 @@ import { getBundledChannelAccountInspector } from "./plugins/bundled.js"; import { getLoadedChannelPlugin } from "./plugins/registry.js"; import type { ChannelId } from "./plugins/types.public.js"; +// Read-only account inspection facade for status/setup diagnostics. Prefer a +// loaded plugin inspector, then the lightweight bundled inspector artifact. export type ReadOnlyInspectedAccount = Record; +/** Inspects channel account config without loading mutable runtime surfaces. */ export async function inspectReadOnlyChannelAccount(params: { channelId: ChannelId; cfg: OpenClawConfig; diff --git a/src/channels/registry-lookup.ts b/src/channels/registry-lookup.ts index 743352ac092d..3b2fae1a380e 100644 --- a/src/channels/registry-lookup.ts +++ b/src/channels/registry-lookup.ts @@ -5,6 +5,8 @@ import type { } from "../plugins/channel-registry-state.types.js"; import { getActivePluginChannelRegistrySnapshotFromState } from "../plugins/runtime-channel-state.js"; +// Cached lookup view for the active channel plugin registry. The cache is keyed +// by registry object/version so request-time callers avoid rebuilding alias maps. export type RegisteredChannelPluginEntry = ActivePluginChannelRegistration & { plugin: ActivePluginChannelRegistration["plugin"] & { id?: string | null; @@ -74,16 +76,19 @@ function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup { return registeredChannelPluginLookup; } +/** Lists active channel plugin registrations from the current registry snapshot. */ export function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] { return buildRegisteredChannelPluginLookup().entries; } +/** Finds an active channel plugin registration by normalized id or alias. */ export function findRegisteredChannelPluginEntry( normalizedKey: string, ): RegisteredChannelPluginEntry | undefined { return buildRegisteredChannelPluginLookup().byKey.get(normalizedKey); } +/** Finds an active channel plugin registration by its canonical plugin id. */ export function findRegisteredChannelPluginEntryById( id: string, ): RegisteredChannelPluginEntry | undefined { diff --git a/src/channels/registry-normalize.ts b/src/channels/registry-normalize.ts index b7e63585f565..112a407b9c02 100644 --- a/src/channels/registry-normalize.ts +++ b/src/channels/registry-normalize.ts @@ -2,6 +2,8 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import type { ChannelId } from "./plugins/channel-id.types.js"; import { findRegisteredChannelPluginEntry } from "./registry-lookup.js"; +// Normalizes user/config channel identifiers through the active plugin registry +// so aliases resolve to canonical channel ids. export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { const key = normalizeOptionalLowercaseString(raw); if (!key) { diff --git a/src/channels/registry.ts b/src/channels/registry.ts index c77199d752ea..90ecc14951e6 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -15,16 +15,16 @@ export { CHAT_CHANNEL_ORDER } from "./ids.js"; export type { ChatChannelId } from "./ids.js"; export { normalizeChatChannelId }; -// Channel docking: prefer this helper in shared code. Importing from -// `src/channels/plugins/*` can eagerly load channel implementations. +/** + * Normalizes built-in chat channel ids without loading channel plugin implementations. + */ export function normalizeChannelId(raw?: string | null): ChatChannelId | null { return normalizeChatChannelId(raw); } -// Normalizes registered channel plugins (bundled or external). -// -// Keep this light: we do not import channel plugins here (those are "heavy" and can pull in -// monitors, web login, etc). The plugin registry must be initialized first. +/** + * Normalizes any registered channel plugin id or alias after registry initialization. + */ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { const key = normalizeOptionalLowercaseString(raw); if (!key) { @@ -33,6 +33,9 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { return findRegisteredChannelPluginEntry(key)?.plugin.id ?? null; } +/** + * Lists registered channel plugin ids without importing their runtime implementations. + */ export function listRegisteredChannelPluginIds(): ChannelId[] { return listRegisteredChannelPluginEntries().flatMap((entry) => { const id = normalizeOptionalString(entry.plugin.id); @@ -40,16 +43,25 @@ export function listRegisteredChannelPluginIds(): ChannelId[] { }); } +/** + * Returns lightweight channel metadata used by message formatting and capability checks. + */ export function getRegisteredChannelPluginMeta( id: string, ): Pick | null { return findRegisteredChannelPluginEntryById(id)?.plugin.meta ?? null; } +/** + * Formats a concise channel primer line for setup/status flows. + */ export function formatChannelPrimerLine(meta: ChannelMeta): string { return `${meta.label}: ${meta.blurb}`; } +/** + * Formats a docs-aware channel selection line for interactive setup prompts. + */ export function formatChannelSelectionLine( meta: ChannelMeta, docsLink: (path: string, label?: string) => string, diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index 171087826180..666e05d287ac 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -9,6 +9,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; type ModelSelectionContext = Parameters>[0]; +/** + * Mutable response-prefix state shared between reply setup and model selection callbacks. + */ export type ReplyPrefixContextBundle = { prefixContext: ResponsePrefixContext; responsePrefix?: string; @@ -16,11 +19,17 @@ export type ReplyPrefixContextBundle = { onModelSelected: (ctx: ModelSelectionContext) => void; }; +/** + * Reply option subset consumed by channel reply dispatchers. + */ export type ReplyPrefixOptions = Pick< ReplyPrefixContextBundle, "responsePrefix" | "responsePrefixContextProvider" | "onModelSelected" >; +/** + * Creates response-prefix options and a live context provider for the selected model. + */ export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; @@ -51,6 +60,9 @@ export function createReplyPrefixContext(params: { }; } +/** + * Creates the reply-prefix options object expected by `getReply` call sites. + */ export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; diff --git a/src/channels/route-projection.ts b/src/channels/route-projection.ts index fdeafdc95db7..3d1c86a8d14b 100644 --- a/src/channels/route-projection.ts +++ b/src/channels/route-projection.ts @@ -20,6 +20,7 @@ import { type DeliveryContext, } from "../utils/delivery-context.js"; +/** Channel route normalized enough to address an outbound delivery target. */ export type RoutableChannelRouteRef = ChannelRouteRef & { channel: string; target: { @@ -29,6 +30,7 @@ export type RoutableChannelRouteRef = ChannelRouteRef & { }; }; +/** Session fields that can carry or reconstruct a channel route. */ export type SessionRouteDeliveryFields = { route?: ChannelRouteRef; deliveryContext?: DeliveryContext; @@ -38,6 +40,7 @@ export type SessionRouteDeliveryFields = { lastThreadId?: string | number; }; +/** Normalizes a route and rejects routes that cannot address a channel target. */ export function normalizeRoutableChannelRoute( route?: ChannelRouteRef | null, ): RoutableChannelRouteRef | undefined { @@ -57,14 +60,17 @@ export function normalizeRoutableChannelRoute( return normalized as RoutableChannelRouteRef; } +/** Converts legacy delivery context metadata into a channel route. */ export function routeFromDeliveryContext(context?: DeliveryContext): ChannelRouteRef | undefined { return channelRouteFromDeliveryContext(normalizeDeliveryContext(context)); } +/** Converts a channel route back to legacy delivery context metadata. */ export function deliveryContextFromRoute(route?: ChannelRouteRef): DeliveryContext | undefined { return deliveryContextFromChannelRoute(route); } +/** Projects the best known delivery route from a stored session entry. */ export function routeFromSessionEntry(entry?: SessionEntry | null): ChannelRouteRef | undefined { if (!entry) { return undefined; @@ -75,12 +81,14 @@ export function routeFromSessionEntry(entry?: SessionEntry | null): ChannelRoute ); } +/** Builds session persistence fields from a channel route. */ export function sessionDeliveryFieldsFromRoute( route?: ChannelRouteRef, ): SessionRouteDeliveryFields { return normalizeSessionDeliveryFields({ route }); } +/** Converts a persisted conversation reference into a channel route. */ export function routeFromConversationRef( conversation?: ConversationRef | null, ): ChannelRouteRef | undefined { @@ -101,24 +109,28 @@ export function routeFromConversationRef( }); } +/** Converts a conversation reference into a routable channel route. */ export function routableRouteFromConversationRef( conversation?: ConversationRef | null, ): RoutableChannelRouteRef | undefined { return normalizeRoutableChannelRoute(routeFromConversationRef(conversation)); } +/** Extracts a channel route from a session binding record. */ export function routeFromBindingRecord( binding?: SessionBindingRecord | null, ): ChannelRouteRef | undefined { return routeFromConversationRef(binding?.conversation); } +/** Extracts a routable channel route from a session binding record. */ export function routableRouteFromBindingRecord( binding?: SessionBindingRecord | null, ): RoutableChannelRouteRef | undefined { return normalizeRoutableChannelRoute(routeFromBindingRecord(binding)); } +/** Projects route fields used by older session and delivery callers. */ export function routeToDeliveryFields(route?: ChannelRouteRef): { deliveryContext?: DeliveryContext; channel?: string; @@ -136,6 +148,7 @@ export function routeToDeliveryFields(route?: ChannelRouteRef): { }; } +/** Compares whether two routes address the same delivery target. */ export function routesShareDeliveryTarget(params: { left?: ChannelRouteRef | null; right?: ChannelRouteRef | null; @@ -148,6 +161,7 @@ export function routesShareDeliveryTarget(params: { return ( left.channel === right.channel && channelRouteTarget(left) === channelRouteTarget(right) && + // Missing account ids are wildcards; thread ids must match when present. (left.accountId == null || right.accountId == null || left.accountId === right.accountId) && String(channelRouteThreadId(left) ?? "") === String(channelRouteThreadId(right) ?? "") ); diff --git a/src/channels/sender-identity.ts b/src/channels/sender-identity.ts index f313d6f4b987..588c096b288c 100644 --- a/src/channels/sender-identity.ts +++ b/src/channels/sender-identity.ts @@ -1,7 +1,12 @@ +/** + * Core sender identity validation for channel contexts before plugins/tools consume them. + * Keep this generic; channel-specific identity extraction belongs in each plugin. + */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeChatType } from "./chat-type.js"; +/** Validates trusted sender identity fields before channel contexts reach plugins/tools. */ export function validateSenderIdentity(ctx: MsgContext): string[] { const issues: string[] = []; @@ -14,18 +19,22 @@ export function validateSenderIdentity(ctx: MsgContext): string[] { const senderE164 = normalizeOptionalString(ctx.SenderE164) || ""; if (!isDirect) { + // Group/channel messages need an actor identity distinct from the conversation target; + // direct chats can derive the actor from the peer route. if (!senderId && !senderName && !senderUsername && !senderE164) { issues.push("missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)"); } } if (senderE164) { + // Keep E.164 canonical here so access-group matching does not compare mixed phone formats. if (!/^\+\d{3,}$/.test(senderE164)) { issues.push(`invalid SenderE164: ${senderE164}`); } } if (senderUsername) { + // Usernames are handle tokens, not display names or @mentions. if (senderUsername.includes("@")) { issues.push(`SenderUsername should not include "@": ${senderUsername}`); } diff --git a/src/channels/sender-label.ts b/src/channels/sender-label.ts index 33d46cb9f1f2..d76ce9f998b0 100644 --- a/src/channels/sender-label.ts +++ b/src/channels/sender-label.ts @@ -1,5 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +// Sender display helpers shared by channel ingress and audit surfaces. The +// resolved label keeps a human-readable name plus stable id when both differ. export type SenderLabelParams = { name?: string; username?: string; @@ -18,6 +20,7 @@ function normalizeSenderLabelParams(params: SenderLabelParams) { }; } +/** Resolves the best one-line sender label from available identity fields. */ export function resolveSenderLabel(params: SenderLabelParams): string | null { const { name, username, tag, e164, id } = normalizeSenderLabelParams(params); @@ -29,6 +32,7 @@ export function resolveSenderLabel(params: SenderLabelParams): string | null { return display || idPart || null; } +/** Returns de-duplicated sender label candidates for matching and search. */ export function listSenderLabelCandidates(params: SenderLabelParams): string[] { const candidates = new Set(); const { name, username, tag, e164, id } = normalizeSenderLabelParams(params); diff --git a/src/channels/session-envelope.ts b/src/channels/session-envelope.ts index 46807b8ff9bf..6c60c36b479d 100644 --- a/src/channels/session-envelope.ts +++ b/src/channels/session-envelope.ts @@ -2,6 +2,8 @@ import { resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Resolves the session-envelope formatting context for an inbound channel turn. +// Callers use the previous timestamp to avoid losing existing session metadata. export function resolveInboundSessionEnvelopeContext(params: { cfg: OpenClawConfig; agentId: string; diff --git a/src/channels/session-meta.ts b/src/channels/session-meta.ts index 00a39c359f3f..8b1acb5c18a7 100644 --- a/src/channels/session-meta.ts +++ b/src/channels/session-meta.ts @@ -6,10 +6,14 @@ let inboundSessionRuntimePromise: Promise< > | null = null; function loadInboundSessionRuntime() { + // Keep the session writer out of channel startup paths that only need SDK types. inboundSessionRuntimePromise ??= import("../config/sessions/inbound.runtime.js"); return inboundSessionRuntimePromise; } +/** + * Best-effort inbound session metadata recorder for channel plugin command handlers. + */ export async function recordInboundSessionMetaSafe(params: { cfg: OpenClawConfig; agentId: string; @@ -28,6 +32,7 @@ export async function recordInboundSessionMetaSafe(params: { ctx: params.ctx, }); } catch (err) { + // Session metadata improves follow-up routing, but command handling should not fail on disk IO. params.onError?.(err); } } diff --git a/src/channels/session.types.ts b/src/channels/session.types.ts index 0b8644da9d66..f2c9b4c177e8 100644 --- a/src/channels/session.types.ts +++ b/src/channels/session.types.ts @@ -2,6 +2,8 @@ import type { MsgContext } from "../auto-reply/templating.js"; import type { GroupKeyResolution, SessionEntry } from "../config/sessions/types.js"; import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; +// Channel session recording contracts shared by inbound dispatch and session +// metadata writers. These types describe updates, not persistence mechanics. export type InboundLastRouteUpdate = { sessionKey: string; channel: SessionEntry["lastChannel"]; @@ -16,6 +18,7 @@ export type InboundLastRouteUpdate = { }; }; +/** Function contract for recording inbound channel session state. */ export type RecordInboundSession = (params: { storePath: string; sessionKey: string; diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index 9b641916a3ae..7145d628be47 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -2,15 +2,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { TOOL_DISPLAY_CONFIG } from "../agents/tool-display-config.js"; import { resolveToolDisplay } from "../agents/tool-display.js"; -/** - * Channel-agnostic status reaction controller. - * Provides a unified interface for displaying agent status via message reactions. - */ - -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── - +/** Adapter implemented by channels that expose message reaction status updates. */ export type StatusReactionAdapter = { /** Set/replace the current reaction emoji. */ setReaction: (emoji: string) => Promise; @@ -20,30 +12,33 @@ export type StatusReactionAdapter = { removeReaction?: (emoji: string) => Promise; }; +/** Optional emoji overrides for each status reaction state. */ export type StatusReactionEmojis = { - queued?: string; // Default: uses initialEmoji param - thinking?: string; // Default: "🧠" - tool?: string; // Default: "🛠️" - coding?: string; // Default: "💻" - web?: string; // Default: "🌐" - deploy?: string; // Default: "🛫" - build?: string; // Default: "🏗️" - concierge?: string; // Default: "💁" - done?: string; // Default: "✅" - error?: string; // Default: "❌" - stallSoft?: string; // Default: "⏳" - stallHard?: string; // Default: "⚠️" - compacting?: string; // Default: "🗜️" + queued?: string; + thinking?: string; + tool?: string; + coding?: string; + web?: string; + deploy?: string; + build?: string; + concierge?: string; + done?: string; + error?: string; + stallSoft?: string; + stallHard?: string; + compacting?: string; }; +/** Timing controls for debounced status reactions and stall warnings. */ export type StatusReactionTiming = { - debounceMs?: number; // Default: 700 - stallSoftMs?: number; // Default: 10000 - stallHardMs?: number; // Default: 30000 - doneHoldMs?: number; // Default: 1500 (not used in controller, but exported for callers) - errorHoldMs?: number; // Default: 2500 (not used in controller, but exported for callers) + debounceMs?: number; + stallSoftMs?: number; + stallHardMs?: number; + doneHoldMs?: number; + errorHoldMs?: number; }; +/** Controller API for agent status reaction state transitions. */ export type StatusReactionController = { setQueued: () => Promise | void; setThinking: () => Promise | void; @@ -57,10 +52,7 @@ export type StatusReactionController = { restoreInitial: () => Promise; }; -// ───────────────────────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────────────────────── - +/** Default emoji set used by status reaction controllers. */ export const DEFAULT_EMOJIS: Required = { queued: "👀", thinking: "🧠", @@ -77,6 +69,7 @@ export const DEFAULT_EMOJIS: Required = { compacting: "🗜️", }; +/** Default debounce, stall, and terminal hold timings for status reactions. */ export const DEFAULT_TIMING: Required = { debounceMs: 700, stallSoftMs: 10_000, @@ -85,6 +78,7 @@ export const DEFAULT_TIMING: Required = { errorHoldMs: 2500, }; +/** Tool-name tokens mapped to the coding status reaction. */ export const CODING_TOOL_TOKENS: string[] = [ "exec", "process", @@ -95,6 +89,7 @@ export const CODING_TOOL_TOKENS: string[] = [ "bash", ]; +/** Tool-name tokens mapped to the web status reaction. */ export const WEB_TOOL_TOKENS: string[] = [ "web_search", "web-search", @@ -103,6 +98,7 @@ export const WEB_TOOL_TOKENS: string[] = [ "browser", ]; +/** Tool-name tokens mapped to the deploy status reaction. */ export const DEPLOY_TOOL_TOKENS: string[] = [ "fastlane", "deploy", @@ -114,6 +110,7 @@ export const DEPLOY_TOOL_TOKENS: string[] = [ "distribute", ]; +/** Tool-name tokens mapped to the build status reaction. */ export const BUILD_TOOL_TOKENS: string[] = [ "build", "compile", @@ -129,6 +126,7 @@ export const BUILD_TOOL_TOKENS: string[] = [ "lint", ]; +/** Tool-name tokens mapped to the concierge/browser-control status reaction. */ export const CONCIERGE_TOOL_TOKENS: string[] = [ "navigate", "click", @@ -143,13 +141,7 @@ export const CONCIERGE_TOOL_TOKENS: string[] = [ "chromedp", ]; -// ───────────────────────────────────────────────────────────────────────────── -// Functions -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Resolve the appropriate emoji for a tool invocation. - */ +/** Resolves the appropriate emoji for a tool invocation. */ export function resolveToolEmoji( toolName: string | undefined, emojis: Required, @@ -216,7 +208,6 @@ export function createStatusReactionController(params: { }): StatusReactionController { const { enabled, adapter, initialEmoji, onError } = params; - // Merge user-provided overrides with defaults const emojis: Required = { ...DEFAULT_EMOJIS, queued: params.emojis?.queued ?? initialEmoji, @@ -228,7 +219,6 @@ export function createStatusReactionController(params: { ...params.timing, }; - // State let currentEmoji = ""; let pendingEmoji = ""; let debounceTimer: NodeJS.Timeout | null = null; @@ -238,17 +228,11 @@ export function createStatusReactionController(params: { let chainPromise = Promise.resolve(); const activeEmojis = new Set(); - /** - * Serialize async operations to prevent race conditions. - */ function enqueue(fn: () => Promise): Promise { chainPromise = chainPromise.then(fn, fn); return chainPromise; } - /** - * Clear all timers. - */ function clearAllTimers(): void { if (debounceTimer) { clearTimeout(debounceTimer); @@ -264,9 +248,6 @@ export function createStatusReactionController(params: { } } - /** - * Clear debounce timer only (used during phase transitions). - */ function clearDebounceTimer(): void { if (debounceTimer) { clearTimeout(debounceTimer); @@ -274,9 +255,6 @@ export function createStatusReactionController(params: { } } - /** - * Reset stall timers (called on each phase change). - */ function resetStallTimers(): void { if (stallSoftTimer) { clearTimeout(stallSoftTimer); @@ -315,9 +293,6 @@ export function createStatusReactionController(params: { } } - /** - * Apply an emoji while keeping previous active-loop reactions visible. - */ async function applyEmoji(newEmoji: string): Promise { if (!enabled) { return; @@ -337,9 +312,6 @@ export function createStatusReactionController(params: { } } - /** - * Schedule an emoji change (debounced or immediate). - */ function scheduleEmoji( emoji: string, options: { immediate?: boolean; skipStallReset?: boolean } = {}, @@ -348,7 +320,7 @@ export function createStatusReactionController(params: { return; } - // Deduplicate: if already scheduled/current, skip send but keep stall timers fresh + // Skip duplicate sends while still refreshing stall timers for active phases. if (emoji === currentEmoji || emoji === pendingEmoji) { if (!options.skipStallReset) { resetStallTimers(); @@ -360,13 +332,11 @@ export function createStatusReactionController(params: { clearDebounceTimer(); if (options.immediate) { - // Immediate execution for terminal states void enqueue(async () => { await applyEmoji(emoji); pendingEmoji = ""; }); } else { - // Debounced execution for intermediate states debounceTimer = setTimeout(() => { debounceTimer = null; void enqueue(async () => { @@ -376,16 +346,11 @@ export function createStatusReactionController(params: { }, timing.debounceMs); } - // Reset stall timers on phase change (unless triggered by stall timer itself) if (!options.skipStallReset) { resetStallTimers(); } } - // ─────────────────────────────────────────────────────────────────────────── - // Controller API - // ─────────────────────────────────────────────────────────────────────────── - function setQueued(): void { scheduleEmoji(emojis.queued, { immediate: true }); } @@ -416,7 +381,7 @@ export function createStatusReactionController(params: { finished = true; clearAllTimers(); - // Directly enqueue to ensure we return the updated promise + // Return the updated chain so callers can wait for terminal cleanup. return enqueue(async () => { await applyEmoji(emoji); await removeActiveEmojis({ keepEmoji: emoji }); diff --git a/src/channels/status/read-model.ts b/src/channels/status/read-model.ts index 6215cd5cbd1f..a70a8de4b7dc 100644 --- a/src/channels/status/read-model.ts +++ b/src/channels/status/read-model.ts @@ -5,6 +5,8 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { hasConfiguredUnavailableCredentialStatus } from "../account-snapshot-fields.js"; import type { ChannelAccountSnapshot } from "../plugins/types.public.js"; +// Read-model helpers for merging gateway channel status with local config +// snapshots. Keep input handling tolerant because gateway payloads are external. export type RuntimeChannelStatusPayload = { channelAccounts?: unknown; }; @@ -23,6 +25,7 @@ function readRuntimeAccountsByChannel(payload: unknown): Record return asRecord(asRecord(payload).channelAccounts); } +/** Reads raw runtime account records for one channel from a gateway payload. */ export function getRuntimeChannelAccounts(params: { payload: unknown; channelId: string; @@ -31,6 +34,7 @@ export function getRuntimeChannelAccounts(params: { return Array.isArray(raw) ? raw.map(asRecord) : []; } +/** Normalizes gateway channel account snapshots into a channel-id map. */ export function normalizeRuntimeChannelAccountSnapshots( payload: unknown, ): Map { @@ -52,6 +56,7 @@ export function normalizeRuntimeChannelAccountSnapshots( return out; } +/** Resolves a stable account id from runtime status record fallbacks. */ export function resolveRuntimeChannelAccountId(account: RuntimeChannelAccount): string { return ( normalizeOptionalString(account.accountId) ?? @@ -61,6 +66,7 @@ export function resolveRuntimeChannelAccountId(account: RuntimeChannelAccount): ); } +/** Finds a runtime account, including singleton default-account fallback. */ export function findRuntimeChannelAccount(params: { liveAccounts: RuntimeChannelAccount[]; accountId: string; @@ -75,6 +81,7 @@ export function findRuntimeChannelAccount(params: { ); } +/** Reports whether a runtime account has usable live credentials. */ export function hasRuntimeCredentialAvailable(params: { liveAccounts: RuntimeChannelAccount[]; accountId: string; @@ -89,6 +96,7 @@ export function hasRuntimeCredentialAvailable(params: { return account.running === true || account.connected === true; } +/** Converts configured-but-unavailable credential markers to available. */ export function markConfiguredUnavailableCredentialStatusesAvailable( account: unknown, ): Record { @@ -101,6 +109,7 @@ export function markConfiguredUnavailableCredentialStatusesAvailable( return record; } +/** Merges local and runtime accounts into display rows with source metadata. */ export async function resolveChannelAccountStatusRows(params: { localAccountIds: string[]; runtimeAccounts: ChannelAccountSnapshot[]; diff --git a/src/channels/streaming.ts b/src/channels/streaming.ts index 103f51f90a99..ab17d08240d7 100644 --- a/src/channels/streaming.ts +++ b/src/channels/streaming.ts @@ -27,12 +27,19 @@ export type { export type { SlackChannelStreamingConfig } from "../config/types.slack.js"; export type StreamingCompatEntry = { + /** Canonical nested streaming config or legacy preview mode string. */ streaming?: unknown; + /** Legacy preview stream mode. */ streamMode?: unknown; + /** Legacy text chunking mode. */ chunkMode?: unknown; + /** Legacy block delivery toggle. */ blockStreaming?: unknown; + /** Legacy preview chunk config. */ draftChunk?: unknown; + /** Legacy block coalescing config. */ blockStreamingCoalesce?: unknown; + /** Legacy native streaming transport toggle. */ nativeStreaming?: unknown; }; @@ -195,8 +202,11 @@ export async function resolveTranscriptBackedChannelFinalText(params: { } export type ChannelProgressLineOptions = { + /** Whether generated tool details should use Markdown formatting. */ markdown?: boolean; + /** Detail shape for tool arguments shown in progress drafts. */ detailMode?: "explain" | "raw"; + /** Whether command progress should show raw command text or status-only copy. */ commandText?: ChannelStreamingCommandTextMode; }; @@ -266,14 +276,23 @@ export type ChannelProgressDraftLineInput = export type ChannelProgressDraftLineKind = ChannelProgressDraftLineInput["event"]; export type ChannelProgressDraftLine = { + /** Stable line id used to update an existing progress line in place. */ id?: string; + /** Progress event family that produced this line. */ kind: ChannelProgressDraftLineKind; + /** Rendered line text before final draft truncation/prefix formatting. */ text: string; + /** Human-readable label for UI renderers. */ label: string; + /** Optional leading icon for rich or plain progress renderers. */ icon?: string; + /** Compact detail text separated from label/icon. */ detail?: string; + /** Optional lifecycle status, such as completed or exit code. */ status?: string; + /** Normalized tool name when the line represents tool work. */ toolName?: string; + /** Whether final formatting should add a bullet/line prefix. */ prefix?: boolean; }; @@ -374,14 +393,18 @@ function shouldPrefixProgressLine(line: string): boolean { } export function formatChannelProgressDraftLine( + /** Structured progress event to render as one draft line. */ input: ChannelProgressDraftLineInput, + /** Formatting options for tool details and command text. */ options?: ChannelProgressLineOptions, ): string | undefined { return buildChannelProgressDraftLine(input, options)?.text; } export function resolveChannelProgressDraftLineOptions( + /** Channel streaming config source for command-text defaults. */ entry: StreamingCompatEntry | null | undefined, + /** Caller-supplied line formatting overrides. */ options?: ChannelProgressLineOptions, ): ChannelProgressLineOptions { return { @@ -391,8 +414,11 @@ export function resolveChannelProgressDraftLineOptions( } export function buildChannelProgressDraftLineForEntry( + /** Channel streaming config source for command-text defaults. */ entry: StreamingCompatEntry | null | undefined, + /** Structured progress event to render as one draft line. */ input: ChannelProgressDraftLineInput, + /** Formatting options for tool details and command text. */ options?: ChannelProgressLineOptions, ): ChannelProgressDraftLine | undefined { return buildChannelProgressDraftLine( @@ -402,15 +428,20 @@ export function buildChannelProgressDraftLineForEntry( } export function formatChannelProgressDraftLineForEntry( + /** Channel streaming config source for command-text defaults. */ entry: StreamingCompatEntry | null | undefined, + /** Structured progress event to render as one draft line. */ input: ChannelProgressDraftLineInput, + /** Formatting options for tool details and command text. */ options?: ChannelProgressLineOptions, ): string | undefined { return buildChannelProgressDraftLineForEntry(entry, input, options)?.text; } export function buildChannelProgressDraftLine( + /** Structured progress event to normalize into draft-line metadata. */ input: ChannelProgressDraftLineInput, + /** Formatting options for tool details and command text. */ options?: ChannelProgressLineOptions, ): ChannelProgressDraftLine | undefined { switch (input.event) { @@ -514,9 +545,13 @@ export function buildChannelProgressDraftLine( } export function createChannelProgressDraftGate(params: { + /** Callback that starts the channel progress draft. */ onStart: () => void | Promise; + /** Delay before first work event starts a draft; second work event starts immediately. */ initialDelayMs?: number; + /** Timer implementation, injectable for tests. */ setTimeoutFn?: typeof setTimeout; + /** Timer clearer, injectable for tests. */ clearTimeoutFn?: typeof clearTimeout; }) { const initialDelayMs = params.initialDelayMs ?? DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS; @@ -561,6 +596,8 @@ export function createChannelProgressDraftGate(params: { started = false; throw error; }); + // Hold one startup promise so timer, explicit start, and second-work triggers + // cannot race into duplicate draft creation. startPromise = nextStart; return startPromise; }; @@ -888,6 +925,8 @@ function compactChannelProgressDraftLine(line: string, maxChars: number): string if (detailLimit < 8) { return undefined; } + // Keep the stable tool label/icon visible while trimming volatile command + // detail; this reduces progress draft edit churn in chat UIs. return removeUnbalancedInlineBackticks( `${prefix}${compactProgressLineDetail(detail, detailLimit)}`, ); @@ -945,6 +984,7 @@ function getProgressDraftLineText(line: string | ChannelProgressDraftLine): stri } export function normalizeChannelProgressDraftLineIdentity( + /** Progress line whose duplicate/update identity should be normalized. */ line: string | ChannelProgressDraftLine | undefined, ): string { const text = typeof line === "string" ? line : line?.text; @@ -952,8 +992,11 @@ export function normalizeChannelProgressDraftLineIdentity( } export function mergeChannelProgressDraftLine( + /** Existing progress draft lines in display order. */ lines: TLine[], + /** New or updated progress line. */ line: TLine, + /** Merge limits for rolling progress drafts. */ params: { maxLines: number }, ): TLine[] { const normalized = normalizeChannelProgressDraftLineIdentity(line); @@ -983,11 +1026,17 @@ export function mergeChannelProgressDraftLine; + /** Stable seed used when choosing automatic progress labels. */ seed?: string; + /** Random source used when choosing automatic progress labels. */ random?: () => number; + /** Optional formatter applied after line compaction. */ formatLine?: (line: string) => string; + /** Prefix used for plain progress lines that lack their own icon. */ bullet?: string; }): string { const rawLabel = resolveChannelProgressDraftLabel({ diff --git a/src/channels/targets.ts b/src/channels/targets.ts index cd7d7c7f1aa7..efe229fc2fe0 100644 --- a/src/channels/targets.ts +++ b/src/channels/targets.ts @@ -1,10 +1,16 @@ +/** + * Shared messaging-target parsing primitives for channel plugins and SDK consumers. + * Channel-specific grammars stay in plugins; this file owns common target shapes and parse order. + */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; export type { DirectoryConfigParams } from "./plugins/directory-types.js"; export type { ChannelDirectoryEntry } from "./plugins/types.public.js"; +/** Canonical route target families shared by channel-owned parsers. */ export type MessagingTargetKind = "user" | "channel"; +/** Parsed channel target with the original token and normalized lookup key. */ export type MessagingTarget = { kind: MessagingTargetKind; id: string; @@ -12,15 +18,18 @@ export type MessagingTarget = { normalized: string; }; +/** Options for parsers that can infer a kind or reject ambiguous input. */ export type MessagingTargetParseOptions = { defaultKind?: MessagingTargetKind; ambiguousMessage?: string; }; +/** Builds the stable lower-case lookup key used to compare channel targets. */ export function normalizeTargetId(kind: MessagingTargetKind, id: string): string { return normalizeLowercaseStringOrEmpty(`${kind}:${id}`); } +/** Creates a parsed target while preserving the user-provided raw token. */ export function buildMessagingTarget( kind: MessagingTargetKind, id: string, @@ -34,6 +43,7 @@ export function buildMessagingTarget( }; } +/** Validates an extracted target id with a channel-owned grammar. */ export function ensureTargetId(params: { candidate: string; pattern: RegExp; @@ -45,6 +55,7 @@ export function ensureTargetId(params: { return params.candidate; } +/** Parses one mention pattern whose first capture group is the target id. */ export function parseTargetMention(params: { raw: string; mentionPattern: RegExp; @@ -57,6 +68,7 @@ export function parseTargetMention(params: { return buildMessagingTarget(params.kind, match[1], params.raw); } +/** Parses a single kind-prefixed target such as channel: or user:. */ export function parseTargetPrefix(params: { raw: string; prefix: string; @@ -69,6 +81,7 @@ export function parseTargetPrefix(params: { return id ? buildMessagingTarget(params.kind, id, params.raw) : undefined; } +/** Parses the first matching kind-prefixed target from a channel grammar list. */ export function parseTargetPrefixes(params: { raw: string; prefixes: Array<{ prefix: string; kind: MessagingTargetKind }>; @@ -86,6 +99,7 @@ export function parseTargetPrefixes(params: { return undefined; } +/** Parses @user shorthand and validates it against a channel-owned user grammar. */ export function parseAtUserTarget(params: { raw: string; pattern: RegExp; @@ -103,6 +117,7 @@ export function parseAtUserTarget(params: { return buildMessagingTarget("user", id, params.raw); } +/** Tries mention, explicit prefixes, then @user shorthand in deterministic order. */ export function parseMentionPrefixOrAtUserTarget(params: { raw: string; mentionPattern: RegExp; @@ -132,6 +147,7 @@ export function parseMentionPrefixOrAtUserTarget(params: { }); } +/** Requires a parsed target of the requested kind and returns its channel id. */ export function requireTargetKind(params: { platform: string; target: MessagingTarget | undefined; diff --git a/src/channels/thread-binding-id.ts b/src/channels/thread-binding-id.ts index d5983ee3f8ae..fc6392e953c0 100644 --- a/src/channels/thread-binding-id.ts +++ b/src/channels/thread-binding-id.ts @@ -1,5 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +// Parses account-scoped thread binding ids back into conversation ids. Binding +// ids are account-prefixed so cross-account conversations cannot collide. export function resolveThreadBindingConversationIdFromBindingId(params: { accountId: string; bindingId?: string; diff --git a/src/channels/thread-bindings-messages.ts b/src/channels/thread-bindings-messages.ts index fb3321108a66..8e2857cedeb5 100644 --- a/src/channels/thread-bindings-messages.ts +++ b/src/channels/thread-bindings-messages.ts @@ -1,3 +1,7 @@ +/** + * Channel-neutral thread-binding message builders shared by plugins, ACP focus, and subagent flows. + * Keep text system-prefixed and compact because callers post it directly into user-visible threads. + */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { prefixSystemMessage } from "../infra/system-message.js"; @@ -15,6 +19,7 @@ function normalizeThreadBindingDurationMs(raw: unknown): number { return durationMs; } +/** Formats thread-binding timeout durations for compact user-facing messages. */ export function formatThreadBindingDurationLabel(durationMs: number): string { if (durationMs <= 0) { return "disabled"; @@ -29,6 +34,7 @@ export function formatThreadBindingDurationLabel(durationMs: number): string { return `${totalMinutes}m`; } +/** Builds the native thread name for a focused thread-bound session. */ export function resolveThreadBindingThreadName(params: { agentId?: string; label?: string; @@ -36,9 +42,11 @@ export function resolveThreadBindingThreadName(params: { const label = normalizeOptionalString(params.label); const base = label || normalizeOptionalString(params.agentId) || "agent"; const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim(); + // Native channel thread names have tight limits; keep generated names bounded. return raw.slice(0, 100); } +/** Builds the system-prefixed intro text posted when a thread binding becomes active. */ export function resolveThreadBindingIntroText(params: { agentId?: string; label?: string; @@ -81,6 +89,7 @@ export function resolveThreadBindingIntroText(params: { return prefixSystemMessage(`${intro}\n${details.join("\n")}`); } +/** Builds the system-prefixed farewell text posted when a thread binding ends. */ export function resolveThreadBindingFarewellText(params: { reason?: string; farewellText?: string; diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index a42ca5cbd891..6ceec82496d5 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -32,8 +32,10 @@ type ChannelThreadBindingsContainerShape = { accounts?: Record; }; +/** Thread-bound session type controlled by spawn policy. */ export type ThreadBindingSpawnKind = "subagent" | "acp"; +/** Effective per-channel/account policy for creating thread-bound sessions. */ export type ThreadBindingSpawnPolicy = { channel: string; accountId: string; @@ -42,20 +44,24 @@ export type ThreadBindingSpawnPolicy = { defaultSpawnContext: ThreadBindingSpawnContext; }; +/** Starting transcript mode for a spawned thread-bound session. */ export type ThreadBindingSpawnContext = "isolated" | "fork"; function normalizeChannelId(value: string | undefined | null): string { return normalizeLowercaseStringOrEmpty(value); } +/** Returns true when top-level commands should spawn in a child thread by default. */ export function supportsAutomaticThreadBindingSpawn(channel: string): boolean { return resolveDefaultTopLevelPlacement(channel) === "child"; } +/** Returns true when /thread here needs a native channel thread to exist first. */ export function requiresNativeThreadContextForThreadHere(channel: string): boolean { return resolveDefaultTopLevelPlacement(channel) === "child"; } +/** Resolves whether a thread binding should attach to the current thread or create a child. */ export function resolveThreadBindingPlacementForCurrentContext(params: { channel: string; threadId?: string; @@ -72,6 +78,7 @@ function resolveDefaultTopLevelPlacement(channel: string): "current" | "child" { return "current"; } return ( + // Loaded plugin metadata wins; bundled metadata is the startup-safe fallback. getLoadedChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(normalized) ?? "current" @@ -104,6 +111,7 @@ function resolveThreadBindingHoursMs(raw: unknown, fallbackHours: number): numbe return Math.min(durationMs, MAX_DATE_TIMESTAMP_MS); } +/** Resolves thread-binding idle timeout with channel/account override before session default. */ export function resolveThreadBindingIdleTimeoutMs(params: { channelIdleHoursRaw: unknown; sessionIdleHoursRaw: unknown; @@ -114,6 +122,7 @@ export function resolveThreadBindingIdleTimeoutMs(params: { ); } +/** Resolves thread-binding max age with channel/account override before session default. */ export function resolveThreadBindingMaxAgeMs(params: { channelMaxAgeHoursRaw: unknown; sessionMaxAgeHoursRaw: unknown; @@ -125,6 +134,7 @@ export function resolveThreadBindingMaxAgeMs(params: { ); } +/** Computes the effective expiry timestamp for a thread-binding lifecycle record. */ export function resolveThreadBindingEffectiveExpiresAt(params: { record: ThreadBindingLifecycleRecord; defaultIdleTimeoutMs: number; @@ -133,6 +143,7 @@ export function resolveThreadBindingEffectiveExpiresAt(params: { return resolveSharedThreadBindingLifecycle(params).expiresAt; } +/** Resolves the effective enabled flag for thread bindings. */ export function resolveThreadBindingsEnabled(params: { channelEnabledRaw: unknown; sessionEnabledRaw: unknown; @@ -171,6 +182,7 @@ function normalizeSpawnContext(value: unknown): ThreadBindingSpawnContext | unde return value === "isolated" || value === "fork" ? value : undefined; } +/** Resolves effective spawn policy from account, channel, then global thread-binding config. */ export function resolveThreadBindingSpawnPolicy(params: { cfg: OpenClawConfig; channel: string; @@ -211,6 +223,7 @@ export function resolveThreadBindingSpawnPolicy(params: { }; } +/** Resolves idle timeout for a concrete channel/account config scope. */ export function resolveThreadBindingIdleTimeoutMsForChannel(params: { cfg: OpenClawConfig; channel: string; @@ -223,6 +236,7 @@ export function resolveThreadBindingIdleTimeoutMsForChannel(params: { }); } +/** Resolves max age for a concrete channel/account config scope. */ export function resolveThreadBindingMaxAgeMsForChannel(params: { cfg: OpenClawConfig; channel: string; @@ -249,6 +263,7 @@ function resolveThreadBindingChannelScope(params: { }); } +/** Formats the user-facing error for disabled thread bindings. */ export function formatThreadBindingDisabledError(params: { channel: string; accountId: string; @@ -257,6 +272,7 @@ export function formatThreadBindingDisabledError(params: { return `Thread bindings are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).`; } +/** Formats the user-facing error for disabled thread-bound session spawning. */ export function formatThreadBindingSpawnDisabledError(params: { channel: string; accountId: string; diff --git a/src/channels/transport/stall-watchdog.ts b/src/channels/transport/stall-watchdog.ts index 9977255e42a5..e9e61b0f719d 100644 --- a/src/channels/transport/stall-watchdog.ts +++ b/src/channels/transport/stall-watchdog.ts @@ -1,11 +1,14 @@ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import type { RuntimeEnv } from "../../runtime.js"; +// Armable idle watchdog for long-running channel transports. It only starts +// timing after arm(), so callers can construct it before connection activity. export type StallWatchdogTimeoutMeta = { idleMs: number; timeoutMs: number; }; +/** Public control surface for a transport stall watchdog instance. */ export type ArmableStallWatchdog = { arm: (atMs?: number) => void; touch: (atMs?: number) => void; @@ -14,6 +17,7 @@ export type ArmableStallWatchdog = { isArmed: () => boolean; }; +/** Creates a watchdog that reports once when an armed transport goes idle. */ export function createArmableStallWatchdog(params: { label: string; timeoutMs: number; @@ -81,6 +85,8 @@ export function createArmableStallWatchdog(params: { if (idleMs < timeoutMs) { return; } + // Disarm before invoking onTimeout so retries or teardown cannot fire a + // second timeout from the same idle interval. disarm(); params.runtime?.error?.( `[${params.label}] transport watchdog timeout: idle ${Math.round(idleMs / 1000)}s (limit ${Math.round(timeoutMs / 1000)}s)`, diff --git a/src/channels/typing-lifecycle.ts b/src/channels/typing-lifecycle.ts index d73630b9b5e9..875eaba2cfac 100644 --- a/src/channels/typing-lifecycle.ts +++ b/src/channels/typing-lifecycle.ts @@ -7,6 +7,7 @@ type TypingKeepaliveLoop = { isRunning: () => boolean; }; +/** Creates a cancellable keepalive loop for channel typing indicators. */ export function createTypingKeepaliveLoop(params: { intervalMs: number; onTick: AsyncTick; @@ -18,6 +19,7 @@ export function createTypingKeepaliveLoop(params: { if (tickInFlight) { return; } + // Avoid overlapping typing updates when a channel API call stalls past the interval. tickInFlight = true; try { await params.onTick(); diff --git a/src/channels/typing-start-guard.ts b/src/channels/typing-start-guard.ts index c575376a5d68..df818ac71de1 100644 --- a/src/channels/typing-start-guard.ts +++ b/src/channels/typing-start-guard.ts @@ -4,6 +4,9 @@ type TypingStartGuard = { isTripped: () => boolean; }; +/** + * Creates a small circuit breaker for channel typing-start calls. + */ export function createTypingStartGuard(params: { isSealed: () => boolean; shouldBlock?: () => boolean; @@ -43,6 +46,8 @@ export function createTypingStartGuard(params: { if (params.rethrowOnError) { throw err; } + // Keep failed typing indicators from repeatedly delaying reply delivery + // after a channel-specific backend starts rejecting start calls. if (maxConsecutiveFailures && consecutiveFailures >= maxConsecutiveFailures) { tripped = true; params.onTrip?.(); diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index cea1659fec33..bdcac4a96ff5 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -2,6 +2,8 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; +// Extracts assistant-message canvas previews from tool JSON or markdown embed +// shortcodes. The returned text strips consumed shortcodes for channel delivery. type CanvasSurface = "assistant_message"; type CanvasPreview = { @@ -176,6 +178,7 @@ function previewFromShortcode(attrs: Record): CanvasPreview | un return undefined; } +/** Extracts a canvas preview from a JSON-shaped tool or assistant payload. */ export function extractCanvasFromText( outputText: string | undefined, _toolName?: string, @@ -184,6 +187,7 @@ export function extractCanvasFromText( return coerceCanvasPreview(parsed); } +/** Extracts [embed ...] shortcodes outside code fences and returns stripped text. */ export function extractCanvasShortcodes(text: string | undefined): { text: string; previews: CanvasPreview[]; @@ -205,6 +209,7 @@ export function extractCanvasShortcodes(text: string | undefined): { while ((match = re.exec(text))) { const start = match.index ?? 0; if (fenceSpans.some((span) => start >= span.start && start < span.end)) { + // Literal embed examples in code blocks must remain visible text. continue; } matches.push({ @@ -224,6 +229,8 @@ export function extractCanvasShortcodes(text: string | undefined): { let stripped = ""; for (const match of matches) { if (match.start < cursor) { + // Prefer the first non-overlapping shortcode so nested/overlapping input + // cannot strip arbitrary text outside the matched span. continue; } stripped += text.slice(cursor, match.start); diff --git a/src/cli/cli-name.ts b/src/cli/cli-name.ts index 254ec375dfb3..b3af3d285445 100644 --- a/src/cli/cli-name.ts +++ b/src/cli/cli-name.ts @@ -1,3 +1,4 @@ +// CLI-name helpers keep generated examples aligned with the binary the user invoked. import path from "node:path"; const DEFAULT_CLI_NAME = "openclaw"; @@ -5,6 +6,7 @@ const DEFAULT_CLI_NAME = "openclaw"; const KNOWN_CLI_NAMES = new Set([DEFAULT_CLI_NAME]); const CLI_PREFIX_RE = /^(?:((?:pnpm|npm|bunx|npx)\s+))?(openclaw)\b/; +/** Resolve the displayed CLI binary name from argv, falling back to `openclaw`. */ export function resolveCliName(argv: string[] = process.argv): string { const argv1 = argv[1]; if (!argv1) { @@ -17,6 +19,7 @@ export function resolveCliName(argv: string[] = process.argv): string { return DEFAULT_CLI_NAME; } +/** Replace a leading `openclaw` command prefix with the active CLI name. */ export function replaceCliName(command: string, cliName = resolveCliName()): string { if (!command.trim()) { return command; diff --git a/src/cli/command-bootstrap.ts b/src/cli/command-bootstrap.ts index 3625c136a1b1..5a78fe11327e 100644 --- a/src/cli/command-bootstrap.ts +++ b/src/cli/command-bootstrap.ts @@ -1,3 +1,4 @@ +// Shared command preflight: config readiness plus optional plugin registry activation. import type { RuntimeEnv } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import type { CliPluginRegistryPolicy } from "./command-catalog.js"; @@ -10,6 +11,7 @@ function loadConfigGuardModule() { return configGuardModuleLoader.load(); } +/** Run the lazy command bootstrap steps selected by command policy. */ export async function ensureCliCommandBootstrap(params: { runtime: RuntimeEnv; commandPath: string[]; diff --git a/src/cli/command-config-resolution.ts b/src/cli/command-config-resolution.ts index 3fbcef4252d7..385982333d3a 100644 --- a/src/cli/command-config-resolution.ts +++ b/src/cli/command-config-resolution.ts @@ -1,3 +1,4 @@ +// Command config resolver that combines secret materialization with optional plugin auto-enable. import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -6,6 +7,7 @@ import { resolveCommandSecretRefsViaGateway, } from "./command-secret-gateway.js"; +/** Resolve command-scoped secrets and return both raw resolved and effective config views. */ export async function resolveCommandConfigWithSecrets(params: { config: TConfig; commandName: string; diff --git a/src/cli/command-format.ts b/src/cli/command-format.ts index ecb9b6870130..9b8fe988540d 100644 --- a/src/cli/command-format.ts +++ b/src/cli/command-format.ts @@ -1,3 +1,4 @@ +// Formats CLI command examples with active container/profile hints when they apply. import { replaceCliName, resolveCliName } from "./cli-name.js"; import { normalizeProfileName } from "./profile-utils.js"; @@ -9,6 +10,7 @@ const UPDATE_COMMAND_RE = /^(?:pnpm|npm|bunx|npx)\s+openclaw\b.*(?:^|\s)update(?:\s|$)|^openclaw\b.*(?:^|\s)update(?:\s|$)/; const CONTAINER_HINT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/; +/** Add active root options to a displayed command without duplicating explicit flags. */ export function formatCliCommand( command: string, env: Record = process.env as Record, diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index cef81d7df956..52233db96e39 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,3 +1,5 @@ +// Command-specific secret target policy. Each exported helper returns the config secret IDs +// a command may inspect, with optional concrete-path filters for selected providers/accounts. import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; @@ -206,6 +208,7 @@ function pathPatternMatchesConcretePath(pathPattern: string, path: string): bool return pathIndex === pathSegments.length; } +// Registry entries use wildcard path patterns; command inputs often identify one concrete config path. function targetIdsForConfigPath(path: string): string[] { return listSecretTargetRegistryEntries() .filter((entry) => pathPatternMatchesConcretePath(entry.pathPattern ?? entry.id, path)) @@ -521,6 +524,8 @@ function addSelectedProviderCredentialTargets< }) => void; hasConfiguredCredential: (provider: Provider) => boolean; }): boolean { + // A selected provider can own a direct path and a plugin-scoped path; include both so + // secret filtering does not hide the credential a provider actually resolved from config. if (params.provider.credentialPath.trim()) { addConfigPathTargets({ path: params.provider.credentialPath, @@ -662,6 +667,8 @@ function getCapabilityWebProviderAutoDetectTargets< if (fallbackTargetIds.size === 0) { return { targetIds }; } + // Fallback credentials are optional unless their concrete path is already configured; + // this prevents auto-detect from forcing unrelated provider credentials active. const allowedPaths = mergeConfiguredAllowedPaths({ config: params.config, baseTargetIds: params.baseTargetIds, @@ -816,6 +823,7 @@ function pathTargetsScopedChannelAccount(params: { return accountId === params.accountId; } +/** Return channel secret targets, optionally narrowed to one channel account subtree. */ export function getScopedChannelsCommandSecretTargets(params: { config: OpenClawConfig; channel?: string | null; @@ -846,14 +854,17 @@ export function getScopedChannelsCommandSecretTargets(params: { return { targetIds, allowedPaths }; } +/** Secret targets needed by QR remote pairing flows. */ export function getQrRemoteCommandSecretTargetIds(): Set { return toTargetIdSet(STATIC_QR_REMOTE_TARGET_IDS); } +/** All registered channel secret targets, regardless of current config. */ export function getChannelsCommandSecretTargetIds(): Set { return toTargetIdSet(getCommandSecretTargets().channels); } +/** Channel secret targets contributed by channels currently present in config/read-only plugins. */ export function getConfiguredChannelsCommandSecretTargetIds( config: OpenClawConfig, env?: NodeJS.ProcessEnv, @@ -861,18 +872,22 @@ export function getConfiguredChannelsCommandSecretTargetIds( return toTargetIdSet(getConfiguredChannelSecretTargetIds(config, env)); } +/** Model-provider credential targets used by commands that can touch provider config. */ export function getModelsCommandSecretTargetIds(): Set { return toTargetIdSet(STATIC_MODEL_TARGET_IDS); } +/** Credential targets required by memory embedding flows. */ export function getMemoryEmbeddingCommandSecretTargetIds(): Set { return toTargetIdSet(STATIC_MEMORY_EMBEDDING_TARGET_IDS); } +/** Credential targets required by text-to-speech flows. */ export function getTtsCommandSecretTargetIds(): Set { return toTargetIdSet(STATIC_TTS_TARGET_IDS); } +/** Agent runtime credential targets, optionally including all channel credential targets. */ export function getAgentRuntimeCommandSecretTargetIds(params?: { includeChannelTargets?: boolean; }): Set { @@ -882,6 +897,7 @@ export function getAgentRuntimeCommandSecretTargetIds(params?: { return toTargetIdSet(getCommandSecretTargets().agentRuntime); } +/** Static web-fetch capability targets plus plugin-provided web-fetch credential targets. */ export function getCapabilityWebFetchCommandSecretTargetIds(): Set { return toTargetIdSet(getCapabilityWebFetchTargetIds()); } @@ -933,6 +949,7 @@ function getCapabilityWebCommandSecretTargets( }; } +/** Web-fetch target scope for selected/auto-detected providers and configured fallback paths. */ export function getCapabilityWebFetchCommandSecretTargets( config: OpenClawConfig, options?: { @@ -950,10 +967,12 @@ export function getCapabilityWebFetchCommandSecretTargets( }); } +/** Static web-search capability targets plus plugin-provided web-search credential targets. */ export function getCapabilityWebSearchCommandSecretTargetIds(): Set { return toTargetIdSet(getCapabilityWebSearchTargetIds()); } +/** Web-search target scope for selected/auto-detected providers and configured fallback paths. */ export function getCapabilityWebSearchCommandSecretTargets( config: OpenClawConfig, options?: { @@ -971,6 +990,7 @@ export function getCapabilityWebSearchCommandSecretTargets( }); } +/** Status command targets; channel targets can be limited to configured channel plugins. */ export function getStatusCommandSecretTargetIds( config?: OpenClawConfig, env?: NodeJS.ProcessEnv, @@ -985,6 +1005,7 @@ export function getStatusCommandSecretTargetIds( return toTargetIdSet([...STATIC_STATUS_TARGET_IDS, ...channelTargetIds]); } +/** Secret targets that the security audit command is allowed to inspect. */ export function getSecurityAuditCommandSecretTargetIds(): Set { return toTargetIdSet(getCommandSecretTargets().securityAudit); } diff --git a/src/cli/command-source.test-helpers.ts b/src/cli/command-source.test-helpers.ts index c8eef9399288..7b8388f91fb0 100644 --- a/src/cli/command-source.test-helpers.ts +++ b/src/cli/command-source.test-helpers.ts @@ -1,3 +1,4 @@ +// Test helper for reading command source plus lazy runtime modules that carry secret resolution. import fs from "node:fs/promises"; import path from "node:path"; @@ -42,6 +43,7 @@ async function readModuleSource(modulePath: string, seen: Set): Promise< return nestedSources.length > 0 ? [source, ...nestedSources].join("\n") : source; } +/** Read a command source file and selected nested runtime modules for source-policy tests. */ export async function readCommandSource( relativePath: string, cwd = process.cwd(), diff --git a/src/cli/config-recovery-hints.ts b/src/cli/config-recovery-hints.ts index 913f745dd3f1..6e75386adbcd 100644 --- a/src/cli/config-recovery-hints.ts +++ b/src/cli/config-recovery-hints.ts @@ -1,5 +1,7 @@ +// Reusable recovery strings for config/startup failures surfaced by CLI commands. import { formatCliCommand } from "./command-format.js"; +/** Hint shown when doctor can migrate or repair an invalid config file. */ export function formatInvalidConfigRecoveryHint(): string { return [ `Run "${formatCliCommand("openclaw doctor --fix")}" to repair, then retry.`, @@ -7,6 +9,7 @@ export function formatInvalidConfigRecoveryHint(): string { ].join("\n"); } +/** Hint shown when a plugin package is missing its compiled runtime output. */ export function formatPluginPackagingRuntimeOutputRecoveryHint(): string { return [ "This is a plugin packaging issue, not a local config problem.", diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts index dfc4160716d2..383ddcedb224 100644 --- a/src/cli/config-set-dryrun.ts +++ b/src/cli/config-set-dryrun.ts @@ -1,11 +1,15 @@ +// Shared dry-run result contract for `openclaw config set` validation-only paths. +/** Config-set input mode that produced the simulated operation. */ export type ConfigSetDryRunInputMode = "value" | "json" | "builder" | "unset"; +/** One validation error found during config-set dry-run processing. */ export type ConfigSetDryRunError = { kind: "missing-path" | "schema" | "resolvability"; message: string; ref?: string; }; +/** Dry-run summary returned by config-set command handlers and tests. */ export type ConfigSetDryRunResult = { ok: boolean; operations: number; diff --git a/src/cli/config-set-parser.ts b/src/cli/config-set-parser.ts index cbacfeecdec2..daa86913cc93 100644 --- a/src/cli/config-set-parser.ts +++ b/src/cli/config-set-parser.ts @@ -1,3 +1,4 @@ +// Mode resolver for `openclaw config set`; keeps mutually exclusive builders out of action code. type ConfigSetMode = "value" | "json" | "ref_builder" | "provider_builder" | "batch"; type ConfigSetModeResolution = @@ -10,6 +11,7 @@ type ConfigSetModeResolution = error: string; }; +/** Resolve the config-set input mode or return the exact flag-conflict error. */ export function resolveConfigSetMode(params: { hasBatchMode: boolean; hasRefBuilderOptions: boolean; diff --git a/src/cli/daemon-cli-compat.ts b/src/cli/daemon-cli-compat.ts index 81ab9c1b909a..d5466fc519fd 100644 --- a/src/cli/daemon-cli-compat.ts +++ b/src/cli/daemon-cli-compat.ts @@ -1,3 +1,4 @@ +// Compatibility parser for older bundled daemon CLI exports inside generated bundles. export const LEGACY_DAEMON_CLI_EXPORTS = [ "registerDaemonCli", "runDaemonInstall", @@ -10,6 +11,8 @@ export const LEGACY_DAEMON_CLI_EXPORTS = [ type LegacyDaemonCliExport = (typeof LEGACY_DAEMON_CLI_EXPORTS)[number]; type LegacyDaemonCliRunnerExport = Exclude; + +/** Accessor names for legacy daemon bundle exports after minifier/export alias resolution. */ export type LegacyDaemonCliAccessors = { registerDaemonCli: string; runDaemonRestart: string; @@ -53,6 +56,7 @@ function findRegisterContainerSymbol(bundleSource: string): string | null { return bundleSource.match(REGISTER_CONTAINER_RE)?.[1] ?? null; } +/** Find the accessor for the old `registerDaemonCli` export shape, including esbuild containers. */ export function resolveLegacyDaemonCliRegisterAccessor(bundleSource: string): string | null { const aliases = parseExportAliases(bundleSource); if (!aliases) { @@ -67,6 +71,7 @@ export function resolveLegacyDaemonCliRegisterAccessor(bundleSource: string): st : (registerDirectAlias ?? null); } +/** Find legacy daemon runner exports in generated bundle source. */ export function resolveLegacyDaemonCliRunnerAccessors( bundleSource: string, ): Partial> | null { @@ -102,6 +107,7 @@ export function resolveLegacyDaemonCliRunnerAccessors( }; } +/** Resolve all legacy daemon accessors required to bridge old bundles into current CLI code. */ export function resolveLegacyDaemonCliAccessors( bundleSource: string, ): LegacyDaemonCliAccessors | null { diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index ff2f3970e4eb..b4522d36c6b0 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -1,3 +1,4 @@ +// Public daemon CLI barrel retained for gateway service command compatibility. export { registerDaemonCli } from "./daemon-cli/register.js"; export { addGatewayServiceCommands } from "./daemon-cli/register-service-commands.js"; export { diff --git a/src/cli/daemon-cli/gateway-token-drift.ts b/src/cli/daemon-cli/gateway-token-drift.ts index 4cd7bf9864cf..d6a9efdda5ab 100644 --- a/src/cli/daemon-cli/gateway-token-drift.ts +++ b/src/cli/daemon-cli/gateway-token-drift.ts @@ -1,3 +1,4 @@ +// Token drift resolver for restart checks: compare service token only when token auth is active. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveGatewayAuthToken } from "../../gateway/auth-token-resolution.js"; import { createGatewayCredentialPlan } from "../../gateway/credential-planner.js"; @@ -21,6 +22,7 @@ function isPasswordFallbackActive(params: { return plan.passwordCanWin && !plan.tokenCanWin; } +/** Resolve the expected Gateway token for service drift checks, or undefined when token auth is inactive. */ export async function resolveGatewayTokenForDriftCheck(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; diff --git a/src/cli/daemon-cli/install.runtime.ts b/src/cli/daemon-cli/install.runtime.ts index e19e377d48ea..9025ca506153 100644 --- a/src/cli/daemon-cli/install.runtime.ts +++ b/src/cli/daemon-cli/install.runtime.ts @@ -1 +1,2 @@ +// Lazy runtime barrel for Gateway service install command implementation. export { runDaemonInstall } from "./install.js"; diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 61e28d7d5663..0378dfab372d 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -1,3 +1,4 @@ +// Gateway service installer: writes config defaults, resolves credentials, and installs service definitions. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveNodeStartupTlsEnvironment } from "../../bootstrap/node-startup-env.js"; import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js"; @@ -33,6 +34,7 @@ import { } from "./shared.js"; import type { DaemonInstallOptions } from "./types.js"; +/** Merge safe existing service environment into the current install invocation environment. */ export function mergeInstallInvocationEnv(params: { env: NodeJS.ProcessEnv; existingServiceEnv?: Record; @@ -62,6 +64,8 @@ export function mergeInstallInvocationEnv(params: { ) { continue; } + // Existing service env may contain host-specific secrets or loader overrides; keep only + // portable, non-dangerous values and let the current shell override them. if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { continue; } @@ -77,6 +81,7 @@ export function mergeInstallInvocationEnv(params: { }; } +/** Install or refresh the managed Gateway service. */ export async function runDaemonInstall(opts: DaemonInstallOptions) { const { json, stdout, warnings, emit, fail } = createDaemonInstallActionContext(opts.json); if (failIfNixDaemonInstallMode(fail)) { diff --git a/src/cli/daemon-cli/lifecycle.runtime.ts b/src/cli/daemon-cli/lifecycle.runtime.ts index 9c524d40907b..42133e6cb037 100644 --- a/src/cli/daemon-cli/lifecycle.runtime.ts +++ b/src/cli/daemon-cli/lifecycle.runtime.ts @@ -1,3 +1,4 @@ +// Lazy runtime barrel for Gateway service lifecycle command implementations. export { runDaemonRestart, runDaemonStart, diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 0cdfbfa7c62a..78f213e00132 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,3 +1,4 @@ +// Gateway service lifecycle runners, including unmanaged-process fallbacks and restart health checks. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { isRestartEnabled } from "../../config/commands.flags.js"; @@ -21,13 +22,13 @@ import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; import { parseDurationMs } from "../parse-duration.js"; import { recoverInstalledLaunchAgent } from "./launchd-recovery.js"; -import { createNullWriter } from "./response.js"; import { runServiceRestart, runServiceStart, runServiceStop, runServiceUninstall, } from "./lifecycle-core.js"; +import { createNullWriter } from "./response.js"; import { DEFAULT_RESTART_HEALTH_ATTEMPTS, DEFAULT_RESTART_HEALTH_DELAY_MS, @@ -257,6 +258,7 @@ async function restartGatewayWithoutServiceManager( }; } +/** Uninstall the managed Gateway service after stopping it. */ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { return await runServiceUninstall({ serviceNoun: "Gateway", @@ -267,6 +269,7 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { }); } +/** Start the managed Gateway service, repairing stale service definitions when possible. */ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { const service = resolveGatewayService(); return await runServiceStart({ @@ -289,6 +292,7 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { }); } +/** Stop the managed Gateway service or verified unmanaged listener fallback. */ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { const service = resolveGatewayService(); let gatewayPortPromise: Promise | undefined; @@ -306,11 +310,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { }); } -/** - * Restart the gateway service service. - * @returns `true` if restart succeeded, `false` if the service was not loaded. - * Throws/exits on check or restart failures. - */ +/** Restart the Gateway service or a verified unmanaged listener, then prove health. */ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { if (opts.skipDeferral && !opts.safe) { throw new Error("--skip-deferral requires --safe"); @@ -354,6 +354,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi }, postRestartCheck: async ({ warnings, fail, stdout }) => { if (restartedWithoutServiceManager) { + // SIGUSR1 restarts have no service-manager state to watch; use listener health only. const health = await waitForGatewayHealthyListener({ port: restartPort, attempts: restartHealthAttempts, @@ -391,6 +392,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi }); if (!health.healthy && health.staleGatewayPids.length > 0) { + // On Windows service restarts can leave stale listeners behind; kill verified stale + // Gateway pids once, restart again, then re-run the same health proof. const staleMsg = `Found stale gateway process(es): ${health.staleGatewayPids.join(", ")}.`; warnings.push(staleMsg); if (!json) { diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 561ddf05dbb8..f43e015276d5 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -1,3 +1,4 @@ +// Gateway status probe helper used by `gateway status` service diagnostics. import type { OpenClawConfig } from "../../config/types.js"; import type { GatewayProbeResult } from "../../gateway/probe.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -44,6 +45,7 @@ function readRuntimeVersionFromStatusPayload(payload: unknown): string | null { : null; } +/** Probe Gateway connectivity or read-capability status with optional RPC verification. */ export async function probeGatewayStatus(opts: { url: string; token?: string; diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 2526fe5084a1..5fb102dd836b 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -1,3 +1,4 @@ +// Gateway service command registration shared by `gateway` and legacy `daemon` CLIs. import type { Command } from "commander"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { inheritOptionFromParent } from "../command-options.js"; @@ -53,6 +54,7 @@ function resolveRestartOptions(cmdOpts: DaemonLifecycleOptions, command?: Comman }; } +/** Attach Gateway service status/install/lifecycle subcommands to a parent command. */ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescription?: string }) { parent .command("status") diff --git a/src/cli/daemon-cli/register.ts b/src/cli/daemon-cli/register.ts index 5eef3747cb2f..dc222f214b58 100644 --- a/src/cli/daemon-cli/register.ts +++ b/src/cli/daemon-cli/register.ts @@ -1,8 +1,10 @@ +// Legacy `daemon` command registration, backed by the same Gateway service commands. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { addGatewayServiceCommands } from "./register-service-commands.js"; +/** Register the legacy daemon command group. */ export function registerDaemonCli(program: Command) { const daemon = program .command("daemon") diff --git a/src/cli/daemon-cli/response.ts b/src/cli/daemon-cli/response.ts index ed7777e46043..4cbebd12904e 100644 --- a/src/cli/daemon-cli/response.ts +++ b/src/cli/daemon-cli/response.ts @@ -1,3 +1,4 @@ +// JSON/text response helpers for Gateway service lifecycle commands. import { Writable } from "node:stream"; import type { GatewayService } from "../../daemon/service.js"; import { @@ -8,8 +9,10 @@ import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailab import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; +/** Gateway service action emitted by lifecycle commands. */ export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart"; +/** Stable hint category for machine-readable daemon command output. */ export type DaemonHintKind = | "install" | "container-restart" @@ -19,11 +22,13 @@ export type DaemonHintKind = | "wsl-systemd" | "generic"; +/** Classified daemon recovery hint item. */ export type DaemonHintItem = { kind: DaemonHintKind; text: string; }; +/** Machine-readable response shape for service lifecycle commands. */ export type DaemonActionResponse = { ok: boolean; action: DaemonAction; @@ -74,6 +79,7 @@ function classifyDaemonHintText(text: string): DaemonHintKind { return "generic"; } +/** Classify plain-text hints for JSON daemon responses. */ export function buildDaemonHintItems(hints: string[] | undefined): DaemonHintItem[] | undefined { if (!hints?.length) { return undefined; @@ -81,6 +87,7 @@ export function buildDaemonHintItems(hints: string[] | undefined): DaemonHintIte return hints.map((text) => ({ kind: classifyDaemonHintText(text), text })); } +/** Build the service metadata snapshot embedded in JSON action responses. */ export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) { return { label: service.label, @@ -90,6 +97,7 @@ export function buildDaemonServiceSnapshot(service: GatewayService, loaded: bool }; } +/** Writable sink used when JSON output should suppress service command stdout. */ export function createNullWriter(): Writable { return new Writable({ write(_chunk, _encoding, callback) { @@ -98,6 +106,7 @@ export function createNullWriter(): Writable { }); } +/** Create stdout/warning/emit/fail helpers for one daemon lifecycle action. */ export function createDaemonActionContext(params: { action: DaemonAction; json: boolean }): { stdout: Writable; warnings: string[]; @@ -149,6 +158,7 @@ async function buildInstallFailureHints(error: unknown): Promise void, env: NodeJS.ProcessEnv = process.env, @@ -39,6 +42,7 @@ export function failIfNixDaemonInstallMode( return true; } +/** Build terminal style helpers for status output with no-color fallback. */ export function createCliStatusTextStyles() { const rich = isRich(); return { @@ -52,6 +56,7 @@ export function createCliStatusTextStyles() { }; } +/** Pick the color function for a runtime status label. */ export function resolveRuntimeStatusColor(status: string | undefined): (value: string) => string { const runtimeStatus = status ?? "unknown"; return runtimeStatus === "running" @@ -63,6 +68,7 @@ export function resolveRuntimeStatusColor(status: string | undefined): (value: s : theme.warn; } +/** Extract `--port` from service ProgramArguments. */ export function parsePortFromArgs(programArguments: string[] | undefined): number | null { if (!programArguments?.length) { return null; @@ -87,6 +93,7 @@ export function parsePortFromArgs(programArguments: string[] | undefined): numbe return null; } +/** Pick the best local probe host for a configured Gateway bind mode. */ export function pickProbeHostForBind( bindMode: string, tailnetIPv4: string | undefined, @@ -115,6 +122,7 @@ const SAFE_DAEMON_ENV_KEYS = [ "OPENCLAW_NIX_MODE", ]; +/** Keep only daemon env keys safe to print in diagnostics. */ export function filterDaemonEnv(env: Record | undefined): Record { if (!env) { return {}; @@ -130,11 +138,13 @@ export function filterDaemonEnv(env: Record | undefined): Record return filtered; } +/** Format safe daemon env entries for status output. */ export function safeDaemonEnv(env: Record | undefined): string[] { const filtered = filterDaemonEnv(env); return Object.entries(filtered).map(([key, value]) => `${key}=${value}`); } +/** Normalize listener address strings from platform socket tools. */ export function normalizeListenerAddress(raw: string): string { let value = raw.trim(); if (!value) { @@ -145,6 +155,7 @@ export function normalizeListenerAddress(raw: string): string { return value.trim(); } +/** Render platform-specific hints for missing/stopped Gateway runtimes. */ export function renderRuntimeHints( runtime: { missingUnit?: boolean; missingSupervision?: boolean; status?: string } | undefined, env: NodeJS.ProcessEnv = process.env, @@ -186,6 +197,7 @@ export function renderRuntimeHints( return hints; } +/** Render install/start hints for the current service platform/container context. */ export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] { const profile = env.OPENCLAW_PROFILE; const container = resolveDaemonContainerContext(env); @@ -202,6 +214,7 @@ export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process. return [`Restart the container or the service that manages it for ${container}.`]; } +/** Drop generic systemd hints when a container-specific hint is clearer. */ export function filterContainerGenericHints( hints: string[], env: NodeJS.ProcessEnv = process.env, diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index 1c813b7855df..f1d80b3b623f 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -1,3 +1,4 @@ +// Start-time service repair: rebuilds stale service definitions before starting Gateway. import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; @@ -14,6 +15,7 @@ import { formatGatewayServiceStartRepairIssues } from "../../daemon/service.js"; import { defaultRuntime } from "../../runtime.js"; import { mergeInstallInvocationEnv } from "./install.js"; +/** Repair a loaded but stale Gateway service definition and report the start result. */ export async function repairLoadedGatewayServiceForStart(params: { service: GatewayService; state: GatewayServiceState; diff --git a/src/cli/daemon-cli/status.runtime.ts b/src/cli/daemon-cli/status.runtime.ts index dc051fa1e787..c931f30e1374 100644 --- a/src/cli/daemon-cli/status.runtime.ts +++ b/src/cli/daemon-cli/status.runtime.ts @@ -1 +1,2 @@ +// Lazy runtime barrel for Gateway service status command implementation. export { runDaemonStatus } from "./status.js"; diff --git a/src/cli/daemon-cli/status.ts b/src/cli/daemon-cli/status.ts index 79453a36f9a1..c619193c275b 100644 --- a/src/cli/daemon-cli/status.ts +++ b/src/cli/daemon-cli/status.ts @@ -1,9 +1,11 @@ +// Gateway service status command entrypoint: gathers status, prints it, and handles probe failures. import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; import { defaultRuntime } from "../../runtime.js"; import { gatherDaemonStatus } from "./status.gather.js"; import { printDaemonStatus } from "./status.print.js"; import type { DaemonStatusOptions } from "./types.js"; +/** Run Gateway status diagnostics and apply --require-rpc exit behavior. */ export async function runDaemonStatus(opts: DaemonStatusOptions) { try { if (opts.requireRpc && !opts.probe) { diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 846d2257b509..23f271e8c738 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -1,5 +1,7 @@ +// Shared option types for Gateway service CLI commands. import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; +/** RPC probe options accepted by Gateway service status commands. */ export type GatewayRpcOpts = { url?: string; token?: string; @@ -8,6 +10,7 @@ export type GatewayRpcOpts = { json?: boolean; }; +/** Full option bag for Gateway service status. */ export type DaemonStatusOptions = { rpc: GatewayRpcOpts; probe: boolean; @@ -15,6 +18,7 @@ export type DaemonStatusOptions = { json: boolean; } & FindExtraGatewayServicesOptions; +/** Options for installing or rewriting the Gateway service. */ export type DaemonInstallOptions = { port?: string | number; runtime?: string; @@ -24,6 +28,7 @@ export type DaemonInstallOptions = { json?: boolean; }; +/** Options shared by service start/stop/restart/uninstall commands. */ export type DaemonLifecycleOptions = { json?: boolean; force?: boolean; diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index a92aba042a91..28399008dceb 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -1,3 +1,4 @@ +// Directory CLI for chat-channel identity lookup: self, peers, groups, and group members. import { normalizeOptionalString, normalizeStringifiedOptionalString, @@ -60,6 +61,7 @@ function printDirectoryList(params: { ); } +/** Register directory lookup commands and shared channel/account resolution. */ export function registerDirectoryCli(program: Command) { const directory = program .command("directory") @@ -112,12 +114,15 @@ export function registerDirectoryCli(program: Command) { : null; if (resolvedExplicit?.configChanged) { cfg = resolvedExplicit.cfg; + // Installing an explicit channel can update plugin records; commit before directory calls + // so subsequent registry reads see the channel the user just selected. const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, }); cfg = committed.config; } else if (autoEnabled.changes.length > 0) { + // Auto-enable changes are config-only and must be persisted before later CLI invocations. await replaceConfigFile({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, diff --git a/src/cli/error-format.ts b/src/cli/error-format.ts index 3a01fdc04abd..b4f77e9ce987 100644 --- a/src/cli/error-format.ts +++ b/src/cli/error-format.ts @@ -1,3 +1,4 @@ +// Reusable CLI error-message formatters that keep recovery hints consistent across commands. import { formatCliCommand } from "./command-format.js"; const DEFAULT_GATEWAY_PORT_EXAMPLE = 18789; @@ -6,10 +7,12 @@ function formatInlineCliCommand(command: string): string { return `\`${formatCliCommand(command)}\``; } +/** Explain the valid TCP port range with a concrete example. */ export function formatPortRangeHint(example = DEFAULT_GATEWAY_PORT_EXAMPLE): string { return `Use a port number from 1 to 65535, for example ${example}.`; } +/** Format an invalid CLI port option using the shared port-range hint. */ export function formatInvalidPortOption( option: string, example = DEFAULT_GATEWAY_PORT_EXAMPLE, @@ -17,6 +20,7 @@ export function formatInvalidPortOption( return `Invalid ${option}. ${formatPortRangeHint(example)}`; } +/** Explain a bad configured port and include the equivalent CLI override. */ export function formatInvalidConfigPort( path: string, example = DEFAULT_GATEWAY_PORT_EXAMPLE, @@ -24,6 +28,7 @@ export function formatInvalidConfigPort( return `Invalid ${path} in config. Set ${path} to a number from 1 to 65535, or pass --port ${example}.`; } +/** Format the standard missing-channel error plus channel-list recovery command. */ export function formatUnknownChannelMessage(params: { channel: string; listCommand?: string; @@ -36,6 +41,7 @@ export function formatUnknownChannelMessage(params: { )} to see configured and installable channels.`; } +/** Format a channel capability miss with the inspection command for that channel. */ export function formatUnsupportedChannelActionMessage(params: { channel: string; action: string; @@ -48,6 +54,7 @@ export function formatUnsupportedChannelActionMessage(params: { )} to inspect supported actions.`; } +/** Format strict JSON parsing failures without exposing long untrusted input verbatim. */ export function formatStrictJsonParseFailure(params: { value: string; cause: unknown }): string { const rawCause = params.cause instanceof Error ? params.cause.message : String(params.cause); const cause = rawCause.trim().replace(/[.。]+$/u, ""); @@ -63,6 +70,7 @@ export function formatStrictJsonParseFailure(params: { value: string; cause: unk ].join(" "); } +/** Normalize gateway failure text and attach the deep-status recovery command. */ export function formatGatewayCommandFailure(params: { action: string; error: unknown; @@ -82,6 +90,7 @@ export function formatGatewayCommandFailure(params: { )} to inspect the active Gateway.`; } +/** Format a generic lookup miss with the list command that can recover it. */ export function formatLookupMiss(params: { noun: string; value: string; @@ -94,6 +103,7 @@ export function formatLookupMiss(params: { )} to see recent ${valueLabel}s.`; } +/** Format a plugin lookup miss with optional ClawHub search guidance. */ export function formatMissingPluginMessage(params: { id: string; listCommand?: string; diff --git a/src/cli/gateway-run-argv.ts b/src/cli/gateway-run-argv.ts index 394c8cbac6a6..fb26830c34d8 100644 --- a/src/cli/gateway-run-argv.ts +++ b/src/cli/gateway-run-argv.ts @@ -1,3 +1,4 @@ +// Fast-path argv parser for `openclaw gateway ...` without full Commander registration. import { isValueToken } from "../infra/cli-root-options.js"; const GATEWAY_RUN_VALUE_FLAGS = new Set([ @@ -26,6 +27,7 @@ const GATEWAY_RUN_BOOLEAN_FLAGS = new Set([ "--raw-stream", ]); +/** Return how many argv tokens a gateway-run option consumes, or 0 when not recognized. */ export function consumeGatewayRunOptionToken(args: ReadonlyArray, index: number): number { const arg = args[index]; if (!arg || arg === "--" || !arg.startsWith("-")) { @@ -45,6 +47,7 @@ export function consumeGatewayRunOptionToken(args: ReadonlyArray, index: return isValueToken(args[index + 1]) ? 2 : 0; } +/** Return how many root fast-path tokens are consumed before the `gateway` command. */ export function consumeGatewayFastPathRootOptionToken( args: ReadonlyArray, index: number, @@ -65,6 +68,7 @@ export function consumeGatewayFastPathRootOptionToken( return 0; } +/** Resolve the gateway command path from raw argv for catalog/policy lookups. */ export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | null { const args = argv.slice(2); let sawGateway = false; diff --git a/src/cli/gateway-secret-options.ts b/src/cli/gateway-secret-options.ts index 4be7b394f259..4b07b3bcb518 100644 --- a/src/cli/gateway-secret-options.ts +++ b/src/cli/gateway-secret-options.ts @@ -1,3 +1,4 @@ +// Gateway auth option parser: supports direct values and file-backed secrets with CLI warnings. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { readSecretFromFile } from "../acp/secret-file.js"; import { defaultRuntime } from "../runtime.js"; @@ -26,6 +27,7 @@ function warnGatewaySecretCliFlag(flag: "--token" | "--password"): void { ); } +/** Normalize gateway token/password options and reject ambiguous direct+file pairs. */ export function resolveGatewayAuthOptions(opts: { token?: unknown; tokenFile?: unknown; diff --git a/src/cli/help-format.ts b/src/cli/help-format.ts index bb2b7238d2b7..72c5b5691a52 100644 --- a/src/cli/help-format.ts +++ b/src/cli/help-format.ts @@ -1,5 +1,7 @@ +// Small help-text formatter shared by command registrations. import { theme } from "../../packages/terminal-core/src/theme.js"; +/** Command plus short description tuple used in help epilogues. */ export type HelpExample = readonly [command: string, description: string]; function formatHelpExample(command: string, description: string): string { @@ -13,6 +15,7 @@ function formatHelpExampleLine(command: string, description: string): string { return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`; } +/** Render help examples in stacked or inline comment style. */ export function formatHelpExamples(examples: ReadonlyArray, inline = false): string { const formatter = inline ? formatHelpExampleLine : formatHelpExample; return examples.map(([command, description]) => formatter(command, description)).join("\n"); diff --git a/src/cli/install-spec.ts b/src/cli/install-spec.ts index b4d61a811000..c5cb088e87e3 100644 --- a/src/cli/install-spec.ts +++ b/src/cli/install-spec.ts @@ -1,5 +1,7 @@ +// Install spec classifier used before package resolution or local path expansion. import path from "node:path"; +/** Detect specs that should be interpreted as local file/path installs. */ export function looksLikeLocalInstallSpec(spec: string, knownSuffixes: readonly string[]): boolean { return ( spec.startsWith(".") || diff --git a/src/cli/message-secret-scope.ts b/src/cli/message-secret-scope.ts index 938abb2f2948..7e4408165be2 100644 --- a/src/cli/message-secret-scope.ts +++ b/src/cli/message-secret-scope.ts @@ -1,3 +1,4 @@ +// Scope resolver for message command secrets: infer channel/account from flags and targets. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeAccountId } from "../routing/session-key.js"; import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; @@ -50,6 +51,7 @@ function resolveScopedAccountId(value: unknown): string | undefined { return normalizeAccountId(trimmed); } +/** Resolve the narrowest channel/account secret scope visible from message CLI inputs. */ export function resolveMessageSecretScope(params: { channel?: unknown; target?: unknown; diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index a4de73b187d0..b785164dd6cc 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -1 +1,2 @@ +// Public barrel for node-management CLI registration. export { registerNodesCli } from "./nodes-cli/register.js"; diff --git a/src/cli/nodes-cli/cli-utils.ts b/src/cli/nodes-cli/cli-utils.ts index 894babf01312..4f5106419070 100644 --- a/src/cli/nodes-cli/cli-utils.ts +++ b/src/cli/nodes-cli/cli-utils.ts @@ -1,8 +1,10 @@ +// Node CLI runtime helpers: terminal theme adaptation and standard error handling. import { isRich, theme } from "../../../packages/terminal-core/src/theme.js"; import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { unauthorizedHintForMessage } from "./rpc.js"; +/** Return color helpers that degrade to plain text in non-rich terminals. */ export function getNodesTheme() { const rich = isRich(); const color = (fn: (value: string) => string) => (value: string) => (rich ? fn(value) : value); @@ -16,6 +18,7 @@ export function getNodesTheme() { }; } +/** Run a node CLI action with standard failure text and authorization hints. */ export function runNodesCommand(label: string, action: () => Promise) { return runCommandWithRuntime(defaultRuntime, action, (err) => { const message = String(err); diff --git a/src/cli/nodes-cli/format.ts b/src/cli/nodes-cli/format.ts index 530de28ca296..80483b68e8b2 100644 --- a/src/cli/nodes-cli/format.ts +++ b/src/cli/nodes-cli/format.ts @@ -1,7 +1,9 @@ +// Formatting and parse re-exports for node list/pairing CLI output. import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce"; export { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js"; +/** Format node permission maps as a stable `[permission=yes|no]` label. */ export function formatPermissions(raw: unknown) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return null; diff --git a/src/cli/nodes-cli/pairing-render.ts b/src/cli/nodes-cli/pairing-render.ts index 65e0400dcea6..b8fda2b40475 100644 --- a/src/cli/nodes-cli/pairing-render.ts +++ b/src/cli/nodes-cli/pairing-render.ts @@ -1,8 +1,10 @@ +// Shared renderer for pending node pairing request tables. import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { renderTable } from "../../../packages/terminal-core/src/table.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import type { PendingRequest } from "./types.js"; +/** Render pending pairing requests with sanitized labels and relative request age. */ export function renderPendingPairingRequestsTable(params: { pending: PendingRequest[]; now: number; diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index d6da65e58586..945b176f9157 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -1,3 +1,4 @@ +// Node camera commands: list devices, capture photos, and capture short clips through node.invoke. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -42,6 +43,7 @@ function getGatewayInvokePayload(raw: unknown): unknown { : undefined; } +/** Register node camera list/snap/clip commands. */ export function registerNodesCameraCommands(nodes: Command) { const camera = nodes.command("camera").description("Capture camera media from a paired node"); diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 115ed47226c3..1115cc411db9 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -1,3 +1,4 @@ +// Generic node.invoke command with shell-exec commands intentionally blocked. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -16,6 +17,7 @@ import type { NodesRpcOpts } from "./types.js"; const BLOCKED_NODE_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]); +/** Register direct node command invocation. */ export function registerNodesInvokeCommands(nodes: Command) { nodesCallOpts( nodes diff --git a/src/cli/nodes-cli/register.location.ts b/src/cli/nodes-cli/register.location.ts index 3107cd1f852b..f3ef787e281f 100644 --- a/src/cli/nodes-cli/register.location.ts +++ b/src/cli/nodes-cli/register.location.ts @@ -1,3 +1,4 @@ +// Node location commands: invokes location.get on a paired node and formats the location payload. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; @@ -12,6 +13,7 @@ import { } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +/** Register node location lookup commands. */ export function registerNodesLocationCommands(nodes: Command) { const location = nodes.command("location").description("Fetch location from a paired node"); diff --git a/src/cli/nodes-cli/register.notify.ts b/src/cli/nodes-cli/register.notify.ts index e1486355f5ba..1d5f4b78ac51 100644 --- a/src/cli/nodes-cli/register.notify.ts +++ b/src/cli/nodes-cli/register.notify.ts @@ -1,3 +1,4 @@ +// Local notification command for paired nodes. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; @@ -11,6 +12,7 @@ import { } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +/** Register node notification command. */ export function registerNodesNotifyCommand(nodes: Command) { nodesCallOpts( nodes diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 3ca0d9ef35c2..e5a3a19a6599 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,3 +1,4 @@ +// Node pairing commands: list, approve, reject, remove, and rename paired nodes. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { getTerminalTableWidth } from "../../../packages/terminal-core/src/table.js"; @@ -57,12 +58,14 @@ async function resolveApproveScopesForRequest( if (scopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) { return scopes; } + // Older pending requests only list requested commands; derive approval scopes from them. return resolveNodePairApprovalScopes(request?.commands) as OperatorScope[]; } catch { return [...DEFAULT_NODE_PAIR_APPROVE_SCOPES]; } } +/** Register node pairing management commands. */ export function registerNodesPairingCommands(nodes: Command) { nodesCallOpts( nodes diff --git a/src/cli/nodes-cli/register.push.ts b/src/cli/nodes-cli/register.push.ts index db0fcfa7cc70..7160b1145ae0 100644 --- a/src/cli/nodes-cli/register.push.ts +++ b/src/cli/nodes-cli/register.push.ts @@ -1,3 +1,4 @@ +// APNs test-push command for iOS nodes. import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -26,6 +27,7 @@ function normalizeEnvironment(value: unknown): "sandbox" | "production" | null { return null; } +/** Register the node push-test command. */ export function registerNodesPushCommand(nodes: Command) { nodesCallOpts( nodes diff --git a/src/cli/nodes-cli/register.screen.ts b/src/cli/nodes-cli/register.screen.ts index 980121c747a6..ce386334ee4d 100644 --- a/src/cli/nodes-cli/register.screen.ts +++ b/src/cli/nodes-cli/register.screen.ts @@ -1,3 +1,4 @@ +// Node screen recording command: invokes screen.record and writes returned media locally. import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { shortenHomePath } from "../../utils.js"; @@ -19,6 +20,7 @@ import { } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +/** Register node screen recording commands. */ export function registerNodesScreenCommands(nodes: Command) { const screen = nodes .command("screen") diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index b77205e22789..b45f5096b8f4 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,3 +1,4 @@ +// Node status/list/describe commands and paired-node display formatting. import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -46,6 +47,7 @@ function resolveNodeVersions(node: { return { core: undefined, ui: undefined }; } const platform = normalizeOptionalLowercaseString(node.platform) ?? ""; + // Legacy nodes reported one version field; headless hosts use it as core, mobile nodes as UI. const headless = platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows"; return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy }; @@ -180,6 +182,7 @@ function sanitizePairedNodeForListJson(node: PairedNodeListRow): Omit; } +/** Register node status, describe, and paired-node list commands. */ export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 596728652d27..4f8f6a02e0ca 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -1,3 +1,4 @@ +// Root `nodes` command registration: wires node status, pairing, invoke, media, and plugin extensions. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -12,6 +13,7 @@ import { registerNodesPushCommand } from "./register.push.js"; import { registerNodesScreenCommands } from "./register.screen.js"; import { registerNodesStatusCommands } from "./register.status.js"; +/** Register the `nodes` command group and lazy plugin-provided node commands. */ export async function registerNodesCli(program: Command, argv: readonly string[] = process.argv) { const nodes = program .command("nodes") diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index 6fa1065ce1a2..a17e33382182 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -1,3 +1,4 @@ +// Gateway RPC helpers for node CLI commands, including lazy runtime loading and option parsing. import { randomUUID } from "node:crypto"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; @@ -22,6 +23,7 @@ async function loadNodesCliRpcRuntime(): Promise { return nodesCliRpcRuntimeLoader.load(); } +/** Attach shared Gateway connection/json options to a node command. */ export const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => cmd .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") @@ -29,6 +31,7 @@ export const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) = .option("--timeout ", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000)) .option("--json", "Output JSON", false); +/** Call a Gateway method through the lazily loaded node CLI RPC runtime. */ export const callGatewayCli = async ( method: string, opts: NodesRpcOpts, @@ -39,6 +42,7 @@ export const callGatewayCli = async ( return await runtime.callGatewayCliRuntime(method, opts, params, callOpts); }; +/** Call pairing approval methods with explicit operator scopes. */ export const callNodePairApprovalGatewayCli = async ( method: "node.pair.list" | "node.pair.approve", opts: NodesRpcOpts, @@ -49,6 +53,7 @@ export const callNodePairApprovalGatewayCli = async ( return await runtime.callNodePairApprovalGatewayCliRuntime(method, opts, params, callOpts); }; +/** Build a node.invoke payload with an idempotency key and optional timeout. */ export function buildNodeInvokeParams(params: { nodeId: string; command: string; @@ -72,6 +77,7 @@ function hasOptionalValue(value: unknown): boolean { return value !== undefined && value !== null && value !== ""; } +/** Parse an optional positive integer node CLI flag. */ export function parseOptionalNodePositiveInteger(value: unknown, flag: string): number | undefined { if (!hasOptionalValue(value)) { return undefined; @@ -83,6 +89,7 @@ export function parseOptionalNodePositiveInteger(value: unknown, flag: string): return parsed; } +/** Parse an optional non-negative integer node CLI flag. */ export function parseOptionalNodeNonNegativeInteger( value: unknown, flag: string, @@ -97,6 +104,7 @@ export function parseOptionalNodeNonNegativeInteger( return parsed; } +/** Parse an optional finite number node CLI flag with optional bounds. */ export function parseOptionalNodeFiniteNumber( value: unknown, flag: string, @@ -125,6 +133,7 @@ export function parseOptionalNodeFiniteNumber( return parsed; } +/** Return the local-development hint for known unsigned Peekaboo bridge authorization failures. */ export function unauthorizedHintForMessage(message: string): string | null { const haystack = normalizeLowercaseStringOrEmpty(message); if ( @@ -141,10 +150,12 @@ export function unauthorizedHintForMessage(message: string): string | null { return null; } +/** Resolve a node query to a node id via live node list or paired-node fallback. */ export async function resolveNodeId(opts: NodesRpcOpts, query: string) { return (await resolveNode(opts, query)).nodeId; } +/** Resolve a node query to the best available node record. */ export async function resolveNode(opts: NodesRpcOpts, query: string): Promise { let nodes: NodeListNode[]; try { diff --git a/src/cli/nodes-cli/types.ts b/src/cli/nodes-cli/types.ts index c93a197526c5..6562d00d3e72 100644 --- a/src/cli/nodes-cli/types.ts +++ b/src/cli/nodes-cli/types.ts @@ -1,3 +1,5 @@ +// Shared option/result types for node CLI command modules. +/** Common Gateway/node options consumed across node CLI subcommands. */ export type NodesRpcOpts = { url?: string; token?: string; @@ -43,4 +45,5 @@ export type NodesRpcOpts = { audio?: boolean; }; +/** Node list, paired-node, and pending-request payload types from shared parsers. */ export type { NodeListNode, PairedNode, PendingRequest } from "../../shared/node-list-types.js"; diff --git a/src/cli/outbound-send-deps.ts b/src/cli/outbound-send-deps.ts index b4a116d64f18..bfbe4530466e 100644 --- a/src/cli/outbound-send-deps.ts +++ b/src/cli/outbound-send-deps.ts @@ -1,9 +1,11 @@ +// CLI adapter for outbound sending dependencies used by message-style commands. import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; import type { CliDeps } from "./deps.types.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; export type { CliDeps } from "./deps.types.js"; +/** Convert the broad CLI dependency bundle into the narrow outbound-send dependency shape. */ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } diff --git a/src/cli/parse-bytes.ts b/src/cli/parse-bytes.ts index 27858a112e70..2ebdb05f96eb 100644 --- a/src/cli/parse-bytes.ts +++ b/src/cli/parse-bytes.ts @@ -1,3 +1,4 @@ +// Byte-size parser shared by CLI flags and config schemas. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -27,6 +28,7 @@ function invalidByteSize(raw: string, reason?: string): Error { return new Error(`${prefix} Use values like 512kb, 10mb, 1gb, or 500.`); } +/** Parse a non-negative byte size with optional binary units like kb, mb, gb, or tb. */ export function parseByteSize(raw: string, opts?: BytesParseOptions): number { const trimmed = normalizeLowercaseStringOrEmpty(normalizeOptionalString(raw) ?? ""); if (!trimmed) { diff --git a/src/cli/parse-timeout.ts b/src/cli/parse-timeout.ts index ed3136393c70..31dcca80e34d 100644 --- a/src/cli/parse-timeout.ts +++ b/src/cli/parse-timeout.ts @@ -1,5 +1,7 @@ +// Shared CLI timeout parsers for millisecond flags and config-backed fallbacks. import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +/** Parse a positive millisecond timeout, returning undefined for absent or invalid input. */ export function parseTimeoutMs(raw: unknown): number | undefined { if (raw === undefined || raw === null) { return undefined; @@ -26,6 +28,7 @@ function invalidTimeout(value?: string): Error { ); } +/** Parse a positive timeout or return the supplied fallback for missing values. */ export function parseTimeoutMsWithFallback( raw: unknown, fallbackMs: number, diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index 31b49e66e736..ae2e2d4e766c 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -1,3 +1,5 @@ +// Pre-action policy for `plugins install`: decide whether an install may bypass invalid +// config so plugin-owned doctor/recovery code can repair broken plugin state. import fs from "node:fs"; import path from "node:path"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; @@ -15,6 +17,7 @@ import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-com type PluginInstallInvalidConfigPolicy = "deny" | "allow-plugin-recovery"; +/** Parsed install request plus recovery metadata needed by CLI pre-action config policy. */ export type PluginInstallRequestContext = { rawSpec: string; normalizedSpec: string; @@ -180,6 +183,7 @@ function resolvePluginInstallArgvRequest(commandPath: string[], argv: string[]) return rawSpec ? { rawSpec, marketplace } : null; } +/** Resolve install metadata from the raw spec before Commander action handlers mutate config. */ export function resolvePluginInstallRequestContext(params: { rawSpec: string; marketplace?: string; @@ -231,6 +235,7 @@ export function resolvePluginInstallRequestContext(params: { }; } +/** Recover the plugin install request from Commander state plus raw argv fallback parsing. */ export function resolvePluginInstallPreactionRequest(params: { actionCommand: Command; commandPath: string[]; @@ -256,6 +261,7 @@ export function resolvePluginInstallPreactionRequest(params: { return request.ok ? request.request : null; } +/** Decide whether invalid config should block a command before plugin recovery can run. */ export function resolvePluginInstallInvalidConfigPolicy( request: PluginInstallRequestContext | null, ): PluginInstallInvalidConfigPolicy { diff --git a/src/cli/plugin-registry-loader.ts b/src/cli/plugin-registry-loader.ts index 66e7325fb93e..05b89b768666 100644 --- a/src/cli/plugin-registry-loader.ts +++ b/src/cli/plugin-registry-loader.ts @@ -1,3 +1,4 @@ +// Lazy plugin-registry loader for CLI commands that need plugin command/capability metadata. import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loggingState } from "../logging/state.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -9,10 +10,12 @@ function loadPluginRegistryModule() { return pluginRegistryModuleLoader.load(); } +/** Plugin registry loading scope selected by command policy. */ export type CliPluginRegistryLoadPolicy = { scope: CliPluginRegistryScope; }; +/** Load the CLI plugin registry and optionally route activation logs to stderr. */ export async function ensureCliPluginRegistryLoaded(params: { scope: CliPluginRegistryScope; routeLogsToStderr?: boolean; diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index cc74103d04a3..356d03f2aacc 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -1,3 +1,5 @@ +// Runtime implementations for `openclaw plugins` subcommands. Heavy plugin modules stay +// lazy-loaded so the base CLI can start without activating the plugin registry. import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { @@ -176,6 +178,7 @@ function collectConfiguredRuntimePluginWarnings(params: { }); } +/** Enable a plugin in config and refresh the registry snapshot for the changed policy. */ export async function runPluginsEnableCommand(idInput: string): Promise { let id = idInput; assertConfigWriteAllowedInCurrentMode(); @@ -220,6 +223,7 @@ export async function runPluginsEnableCommand(idInput: string): Promise { ); } +/** Disable a plugin in config and refresh the registry snapshot for the changed policy. */ export async function runPluginsDisableCommand(idInput: string): Promise { let id = idInput; assertConfigWriteAllowedInCurrentMode(); @@ -267,6 +271,7 @@ export async function runPluginsInstallAction( ); } +/** Inspect or refresh the persisted plugin registry index. */ export async function runPluginsRegistryCommand(opts: PluginRegistryOptions): Promise { const { inspectPluginRegistry, refreshPluginRegistry } = await import("../plugins/plugin-registry.js"); @@ -319,6 +324,7 @@ export async function runPluginsRegistryCommand(opts: PluginRegistryOptions): Pr defaultRuntime.log(lines.join("\n")); } +/** Print plugin install-tree, compatibility, and plugin-owned config diagnostics. */ export async function runPluginsDoctorCommand(): Promise { const { buildPluginCompatibilityNotices, @@ -434,6 +440,7 @@ export async function runPluginsDoctorCommand(): Promise { defaultRuntime.log(lines.join("\n")); } +/** List plugins from a configured marketplace manifest. */ export async function runPluginMarketplaceListCommand( source: string, opts: PluginMarketplaceListOptions, diff --git a/src/cli/plugins-inspect-command.ts b/src/cli/plugins-inspect-command.ts index a8eb1c59cb86..e30c9e4a0d54 100644 --- a/src/cli/plugins-inspect-command.ts +++ b/src/cli/plugins-inspect-command.ts @@ -1,3 +1,4 @@ +// `openclaw plugins inspect`: renders plugin registry shape, capabilities, policy, diagnostics, and install records. import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { getRuntimeConfig } from "../config/config.js"; @@ -11,6 +12,7 @@ import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatMissingPluginMessage } from "./error-format.js"; import { quietPluginJsonLogger } from "./plugins-command-helpers.js"; +/** Options accepted by `openclaw plugins inspect`. */ export type PluginInspectOptions = { json?: boolean; all?: boolean; @@ -111,6 +113,7 @@ function formatInstallLines(install: PluginInstallRecord | undefined): string[] return lines; } +/** Inspect one plugin or all plugins using either snapshot-only or runtime-loaded registry data. */ export async function runPluginsInspectCommand( id: string | undefined, opts: PluginInspectOptions, diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 0345d8c5300f..690625fc9a5f 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -1,7 +1,9 @@ +// `openclaw plugins list`: builds registry reports and defers terminal-only formatting modules. import { getRuntimeConfig } from "../config/config.js"; import type { PluginLogger } from "../plugins/types.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +/** Options accepted by the plugin list command. */ export type PluginsListOptions = { json?: boolean; enabled?: boolean; @@ -35,6 +37,7 @@ async function loadHumanListModules() { }; } +/** Render installed plugin discovery state as JSON, compact table, or verbose text. */ export async function runPluginsListCommand( opts: PluginsListOptions, runtime: RuntimeEnv = defaultRuntime, diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts index e4471194282a..9b6b59f6e780 100644 --- a/src/cli/plugins-registry-refresh.ts +++ b/src/cli/plugins-registry-refresh.ts @@ -1,3 +1,4 @@ +// Registry refresh helper shared by plugin config mutations that need post-write discovery repair. import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js"; @@ -5,10 +6,12 @@ import type { InstalledPluginIndexRefreshReason } from "../plugins/installed-plu import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { refreshPluginRegistry } from "../plugins/plugin-registry.js"; +/** Optional warning sink for best-effort registry/cache refresh failures. */ export type PluginRegistryRefreshLogger = { warn?: (message: string) => void; }; +/** Refresh persisted plugin registry and clear runtime discovery after a config mutation. */ export async function refreshPluginRegistryAfterConfigMutation(params: { config: OpenClawConfig; reason: InstalledPluginIndexRefreshReason; diff --git a/src/cli/plugins-search-command.ts b/src/cli/plugins-search-command.ts index 9b6ce53cd6be..5aaf4f9370bf 100644 --- a/src/cli/plugins-search-command.ts +++ b/src/cli/plugins-search-command.ts @@ -1,3 +1,4 @@ +// ClawHub-backed plugin search command; queries installable plugin families and merges scores. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { @@ -8,6 +9,7 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +/** Options accepted by `openclaw plugins search`. */ export type PluginsSearchOptions = { json?: boolean; limit?: number; @@ -66,6 +68,7 @@ function formatPackageSearchLine(entry: ClawHubPackageSearchResult): string { return `${pkg.name} ${theme.muted(flags.join(" | "))}${summary}\n ${theme.muted(`Install: openclaw plugins install clawhub:${pkg.name}`)}`; } +/** Search ClawHub for installable plugins and write JSON or terminal output. */ export async function runPluginsSearchCommand( queryParts: string[] | string, opts: PluginsSearchOptions = {}, diff --git a/src/cli/program.nodes-test-helpers.ts b/src/cli/program.nodes-test-helpers.ts index 428c7bf79161..24c96dda281e 100644 --- a/src/cli/program.nodes-test-helpers.ts +++ b/src/cli/program.nodes-test-helpers.ts @@ -1,3 +1,5 @@ +// Test fixture helpers for CLI node-list command coverage. +/** Canonical connected iOS node fixture used by CLI node tests. */ export const IOS_NODE = { nodeId: "ios-node", displayName: "iOS Node", @@ -5,6 +7,7 @@ export const IOS_NODE = { connected: true, } as const; +/** Build a stable one-node response payload with an overridable timestamp. */ export function createIosNodeListResponse(ts: number = Date.now()) { return { ts, diff --git a/src/cli/program/action-reparse.ts b/src/cli/program/action-reparse.ts index 74251d5fe954..ad77839e33ab 100644 --- a/src/cli/program/action-reparse.ts +++ b/src/cli/program/action-reparse.ts @@ -1,3 +1,4 @@ +// Reparse support for lazy commands after their placeholder has been replaced. import type { Command } from "commander"; import { buildParseArgv } from "../argv.js"; import { resolveActionArgs, resolveCommandOptionArgs } from "./helpers.js"; @@ -11,6 +12,7 @@ function buildFallbackArgv(program: Command, actionCommand: Command | undefined) : [...parentOptionArgs, ...actionArgsList]; } +/** Rebuild argv from Commander action args and re-run parsing after lazy registration. */ export async function reparseProgramFromActionArgs( program: Command, actionArgs: unknown[], diff --git a/src/cli/program/command-descriptor-utils.ts b/src/cli/program/command-descriptor-utils.ts index df420f662947..7a4a0e2d62fa 100644 --- a/src/cli/program/command-descriptor-utils.ts +++ b/src/cli/program/command-descriptor-utils.ts @@ -1,11 +1,14 @@ +// Utilities for defining safe Commander placeholder descriptors and descriptor catalogs. import type { Command } from "commander"; import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; +/** Minimal descriptor shape used before a command is fully registered. */ export type CommandDescriptorLike = Pick; const SAFE_COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +/** Descriptor catalog plus derived name lists used by lazy command registration. */ export type CommandDescriptorCatalog = { descriptors: readonly TDescriptor[]; getDescriptors: () => readonly TDescriptor[]; @@ -14,6 +17,7 @@ export type CommandDescriptorCatalog getParentDefaultHelpCommands: () => string[]; }; +/** Normalize and validate a command descriptor name for safe Commander registration. */ export function normalizeCommandDescriptorName(name: string): string | null { const normalized = name.trim(); return SAFE_COMMAND_NAME_PATTERN.test(normalized) ? normalized : null; @@ -27,14 +31,17 @@ function assertSafeCommandDescriptorName(name: string): string { return normalized; } +/** Strip unsafe terminal content from descriptor descriptions. */ export function sanitizeCommandDescriptorDescription(description: string): string { return sanitizeForLog(description).trim(); } +/** Return descriptor names in registration order. */ export function getCommandDescriptorNames(descriptors: readonly CommandDescriptorLike[]): string[] { return descriptors.map((descriptor) => descriptor.name); } +/** Return descriptor names that should remain parent commands with subcommands. */ export function getCommandsWithSubcommands( descriptors: readonly NamedCommandDescriptor[], ): string[] { @@ -43,6 +50,7 @@ export function getCommandsWithSubcommands( .map((descriptor) => descriptor.name); } +/** Return descriptors whose parent command should show help by default. */ export function getParentDefaultHelpCommands( descriptors: readonly NamedCommandDescriptor[], ): string[] { @@ -51,6 +59,7 @@ export function getParentDefaultHelpCommands( .map((descriptor) => descriptor.name); } +/** Merge descriptor groups while keeping the first descriptor for each command name. */ export function collectUniqueCommandDescriptors( descriptorGroups: readonly (readonly TDescriptor[])[], ): TDescriptor[] { @@ -68,6 +77,7 @@ export function collectUniqueCommandDescriptors( descriptors: readonly TDescriptor[], ): CommandDescriptorCatalog { @@ -80,6 +90,7 @@ export function defineCommandDescriptorCatalog = { commandNames: readonly string[]; register: TRegister; }; +/** Import-backed command group definition. */ export type ImportedCommandGroupDefinition = { commandNames: readonly string[]; loadModule: () => Promise; register: (module: TModule, args: TRegisterArgs) => Promise | void; }; +/** Resolved group entry after descriptor lookup. */ export type ResolvedCommandGroupEntry = { placeholders: TDescriptor[]; register: TRegister; @@ -34,6 +39,7 @@ function buildDescriptorIndex( return new Map(descriptors.map((descriptor) => [descriptor.name, descriptor])); } +/** Resolve named command-group specs into descriptor-backed entries. */ export function resolveCommandGroupEntries( descriptors: readonly TDescriptor[], specs: readonly CommandGroupDescriptorSpec[], @@ -51,6 +57,7 @@ export function resolveCommandGroupEntries( descriptors: readonly NamedCommandDescriptor[], specs: readonly CommandGroupDescriptorSpec[], @@ -62,6 +69,7 @@ export function buildCommandGroupEntries( })); } +/** Define a lazy group that imports its module at registration time. */ export function defineImportedCommandGroupSpec( commandNames: readonly string[], loadModule: () => Promise, @@ -76,6 +84,7 @@ export function defineImportedCommandGroupSpec( }; } +/** Map multiple import-backed command group definitions to lazy specs. */ export function defineImportedCommandGroupSpecs( definitions: readonly ImportedCommandGroupDefinition[], ): CommandGroupDescriptorSpec<(args: TRegisterArgs) => Promise>[] { @@ -104,6 +113,7 @@ export type ImportedProgramCommandGroupDefinition< exportName: TKey; }; +/** Define a program-level command group from a module export name. */ export function defineImportedProgramCommandGroupSpec< TModule extends Record, TKey extends keyof TModule & string, @@ -117,6 +127,7 @@ export function defineImportedProgramCommandGroupSpec< ); } +/** Map program-level imported command definitions to lazy specs with export validation. */ export function defineImportedProgramCommandGroupSpecs( definitions: readonly AnyImportedProgramCommandGroupDefinition[], ): CommandGroupDescriptorSpec<(program: Command) => Promise>[] { diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 5fc7a160f775..9e331388ac63 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -1,3 +1,4 @@ +// Program command registry facade: exports core descriptors and registers core plus sub-CLIs. import type { Command } from "commander"; import { getCoreCliCommandDescriptors, @@ -17,8 +18,11 @@ export { registerCoreCliByName, registerCoreCliCommands, }; + +/** Core command registration contract re-exported for program builders and tests. */ export type { CommandRegistration }; +/** Register all root-program commands for the current argv shape. */ export function registerProgramCommands( program: Command, ctx: ProgramContext, diff --git a/src/cli/program/command-tree.ts b/src/cli/program/command-tree.ts index e2a58a19dc49..3b2142413c12 100644 --- a/src/cli/program/command-tree.ts +++ b/src/cli/program/command-tree.ts @@ -1,5 +1,7 @@ +// Commander tree mutation helpers used by lazy command replacement. import type { Command } from "commander"; +/** Remove an exact Command instance from a parent program. */ export function removeCommand(program: Command, command: Command): boolean { const commands = program.commands as Command[]; const index = commands.indexOf(command); @@ -10,6 +12,7 @@ export function removeCommand(program: Command, command: Command): boolean { return true; } +/** Remove a command by primary name or alias. */ export function removeCommandByName(program: Command, name: string): boolean { const existing = program.commands.find( (command) => command.name() === name || command.aliases().includes(name), diff --git a/src/cli/program/context.ts b/src/cli/program/context.ts index 9518d857a108..cce763fa03a4 100644 --- a/src/cli/program/context.ts +++ b/src/cli/program/context.ts @@ -1,6 +1,8 @@ +// Root program context: version plus lazily computed channel option strings for help text. import { VERSION } from "../../version.js"; import { resolveCliChannelOptions } from "../channel-options.js"; +/** Root CLI program context consumed by command registration and help rendering. */ export type ProgramContext = { programVersion: string; channelOptions: string[]; @@ -8,6 +10,7 @@ export type ProgramContext = { agentChannelOptions: string; }; +/** Create a program context that resolves channel options once on first use. */ export function createProgramContext(): ProgramContext { let cachedChannelOptions: string[] | undefined; const getChannelOptions = (): string[] => { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 6ed2f3496631..e00c9ffbd89a 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -1,6 +1,8 @@ +// Core root-command descriptor catalog used for help placeholders and lazy registration. import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js"; import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; +/** Descriptor shape for root commands owned by the core CLI. */ export type CoreCliCommandDescriptor = NamedCommandDescriptor; const coreCliCommandCatalog = defineCommandDescriptorCatalog([ @@ -113,20 +115,25 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([ }, ] as const satisfies ReadonlyArray); +/** Static root-command descriptors for the core CLI surface. */ export const CORE_CLI_COMMAND_DESCRIPTORS = coreCliCommandCatalog.descriptors; +/** Return core root-command descriptors in help/registration order. */ export function getCoreCliCommandDescriptors(): ReadonlyArray { return coreCliCommandCatalog.getDescriptors(); } +/** Return names for all core root commands. */ export function getCoreCliCommandNames(): string[] { return coreCliCommandCatalog.getNames(); } +/** Return core root commands that own child subcommands. */ export function getCoreCliCommandsWithSubcommands(): string[] { return coreCliCommandCatalog.getCommandsWithSubcommands(); } +/** Return core root commands whose parent action should default to help. */ export function getCoreCliParentDefaultHelpCommands(): string[] { return coreCliCommandCatalog.getParentDefaultHelpCommands(); } diff --git a/src/cli/program/error-output.ts b/src/cli/program/error-output.ts index a2b91b5817ee..966d0b3d7802 100644 --- a/src/cli/program/error-output.ts +++ b/src/cli/program/error-output.ts @@ -1,3 +1,4 @@ +// Friendly parse-error formatter for Commander errors and root CLI recovery hints. import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { getCommandPathWithRootOptions } from "../argv.js"; @@ -41,6 +42,7 @@ function formatDocsHint(): string { return `${theme.muted("Docs:")} ${formatDocsLink("/cli", "docs.openclaw.ai/cli")}`; } +/** Convert Commander parse errors into OpenClaw-specific help and docs guidance. */ export function formatCliParseErrorOutput( raw: string, options: FormatCliParseErrorOptions = {}, diff --git a/src/cli/program/helpers.ts b/src/cli/program/helpers.ts index e65895152c6f..f206bb0f7847 100644 --- a/src/cli/program/helpers.ts +++ b/src/cli/program/helpers.ts @@ -1,10 +1,13 @@ +// Shared Commander registration helpers for repeated options, positive ints, and lazy reparse args. import { InvalidArgumentError, type Command } from "commander"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; +/** Commander option collector for repeatable string flags. */ export function collectOption(value: string, previous: string[] = []): string[] { return [...previous, value]; } +/** Parse an optional positive integer, treating empty values as unset. */ export function parsePositiveIntOrUndefined(value: unknown): number | undefined { if (value === undefined || value === null || value === "") { return undefined; @@ -12,10 +15,12 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined return parseStrictPositiveInteger(value); } +/** Parse a positive integer without treating empty values specially. */ export function parseStrictPositiveIntOrUndefined(value: unknown): number | undefined { return parseStrictPositiveInteger(value); } +/** Commander argument parser for required positive integer options. */ export function parseStrictPositiveIntOption(value: string, flag: string): number { const parsed = parseStrictPositiveInteger(value); if (parsed === undefined) { @@ -24,6 +29,7 @@ export function parseStrictPositiveIntOption(value: string, flag: string): numbe return parsed; } +/** Return positional args captured by a Commander action command. */ export function resolveActionArgs(actionCommand?: Command): string[] { if (!actionCommand) { return []; @@ -72,6 +78,7 @@ function stringifyOptionValue(value: unknown): string | undefined { return undefined; } +/** Reconstruct explicit option tokens from a Commander command for lazy reparsing. */ export function resolveCommandOptionArgs(command?: Command): string[] { if (!command) { return []; diff --git a/src/cli/program/json-mode.ts b/src/cli/program/json-mode.ts index 60a18cb5decc..53cc1da5d349 100644 --- a/src/cli/program/json-mode.ts +++ b/src/cli/program/json-mode.ts @@ -1,3 +1,4 @@ +// JSON-mode metadata for Commander commands; distinguishes JSON output from parse-only flags. import type { Command } from "commander"; import { hasFlag } from "../argv.js"; @@ -38,6 +39,7 @@ function commandSelectedJsonFlag(command: Command, argv: string[]): boolean { return hasFlag(argv, "--json"); } +/** Mark a command as having a special JSON mode beyond ordinary JSON output. */ export function setCommandJsonMode(command: Command, mode: JsonMode): Command { (command as JsonModeCommand)[jsonModeSymbol] = mode; return command; @@ -50,6 +52,7 @@ function getCommandJsonMode(command: Command, argv: string[] = process.argv): Js return getDeclaredCommandJsonMode(command); } +/** Return true only when `--json` selects machine-readable command output. */ export function isCommandJsonOutputMode(command: Command, argv: string[] = process.argv): boolean { return getCommandJsonMode(command, argv) === "output"; } diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index 739945a0eb07..7e5b527292e4 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -1,3 +1,4 @@ +// Shared helpers for message CLI actions: common flags, plugin preload, numeric validation, and stop hooks. import type { Command } from "commander"; import { getChannelPlugin } from "../../../channels/plugins/index.js"; import { @@ -18,6 +19,7 @@ import { runCommandWithRuntime } from "../../cli-utils.js"; import { createDefaultDeps } from "../../deps.js"; import { ensurePluginRegistryLoaded, type PluginRegistryScope } from "../../plugin-registry.js"; +/** Shared helpers used by every message subcommand registration. */ export type MessageCliHelpers = { withMessageBase: (command: Command) => Command; withMessageTarget: (command: Command) => Command; @@ -126,6 +128,8 @@ function resolveMessagePluginPreloadPlan( const loadOptions = scopedChannel ? { scope: "configured-channels" as const, onlyChannelIds: [scopedChannel] } : { scope: "configured-channels" as const }; + // Gateway-owned actions can execute without loading channel plugins in the CLI process; + // dry-runs, broadcasts, and local actions need registry metadata before building payloads. if ( opts.dryRun === true || ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD.has(action) || @@ -136,6 +140,7 @@ function resolveMessagePluginPreloadPlan( return { preload: false }; } +/** Create shared option decorators and the common message action runner. */ export function createMessageCliHelpers( message: Command, messageChannelOptions: string, @@ -179,6 +184,7 @@ export function createMessageCliHelpers( defaultRuntime.error(danger(String(err))); }, ); + // Outbound actions may start plugin-side resources; run bounded stop hooks even after failure. if (!ACTIONS_WITHOUT_STOP_HOOKS.has(action)) { await runPluginStopHooks(); } diff --git a/src/cli/program/message/register.broadcast.ts b/src/cli/program/message/register.broadcast.ts index bfdd1ebeebd4..5f12c659e1b8 100644 --- a/src/cli/program/message/register.broadcast.ts +++ b/src/cli/program/message/register.broadcast.ts @@ -1,7 +1,9 @@ +// Message broadcast command registration for multi-target outbound sends. import type { Command } from "commander"; import { CHANNEL_TARGETS_DESCRIPTION } from "../../../infra/outbound/channel-target.js"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register `message broadcast` for sending one payload to multiple channel targets. */ export function registerMessageBroadcastCommand(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase( diff --git a/src/cli/program/message/register.discord-admin.ts b/src/cli/program/message/register.discord-admin.ts index 1994d3c53b52..1dc14abd2984 100644 --- a/src/cli/program/message/register.discord-admin.ts +++ b/src/cli/program/message/register.discord-admin.ts @@ -1,6 +1,8 @@ +// Discord-style admin command registration for roles, channels, members, events, and moderation. import type { Command } from "commander"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register Discord admin and moderation message subcommands. */ export function registerMessageDiscordAdminCommands(message: Command, helpers: MessageCliHelpers) { const role = message.command("role").description("Role actions"); helpers diff --git a/src/cli/program/message/register.emoji-sticker.ts b/src/cli/program/message/register.emoji-sticker.ts index 221a3865850e..01388474a85a 100644 --- a/src/cli/program/message/register.emoji-sticker.ts +++ b/src/cli/program/message/register.emoji-sticker.ts @@ -1,7 +1,9 @@ +// Emoji and sticker command registration for Discord-style media assets. import type { Command } from "commander"; import { collectOption } from "../helpers.js"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register emoji list/upload commands. */ export function registerMessageEmojiCommands(message: Command, helpers: MessageCliHelpers) { const emoji = message.command("emoji").description("Emoji actions"); @@ -27,6 +29,7 @@ export function registerMessageEmojiCommands(message: Command, helpers: MessageC }); } +/** Register sticker send/upload commands. */ export function registerMessageStickerCommands(message: Command, helpers: MessageCliHelpers) { const sticker = message.command("sticker").description("Sticker actions"); diff --git a/src/cli/program/message/register.permissions-search.ts b/src/cli/program/message/register.permissions-search.ts index 4aeb7103eddb..925f61b77a85 100644 --- a/src/cli/program/message/register.permissions-search.ts +++ b/src/cli/program/message/register.permissions-search.ts @@ -1,7 +1,9 @@ +// Permissions and search command registration for channel message surfaces. import type { Command } from "commander"; import { collectOption } from "../helpers.js"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register the channel permissions inspection command. */ export function registerMessagePermissionsCommand(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase( @@ -14,6 +16,7 @@ export function registerMessagePermissionsCommand(message: Command, helpers: Mes }); } +/** Register Discord message search command and repeatable filters. */ export function registerMessageSearchCommand(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase(message.command("search").description("Search Discord messages")) diff --git a/src/cli/program/message/register.pins.ts b/src/cli/program/message/register.pins.ts index 2235099aae27..05ce3d706075 100644 --- a/src/cli/program/message/register.pins.ts +++ b/src/cli/program/message/register.pins.ts @@ -1,6 +1,8 @@ +// Pin command registration for pin, unpin, and list-pins actions. import type { Command } from "commander"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register message pin management commands. */ export function registerMessagePinCommands(message: Command, helpers: MessageCliHelpers) { const pins = [ helpers diff --git a/src/cli/program/message/register.poll.ts b/src/cli/program/message/register.poll.ts index fcae239a5044..49dabce16478 100644 --- a/src/cli/program/message/register.poll.ts +++ b/src/cli/program/message/register.poll.ts @@ -1,7 +1,9 @@ +// Poll command registration for channel-backed poll creation. import type { Command } from "commander"; import { collectOption } from "../helpers.js"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register `message poll` and validate poll-specific flags through the shared action path. */ export function registerMessagePollCommand(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase( diff --git a/src/cli/program/message/register.reactions.ts b/src/cli/program/message/register.reactions.ts index ab72a4506fed..9cb79d67829d 100644 --- a/src/cli/program/message/register.reactions.ts +++ b/src/cli/program/message/register.reactions.ts @@ -1,6 +1,8 @@ +// Reaction command registration for adding/removing and listing message reactions. import type { Command } from "commander"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register reaction mutation/list commands. */ export function registerMessageReactionsCommands(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase( diff --git a/src/cli/program/message/register.read-edit-delete.ts b/src/cli/program/message/register.read-edit-delete.ts index 658dc35eadf5..0f930c04bbf6 100644 --- a/src/cli/program/message/register.read-edit-delete.ts +++ b/src/cli/program/message/register.read-edit-delete.ts @@ -1,6 +1,8 @@ +// Read, edit, and delete message command registration. import type { Command } from "commander"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register message read, edit, and delete commands. */ export function registerMessageReadEditDeleteCommands( message: Command, helpers: MessageCliHelpers, diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index cea273da4688..213fa9543357 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -1,6 +1,8 @@ +// Message send command registration, including media and presentation/delivery options. import type { Command } from "commander"; import type { MessageCliHelpers } from "./helpers.js"; +/** Register `message send` and route execution through shared message helpers. */ export function registerMessageSendCommand(message: Command, helpers: MessageCliHelpers) { helpers .withMessageBase( diff --git a/src/cli/program/message/register.thread.ts b/src/cli/program/message/register.thread.ts index 33e403b24dcd..3157d938b8e6 100644 --- a/src/cli/program/message/register.thread.ts +++ b/src/cli/program/message/register.thread.ts @@ -1,3 +1,4 @@ +// Thread command registration, including channel-specific create request normalization. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { getChannelPlugin } from "../../../channels/plugins/index.js"; @@ -24,6 +25,7 @@ function resolveThreadCreateRequest(opts: Record) { }; } +/** Register thread create/list/reply commands. */ export function registerMessageThreadCommands(message: Command, helpers: MessageCliHelpers) { const thread = message.command("thread").description("Thread actions"); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 810d44043469..7bbc74b19fbf 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,3 +1,4 @@ +// Global Commander pre-action hook: startup presentation, config guard, logging, and plugin preflight. import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; import type { LogLevel } from "../../logging/levels.js"; @@ -95,6 +96,7 @@ function isGuidedConfigCommandPath(commandPath: string[]): boolean { ); } +/** Register global pre-action bootstrap hooks for every non-help command invocation. */ export function registerPreActionHooks(program: Command, programVersion: string) { program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts index 55a1b9612c3c..dca2966f5ba3 100644 --- a/src/cli/program/private-qa-cli.ts +++ b/src/cli/program/private-qa-cli.ts @@ -1,3 +1,4 @@ +// Private QA CLI loader, enabled only from source checkouts and explicit env opt-in. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -6,6 +7,7 @@ import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; const PRIVATE_QA_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js"); const SOURCE_CHECKOUT_MARKER_RELATIVE_PATHS = [".git", "pnpm-workspace.yaml"] as const; +/** Return true when private QA CLI routes should be exposed. */ export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1"; } @@ -52,6 +54,7 @@ async function dynamicImportPrivateQaCliModule( return (await import(specifier)) as Record; } +/** Load the private QA module from a source checkout or throw a user-facing availability error. */ export function loadPrivateQaCliModule(params?: { env?: NodeJS.ProcessEnv; cwd?: string; diff --git a/src/cli/program/program-context.ts b/src/cli/program/program-context.ts index 83ff80f6d9e8..2759d112b191 100644 --- a/src/cli/program/program-context.ts +++ b/src/cli/program/program-context.ts @@ -1,13 +1,16 @@ +// Attaches ProgramContext metadata to Commander instances for lazy command helpers. import type { Command } from "commander"; import type { ProgramContext } from "./context.js"; const PROGRAM_CONTEXT_SYMBOL: unique symbol = Symbol.for("openclaw.cli.programContext"); +/** Attach the current root ProgramContext to a Commander program. */ export function setProgramContext(program: Command, ctx: ProgramContext): void { (program as Command & { [PROGRAM_CONTEXT_SYMBOL]?: ProgramContext })[PROGRAM_CONTEXT_SYMBOL] = ctx; } +/** Read ProgramContext metadata from a Commander program when available. */ export function getProgramContext(program: Command): ProgramContext | undefined { return (program as Command & { [PROGRAM_CONTEXT_SYMBOL]?: ProgramContext })[ PROGRAM_CONTEXT_SYMBOL diff --git a/src/cli/program/register-command-groups.ts b/src/cli/program/register-command-groups.ts index ac84ce43728b..2aa6127ecd4f 100644 --- a/src/cli/program/register-command-groups.ts +++ b/src/cli/program/register-command-groups.ts @@ -1,29 +1,35 @@ +// Lazy command-group registration: placeholder commands are replaced by real subcommand groups. import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { Command } from "commander"; import { removeCommandByName } from "./command-tree.js"; import { registerLazyCommand } from "./register-lazy-command.js"; +/** Placeholder command shown before its lazy group is loaded. */ export type CommandGroupPlaceholder = { name: string; description: string; options?: readonly CommandGroupPlaceholderOption[]; }; +/** Commander option metadata attached to a lazy placeholder. */ export type CommandGroupPlaceholderOption = { flags: string; description: string; }; +/** A lazily registered command group and the names it owns. */ export type CommandGroupEntry = { placeholders: readonly CommandGroupPlaceholder[]; names?: readonly string[]; register: (program: Command) => Promise | void; }; +/** Return every command name owned by a lazy command group. */ export function getCommandGroupNames(entry: CommandGroupEntry): readonly string[] { return entry.names ?? entry.placeholders.map((placeholder) => placeholder.name); } +/** Find the group that owns a command name. */ export function findCommandGroupEntry( entries: readonly CommandGroupEntry[], name: string, @@ -31,12 +37,14 @@ export function findCommandGroupEntry( return entries.find((entry) => getCommandGroupNames(entry).includes(name)); } +/** Remove all placeholder/loaded commands owned by a group before replacing it. */ export function removeCommandGroupNames(program: Command, entry: CommandGroupEntry) { for (const name of new Set(getCommandGroupNames(entry))) { removeCommandByName(program, name); } } +/** Eagerly register one lazy command group by command name. */ export async function registerCommandGroupByName( program: Command, entries: readonly CommandGroupEntry[], @@ -51,6 +59,7 @@ export async function registerCommandGroupByName( return true; } +/** Register one placeholder that loads and replaces its whole command group on demand. */ export function registerLazyCommandGroup( program: Command, entry: CommandGroupEntry, @@ -68,6 +77,7 @@ export function registerLazyCommandGroup( }); } +/** Register command groups either eagerly or as lazy placeholders for startup speed. */ export function registerCommandGroups( program: Command, entries: readonly CommandGroupEntry[], diff --git a/src/cli/program/register-lazy-command.ts b/src/cli/program/register-lazy-command.ts index a5fd12009969..c539410eba65 100644 --- a/src/cli/program/register-lazy-command.ts +++ b/src/cli/program/register-lazy-command.ts @@ -1,3 +1,4 @@ +// Lazy Commander placeholder registration used to keep CLI startup imports small. import type { Command } from "commander"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommandByName } from "./command-tree.js"; @@ -15,6 +16,7 @@ type RegisterLazyCommandParams = { register: () => Promise | void; }; +/** Register a placeholder that loads the real command and reparses the original invocation. */ export function registerLazyCommand({ program, name, @@ -32,6 +34,8 @@ export function registerLazyCommand({ placeholder.action(async (...actionArgs) => { const actionCommand = actionArgs.at(-1) as (Command & { args?: string[] }) | undefined; if (actionCommand) { + // Commander separates option values from positional args on placeholders; restore them + // before reparsing so the real command sees the original token order. actionCommand.args = [ ...resolveCommandOptionArgs(actionCommand), ...(actionCommand.args ?? []), diff --git a/src/cli/program/register.agent-turn.ts b/src/cli/program/register.agent-turn.ts index d765c91b22c4..d0d0eb30759a 100644 --- a/src/cli/program/register.agent-turn.ts +++ b/src/cli/program/register.agent-turn.ts @@ -1,3 +1,4 @@ +// Single agent-turn command registration; delegates execution to the Gateway-backed agent command. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; @@ -25,6 +26,7 @@ async function loadSetVerbose(): Promise { return (await import("../../global-state.js")).setVerbose; } +/** Register `openclaw agent` for one Gateway-backed agent turn. */ export function registerAgentTurnCommand( program: Command, args: { agentChannelOptions: string }, diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 62208195ed1b..e61e39b8ddba 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -1,3 +1,4 @@ +// Agent and agents command registration with lazy command-module loading for startup speed. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -70,6 +71,7 @@ async function runAgentsCommandAction( }); } +/** Register single-turn `agent` plus multi-agent management commands. */ export function registerAgentCommands( program: Command, args: { agentChannelOptions: string }, @@ -78,6 +80,7 @@ export function registerAgentCommands( registerAgentsCommands(program); } +/** Register `agents` management subcommands for config, bindings, identity, and deletion. */ export function registerAgentsCommands(program: Command): void { const agents = program .command("agents") diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts index a70574450f13..0fd8d8d1a0b6 100644 --- a/src/cli/program/register.backup.ts +++ b/src/cli/program/register.backup.ts @@ -1,3 +1,4 @@ +// Backup command registration for local state archive creation and verification. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -7,6 +8,7 @@ import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { formatHelpExamples } from "../help-format.js"; +/** Register backup create/verify subcommands. */ export function registerBackupCommand(program: Command) { const backup = program .command("backup") diff --git a/src/cli/program/register.configure.ts b/src/cli/program/register.configure.ts index f3dabac6a6fe..b87b9cdeadd2 100644 --- a/src/cli/program/register.configure.ts +++ b/src/cli/program/register.configure.ts @@ -1,9 +1,11 @@ +// Configure command registration: lazy-loads the interactive configuration wizard. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { CONFIGURE_WIZARD_SECTIONS } from "../../commands/configure.shared.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +/** Register the interactive `configure` command and section filter flag. */ export function registerConfigureCommand(program: Command): void { program .command("configure") diff --git a/src/cli/program/register.crestodian.ts b/src/cli/program/register.crestodian.ts index 34b2c2de7708..ce8b6bf54153 100644 --- a/src/cli/program/register.crestodian.ts +++ b/src/cli/program/register.crestodian.ts @@ -1,3 +1,4 @@ +// Crestodian command registration: setup/repair assistant entrypoint exposed from the root CLI. import type { Command } from "commander"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { runCrestodian } from "../../crestodian/crestodian.js"; @@ -5,6 +6,7 @@ import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { formatHelpExamples } from "../help-format.js"; +/** Register the Crestodian helper command and its one-shot request flags. */ export function registerCrestodianCommand(program: Command) { program .command("crestodian") diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index cf7e72a9f15c..0addfbb03149 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -1,9 +1,11 @@ +// Maintenance command registration: doctor, dashboard, reset, and uninstall. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +/** Register maintenance commands that inspect or mutate local OpenClaw state. */ export function registerMaintenanceCommands(program: Command) { program .command("doctor") diff --git a/src/cli/program/register.message.ts b/src/cli/program/register.message.ts index 22f5277823d6..5254a78b95b8 100644 --- a/src/cli/program/register.message.ts +++ b/src/cli/program/register.message.ts @@ -1,3 +1,4 @@ +// Message command registration: core send/read/manage actions plus channel-specific admin helpers. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -21,6 +22,7 @@ import { registerMessageReadEditDeleteCommands } from "./message/register.read-e import { registerMessageSendCommand } from "./message/register.send.js"; import { registerMessageThreadCommands } from "./message/register.thread.js"; +/** Register the `message` command group with shared channel option helpers. */ export function registerMessageCommands(program: Command, ctx: ProgramContext) { const message = program .command("message") diff --git a/src/cli/program/register.migrate.ts b/src/cli/program/register.migrate.ts index accf1d1b7332..999eb51aa72d 100644 --- a/src/cli/program/register.migrate.ts +++ b/src/cli/program/register.migrate.ts @@ -1,3 +1,4 @@ +// Migration command registration: list, plan, and apply migration providers. import type { Command } from "commander"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { @@ -87,6 +88,7 @@ function readVerifyPluginApps(value: unknown): boolean { return value === true; } +/** Register migration commands and shared provider/item selection flags. */ export function registerMigrateCommand(program: Command) { const migrate = addVerifyPluginAppsOption( program diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 3cd0618030af..e2ffb2bcdbf3 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -1,9 +1,11 @@ +// Setup command registration: baseline setup by default, onboarding wizard when wizard flags appear. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { hasExplicitOptions } from "../command-options.js"; +/** Register the `setup` command and route wizard-style invocations to onboarding. */ export function registerSetupCommand(program: Command): void { program .command("setup") @@ -49,6 +51,7 @@ export function registerSetupCommand(program: Command): void { "remoteUrl", "remoteToken", ]); + // Any onboarding-only flag means the user intended the wizard path even without --wizard. if (opts.wizard || hasWizardFlags) { const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 828577a38243..1c21a6638b2b 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -1,3 +1,4 @@ +// Status, health, sessions, commitments, and task/flow command registration. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -107,6 +108,7 @@ async function runWithVerboseAndTimeout( }); } +/** Register status/health plus persistent session/task inspection command groups. */ export function registerStatusHealthSessionsCommands(program: Command) { program .command("status") diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 943994da57ae..0f13c81349d0 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -1,3 +1,4 @@ +// Sub-CLI registration: core subcommands plus lazily imported command groups. import type { Command } from "commander"; import { resolveCliArgvInvocation } from "../argv-invocation.js"; import { @@ -56,10 +57,12 @@ function resolveSubCliCommandGroups( ); } +/** Return visible sub-CLI descriptors after private QA filtering. */ export function getSubCliEntries(): ReadonlyArray { return getSubCliEntryDescriptors(); } +/** Register one sub-CLI by name, including lazy command groups. */ export async function registerSubCliByName( program: Command, name: string, @@ -72,6 +75,7 @@ export async function registerSubCliByName( return registerCommandGroupByName(program, resolveSubCliCommandGroups(argv, context), name); } +/** Register sub-CLI commands according to eager/lazy startup policy. */ export function registerSubCliCommands(program: Command, argv: string[] = process.argv) { registerSubCliCommandsCore(program, argv); const { primary } = resolveCliArgvInvocation(argv); diff --git a/src/cli/program/register.transcripts.ts b/src/cli/program/register.transcripts.ts index 09f0b7d46ee3..f4ae748039de 100644 --- a/src/cli/program/register.transcripts.ts +++ b/src/cli/program/register.transcripts.ts @@ -1,3 +1,4 @@ +// `openclaw transcripts`: local state inspector for stored transcript metadata and summaries. import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -44,6 +45,7 @@ function sessionDir(date: string, sessionId: string): string { return path.join(stateRootDir(), date, safeSegment(sessionId)); } +// Selectors are date-qualified when duplicate session ids can exist across transcript days. function readDateFromSessionDir(sessionDirValue: string): string { const candidate = path.basename(path.dirname(sessionDirValue)); if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { @@ -286,6 +288,7 @@ async function pathCommand(selector: string, options: TranscriptsPathOptions): P writeLine(selectedPath); } +/** Register transcript list/show/path inspection commands. */ export function registerTranscriptsCli(program: Command): void { const transcripts = program.command("transcripts").description("Inspect stored transcripts"); diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index dc6d230f1040..69c9c143c67a 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,3 +1,4 @@ +// Root help renderer that combines core, sub-CLI, and optional plugin command descriptors. import { Command } from "commander"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; @@ -11,6 +12,7 @@ import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; +/** Options for rendering root help without fully registering the live CLI. */ export type RootHelpRenderOptions = Pick & { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -54,6 +56,7 @@ async function buildRootHelpProgram(renderOptions?: RootHelpRenderOptions): Prom return program; } +/** Render root help text for tests, docs, and command output. */ export async function renderRootHelpText(renderOptions?: RootHelpRenderOptions): Promise { const program = await buildRootHelpProgram(renderOptions); let output = ""; @@ -71,6 +74,7 @@ export async function renderRootHelpText(renderOptions?: RootHelpRenderOptions): return output; } +/** Write rendered root help directly to stdout. */ export async function outputRootHelp(renderOptions?: RootHelpRenderOptions): Promise { process.stdout.write(await renderRootHelpText(renderOptions)); } diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index acc5be690be6..6da5bc0c7405 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -1,3 +1,4 @@ +// Route-first argv parsers for commands that can skip full Commander startup. import { isValueToken } from "../../infra/cli-root-options.js"; import { getCommandPositionalsWithRootOptions, @@ -32,6 +33,7 @@ function parseRepeatedFlagValues(argv: string[], name: string): string[] | null if (arg === name) { const next = args[i + 1]; if (!isValueToken(next)) { + // Invalid fast-path shapes fall back to Commander so its normal errors and help text win. return null; } values.push(next); @@ -63,6 +65,7 @@ function parseSinglePositional( return positionals[0] ?? null; } +/** Parse `openclaw health` flags for the route-first status family. */ export function parseHealthRouteArgs(argv: string[]) { const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); if (timeoutMs === null) { @@ -75,6 +78,7 @@ export function parseHealthRouteArgs(argv: string[]) { }; } +/** Parse `openclaw status` flags without registering the full command tree. */ export function parseStatusRouteArgs(argv: string[]) { const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); if (timeoutMs === null) { @@ -90,6 +94,7 @@ export function parseStatusRouteArgs(argv: string[]) { }; } +/** Parse `openclaw gateway status` RPC-only flags accepted by the fast route. */ export function parseGatewayStatusRouteArgs(argv: string[]) { const url = parseOptionalFlagValue(argv, "--url"); if (!url.ok) { @@ -109,6 +114,7 @@ export function parseGatewayStatusRouteArgs(argv: string[]) { } const ssh = parseOptionalFlagValue(argv, "--ssh"); if (!ssh.ok || ssh.value !== undefined) { + // SSH probe options need the full command because they resolve host aliases and identity files. return null; } const sshIdentity = parseOptionalFlagValue(argv, "--ssh-identity"); @@ -132,6 +138,7 @@ export function parseGatewayStatusRouteArgs(argv: string[]) { }; } +/** Parse `openclaw sessions` filters for JSON/list route execution. */ export function parseSessionsRouteArgs(argv: string[]) { const agent = parseOptionalFlagValue(argv, "--agent"); if (!agent.ok) { @@ -159,6 +166,7 @@ export function parseSessionsRouteArgs(argv: string[]) { }; } +/** Parse `openclaw agents list` display switches for route-first execution. */ export function parseAgentsListRouteArgs(argv: string[]) { return { json: hasFlag(argv, "--json"), @@ -166,6 +174,7 @@ export function parseAgentsListRouteArgs(argv: string[]) { }; } +/** Parse `openclaw config get ` while preserving root option handling. */ export function parseConfigGetRouteArgs(argv: string[]) { const path = parseSinglePositional(argv, { commandPath: ["config", "get"], @@ -180,6 +189,7 @@ export function parseConfigGetRouteArgs(argv: string[]) { }; } +/** Parse `openclaw config unset ` and its mutation guard flags. */ export function parseConfigUnsetRouteArgs(argv: string[]) { const path = parseSinglePositional(argv, { commandPath: ["config", "unset"], @@ -198,6 +208,7 @@ export function parseConfigUnsetRouteArgs(argv: string[]) { }; } +/** Parse `openclaw models list` filters for the lightweight model catalog route. */ export function parseModelsListRouteArgs(argv: string[]) { const provider = parseOptionalFlagValue(argv, "--provider"); if (!provider.ok) { @@ -212,6 +223,7 @@ export function parseModelsListRouteArgs(argv: string[]) { }; } +/** Parse `openclaw models status` probe controls for the route-first status path. */ export function parseModelsStatusRouteArgs(argv: string[]) { const probeProvider = parseOptionalFlagValue(argv, "--probe-provider"); if (!probeProvider.ok) { @@ -257,6 +269,7 @@ export function parseModelsStatusRouteArgs(argv: string[]) { }; } +/** Parse `openclaw channels list` display flags for the route-first list path. */ export function parseChannelsListRouteArgs(argv: string[]) { return { json: hasFlag(argv, "--json"), @@ -264,6 +277,7 @@ export function parseChannelsListRouteArgs(argv: string[]) { }; } +/** Parse `openclaw channels status` probe flags without full CLI registration. */ export function parseChannelsStatusRouteArgs(argv: string[]) { const timeout = parseOptionalFlagValue(argv, "--timeout"); const channel = parseOptionalFlagValue(argv, "--channel"); @@ -281,6 +295,7 @@ export function parseChannelsStatusRouteArgs(argv: string[]) { }; } +/** Parse JSON-only `openclaw plugins list` flags for plugin inventory output. */ export function parsePluginsListRouteArgs(argv: string[]) { if (!hasFlag(argv, "--json")) { return null; @@ -326,6 +341,7 @@ function parseTasksListRouteArgsForCommandPath(argv: string[], commandPath: stri }; } +/** Parse both `openclaw tasks --json` and `openclaw tasks list --json` aliases. */ export function parseTasksListRouteArgs(argv: string[]) { return ( parseTasksListRouteArgsForCommandPath(argv, ["tasks"]) ?? @@ -333,6 +349,7 @@ export function parseTasksListRouteArgs(argv: string[]) { ); } +/** Parse JSON-only `openclaw tasks audit` filters for the route-first audit path. */ export function parseTasksAuditRouteArgs(argv: string[]) { if (!hasFlag(argv, "--json")) { return null; diff --git a/src/cli/program/route-specs.ts b/src/cli/program/route-specs.ts index fac04f4828e7..8b0bb5c52d57 100644 --- a/src/cli/program/route-specs.ts +++ b/src/cli/program/route-specs.ts @@ -1,3 +1,4 @@ +// Preparsed route specs for commands implemented outside Commander action registration. import { hasFlag } from "../argv.js"; import { cliCommandCatalog, type CliCommandCatalogEntry } from "../command-catalog.js"; import { matchesCommandPath } from "../command-path-matches.js"; @@ -7,6 +8,7 @@ import { type AnyRoutedCommandDefinition, } from "./routed-command-definitions.js"; +/** Runtime route with optional preflight and plugin-preload policy. */ export type RouteSpec = { matches: (path: string[]) => boolean; canRun?: (argv: string[]) => boolean; @@ -43,6 +45,7 @@ function createParsedRoute(params: { }; } +/** Route specs generated from catalog entries with parseable routed-command definitions. */ export const routedCommands: RouteSpec[] = cliCommandCatalog .filter( ( diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index a1f7ed2df606..be8f72f6a0f7 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -1,3 +1,4 @@ +// Lazy command implementations for routes that can bypass full Commander registration. import { defaultRuntime } from "../../runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { @@ -26,11 +27,13 @@ type ModelsListCommandModule = typeof import("../../commands/models/list.list-co type ModelsStatusCommandModule = typeof import("../../commands/models/list.status-command.js"); type TasksJsonCommandModule = typeof import("../../commands/tasks-json.js"); +/** Typed parsed route definition that binds one parser to its runner. */ export type RoutedCommandDefinition> = { parseArgs: TParse; runParsedArgs: (args: ParsedRouteArgs) => Promise; }; +/** Erased routed-command definition map shape used by route-spec generation. */ export type AnyRoutedCommandDefinition = { parseArgs: RouteArgParser; runParsedArgs: (args: never) => Promise; @@ -76,6 +79,7 @@ function loadTasksJsonCommand(): Promise { return tasksJsonCommandLoader.load(); } +/** Route id to lazy parser/runner definition. */ export const routedCommandDefinitions = { health: defineRoutedCommand({ parseArgs: parseHealthRouteArgs, diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index ae61cf8bed77..b7e73b40818a 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -1,7 +1,10 @@ +// Routed command lookup for fast paths that bypass full Commander registration. import { routedCommands, type RouteSpec } from "./route-specs.js"; +/** Routed command contract re-exported for callers that only need route lookup. */ export type { RouteSpec } from "./route-specs.js"; +/** Find the first route matching a command path and parseable argv. */ export function findRoutedCommand(path: string[], argv?: string[]): RouteSpec | null { for (const route of routedCommands) { if (route.matches(path)) { diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index bb9ee0843eb3..1de2bbd0fb02 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -1,7 +1,9 @@ +// Sub-CLI descriptor catalog used for root help placeholders and lazy registration. import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js"; import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; import { isPrivateQaCliEnabled } from "./private-qa-cli.js"; +/** Descriptor shape for root-level sub-CLI commands. */ export type SubCliDescriptor = NamedCommandDescriptor; const subCliCommandCatalog = defineCommandDescriptorCatalog([ @@ -189,11 +191,13 @@ function filterPrivateQaItems( return items.filter((item) => getName(item) !== "qa"); } +/** Visible sub-CLI descriptors after private QA gating. */ export const SUB_CLI_DESCRIPTORS = filterPrivateQaItems( subCliCommandCatalog.descriptors, (descriptor) => descriptor.name, ); +/** Return visible sub-CLI descriptors in help/registration order. */ export function getSubCliEntries(): ReadonlyArray { return filterPrivateQaItems( subCliCommandCatalog.getDescriptors(), @@ -201,6 +205,7 @@ export function getSubCliEntries(): ReadonlyArray { ); } +/** Return visible sub-CLI names that own child subcommands. */ export function getSubCliCommandsWithSubcommands(): string[] { return [ ...filterPrivateQaItems( @@ -210,6 +215,7 @@ export function getSubCliCommandsWithSubcommands(): string[] { ]; } +/** Return visible sub-CLI names whose parent command should show help by default. */ export function getSubCliParentDefaultHelpCommands(): string[] { return [ ...filterPrivateQaItems( diff --git a/src/cli/requirements-test-fixtures.ts b/src/cli/requirements-test-fixtures.ts index 4ef7e8fb8c60..f971c4bec75e 100644 --- a/src/cli/requirements-test-fixtures.ts +++ b/src/cli/requirements-test-fixtures.ts @@ -1,3 +1,4 @@ +// Shared empty requirement/install-check fixtures for CLI tests. function createEmptyRequirements() { return { bins: [], @@ -8,6 +9,7 @@ function createEmptyRequirements() { }; } +/** Build an empty install-check result with all requirement buckets present. */ export function createEmptyInstallChecks() { return { requirements: createEmptyRequirements(), diff --git a/src/cli/root-option-forward.ts b/src/cli/root-option-forward.ts index bb53484e1772..1571d3758056 100644 --- a/src/cli/root-option-forward.ts +++ b/src/cli/root-option-forward.ts @@ -1,5 +1,7 @@ +// Root-option forwarding helper for subcommand dispatchers that reparse argv later. import { consumeRootOptionToken } from "../infra/cli-root-options.js"; +/** Copy one consumed root option and its value tokens into `out`, returning token count. */ export function forwardConsumedCliRootOption( args: readonly string[], index: number, diff --git a/src/cli/root-option-value.ts b/src/cli/root-option-value.ts index ae8561172d04..3bc5d7314fe4 100644 --- a/src/cli/root-option-value.ts +++ b/src/cli/root-option-value.ts @@ -1,6 +1,8 @@ +// Shared parser for root options that may be passed as `--flag=value` or `--flag value`. import { isValueToken } from "../infra/cli-root-options.js"; import { parseInlineOptionToken } from "../infra/inline-option-token.js"; +/** Return the normalized option value and whether the next argv token was consumed. */ export function takeCliRootOptionValue( raw: string, next: string | undefined, diff --git a/src/cli/route.ts b/src/cli/route.ts index e7e9a4eb34f1..239e70d416c7 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -1,3 +1,4 @@ +// Route-first CLI entry point for commands that can run before full Commander setup. import { isTruthyEnvValue } from "../infra/env.js"; import { defaultRuntime } from "../runtime.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; @@ -29,6 +30,7 @@ async function prepareRoutedCommand(params: { }); const shouldLoadPlugins = typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; + // Routed commands still honor config guards, logging policy, and plugin loading decisions. await ensureCliExecutionBootstrap({ runtime: defaultRuntime, commandPath: params.commandPath, @@ -37,6 +39,7 @@ async function prepareRoutedCommand(params: { }); } +/** Try a lightweight route-first command before falling back to the full CLI program. */ export async function tryRouteCli(argv: string[]): Promise { if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_ROUTE_FIRST)) { return false; @@ -53,6 +56,7 @@ export async function tryRouteCli(argv: string[]): Promise { return false; } if (route.canRun && !route.canRun(argv)) { + // Let Commander own unsupported argv shapes so user-facing validation stays centralized. return false; } await prepareRoutedCommand({ diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 2a23909b012a..e2ca402366d2 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -1,3 +1,4 @@ +// Formatting layer for `openclaw skills` commands; keeps discovery data separate from terminal UI. import { sanitizeForLog, stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { decorativeEmoji, @@ -13,16 +14,19 @@ import { import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +/** Options for rendering the skill list command. */ export type SkillsListOptions = { json?: boolean; eligible?: boolean; verbose?: boolean; }; +/** Options for rendering one skill detail view. */ export type SkillInfoOptions = { json?: boolean; }; +/** Options for rendering skill readiness checks. */ export type SkillsCheckOptions = { json?: boolean; agent?: string; @@ -110,6 +114,7 @@ function formatSkillMissingSummary(skill: SkillStatusEntry): string { return missing.join("; "); } +/** Render skill discovery status as sanitized JSON or a terminal table. */ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOptions): string { const isReadyForAgent = (skill: SkillStatusEntry) => skill.eligible && !skill.blockedByAgentFilter; @@ -185,6 +190,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti return appendClawHubHint(lines.join("\n"), opts.json); } +/** Render one skill's status, requirements, install hints, and API-key setup details. */ export function formatSkillInfo( report: SkillStatusReport, skillName: string, @@ -328,6 +334,7 @@ export function formatSkillInfo( return appendClawHubHint(lines.join("\n"), opts.json); } +/** Render aggregate setup health for all discovered skills. */ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOptions): string { const eligible = report.skills.filter((s) => s.eligible); const modelVisible = report.skills.filter((s) => s.modelVisible); diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index 23d8f946bc2f..230a5e2d2268 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -1,3 +1,4 @@ +// System CLI commands that call Gateway RPC methods for events, heartbeats, and presence. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; @@ -45,6 +46,7 @@ async function runSystemGatewayCommand( } } +/** Register Gateway-backed system event, heartbeat, and presence commands. */ export function registerSystemCli(program: Command) { const system = program .command("system") diff --git a/src/cli/test-runtime-mock.ts b/src/cli/test-runtime-mock.ts index 855a09291eee..328b18107ecf 100644 --- a/src/cli/test-runtime-mock.ts +++ b/src/cli/test-runtime-mock.ts @@ -1,7 +1,9 @@ +// Vitest helper for CLI commands that write through the RuntimeEnv interface. import type { vi } from "vitest"; type ViLike = Pick; +/** Create a RuntimeEnv-like mock plus captured log/error arrays. */ export function createCliRuntimeMock( viInstance: ViLike, options: { diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 4a0a12ff109b..4a8b76159b62 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -1,3 +1,4 @@ +// Update command presentation helpers: spinner lifecycle, failure hints, and result summaries. import { spinner } from "@clack/prompts"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; @@ -40,6 +41,7 @@ function getStepLabel(step: UpdateStepInfo): string { return STEP_LABELS[step.name] ?? step.name; } +/** Convert updater failure reasons and stderr tails into operator-facing recovery hints. */ export function inferUpdateFailureHints(result: UpdateRunResult): string[] { if (result.status !== "error") { return []; @@ -107,11 +109,13 @@ export function inferUpdateFailureHints(result: UpdateRunResult): string[] { return hints; } +/** Runner-facing progress callbacks plus terminal spinner cleanup. */ export type ProgressController = { progress: UpdateStepProgress; stop: () => void; }; +/** Create a progress adapter for the updater runner without coupling runner code to terminal UI. */ export function createUpdateProgress(enabled: boolean): ProgressController { if (!enabled) { return { @@ -175,6 +179,7 @@ type PrintResultOptions = UpdateCommandOptions & { hideSteps?: boolean; }; +/** Render a completed updater run as JSON or terminal output. */ export function printResult(result: UpdateRunResult, opts: PrintResultOptions): void { if (opts.json) { defaultRuntime.writeJson(result); diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index e79c2c91dbf9..3c5e96ac34b2 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -1,3 +1,4 @@ +// `openclaw update status`: combines install metadata, configured channel, and remote update checks. import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { @@ -32,6 +33,7 @@ function formatGitStatusLine(params: { return parts.join(" · "); } +/** Print update status in JSON or table form for scripts and humans. */ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise { const timeoutMs = parseTimeoutMsOrExit(opts.timeout); if (timeoutMs === null) { diff --git a/src/cli/update-cli/wizard.ts b/src/cli/update-cli/wizard.ts index 570f6dde8de0..5470239170dd 100644 --- a/src/cli/update-cli/wizard.ts +++ b/src/cli/update-cli/wizard.ts @@ -1,3 +1,5 @@ +// Interactive updater entrypoint: resolves current install/channel state, prompts for +// a target channel, then delegates the actual mutation to the non-interactive updater. import { confirm, isCancel } from "@clack/prompts"; import { selectStyled } from "../../../packages/terminal-core/src/prompt-select-styled.js"; import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js"; @@ -21,6 +23,7 @@ import { } from "./shared.js"; import { updateCommand } from "./update-command.js"; +/** Run the TTY-only update wizard and preserve `updateCommand` as the single update executor. */ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise { if (!process.stdin.isTTY) { defaultRuntime.error( diff --git a/src/cli/webhooks-cli.ts b/src/cli/webhooks-cli.ts index 8ae54c93b81e..dbd576f50528 100644 --- a/src/cli/webhooks-cli.ts +++ b/src/cli/webhooks-cli.ts @@ -1,3 +1,4 @@ +// Webhook CLI registrations, currently Gmail Pub/Sub setup and service runner commands. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; @@ -23,6 +24,7 @@ import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { defaultRuntime } from "../runtime.js"; import { formatCliCommand } from "./command-format.js"; +/** Register webhook-related subcommands on the root Commander program. */ export function registerWebhooksCli(program: Command) { const webhooks = program .command("webhooks") diff --git a/src/cli/windows-argv.ts b/src/cli/windows-argv.ts index 30f0b4abac94..10f0e227dc2d 100644 --- a/src/cli/windows-argv.ts +++ b/src/cli/windows-argv.ts @@ -1,5 +1,7 @@ +// Windows launcher normalization for npm/bun wrappers that duplicate node.exe in argv. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Remove duplicated Windows node launcher argv entries while preserving normal POSIX argv. */ export function normalizeWindowsArgv( argv: string[], options: { diff --git a/src/commands/gateway-health-auth-diagnostic.ts b/src/commands/gateway-health-auth-diagnostic.ts index 28c4bde57d53..b1cb3903306f 100644 --- a/src/commands/gateway-health-auth-diagnostic.ts +++ b/src/commands/gateway-health-auth-diagnostic.ts @@ -7,6 +7,9 @@ export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE = export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE = "Gateway credentials required"; export const GATEWAY_HEALTH_REACHABLE_LINE = "Gateway: reachable"; +/** + * Detects when a daemon probe reached the gateway even if read-scope auth failed. + */ export function gatewayProbeResultSawGateway(status: GatewayProbeReachabilityEvidence): boolean { if (status.ok) { return true; @@ -22,11 +25,16 @@ export function gatewayProbeResultSawGateway(status: GatewayProbeReachabilityEvi if (server?.version || server?.connId) { return true; } + // Older probes may only expose close/error text for auth failures; treat known gateway + // close reasons as reachability evidence so health can explain missing credentials. return /\bgateway closed \(\d+\):|\bpairing required\b|\bdevice identity required\b/i.test( status.error ?? "", ); } +/** + * Builds the health diagnostic emitted when the gateway is reachable but credentials are absent. + */ export function buildCredentialsRequiredHealthDiagnostic() { return { ok: false, diff --git a/src/commitments/config.ts b/src/commitments/config.ts index 5728631419f2..b8ab99390d42 100644 --- a/src/commitments/config.ts +++ b/src/commitments/config.ts @@ -1,6 +1,8 @@ import { resolveUserTimezone } from "../agents/date-time.js"; import type { OpenClawConfig } from "../config/config.js"; +// Configuration defaults for hidden follow-up commitment extraction and +// heartbeat delivery limits. const DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS = 15_000; const DEFAULT_COMMITMENT_BATCH_MAX_ITEMS = 8; export const DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS = 64; @@ -30,6 +32,7 @@ function positiveInt(value: unknown, fallback: number): number { : fallback; } +/** Resolves commitment extraction config with conservative defaults. */ export function resolveCommitmentsConfig(cfg?: OpenClawConfig): ResolvedCommitmentsConfig { const raw = cfg?.commitments; return { @@ -46,6 +49,7 @@ export function resolveCommitmentsConfig(cfg?: OpenClawConfig): ResolvedCommitme }; } +/** Resolves the timezone used when interpreting inferred commitment dates. */ export function resolveCommitmentTimezone(cfg?: OpenClawConfig): string { return resolveUserTimezone(cfg?.agents?.defaults?.userTimezone); } diff --git a/src/commitments/model-selection.runtime.ts b/src/commitments/model-selection.runtime.ts index 665a6631d59a..51edb9b9fbc7 100644 --- a/src/commitments/model-selection.runtime.ts +++ b/src/commitments/model-selection.runtime.ts @@ -1,6 +1,8 @@ import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +// Lazy runtime seam for commitment extraction model selection. Keeps the +// background extraction runtime from loading model-selection code until needed. export function resolveCommitmentDefaultModelRef(params: { cfg: OpenClawConfig; agentId?: string; diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts index 0ea8f429fa2a..41a96d743571 100644 --- a/src/commitments/runtime.ts +++ b/src/commitments/runtime.ts @@ -19,6 +19,8 @@ import type { CommitmentScope, } from "./types.js"; +// Background runtime for extracting inferred follow-up commitments from +// completed turns. It batches hidden extraction requests and persists results. type TimerHandle = ReturnType; type ModelRef = { provider: string; model: string }; type EmbeddedAgentPayloadResult = { payloads?: Array<{ text?: string }> }; @@ -74,10 +76,12 @@ function clearTimer(handle: TimerHandle): void { (runtime.clearTimer ?? clearTimeout)(handle); } +/** Installs runtime hooks for extraction tests or alternate batch extraction. */ export function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void { runtime = next; } +/** Clears queued work, timers, and injected hooks for isolated tests. */ export function resetCommitmentExtractionRuntimeForTests(): void { if (timer) { clearTimer(timer); @@ -99,6 +103,7 @@ function isUsefulText(value: string | undefined): boolean { return Boolean(value?.trim()); } +/** Enqueues one completed turn for delayed commitment extraction. */ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueInput): boolean { const resolved = resolveCommitmentsConfig(input.cfg); const nowMs = input.nowMs ?? Date.now(); @@ -183,6 +188,8 @@ function openTerminalFailureCooldown( if (cooldownUntil !== undefined) { terminalFailureCooldownUntilByAgent.set(agentId, cooldownUntil); } + // Terminal auth/model failures will keep failing for queued turns from the + // same agent. Drop them and cool down to avoid noisy background retries. queue = queue.filter((item) => item.agentId !== agentId); log.warn("commitment extraction disabled temporarily after terminal model/auth failure", { agentId, @@ -273,6 +280,7 @@ async function hydrateBatch( ); } +/** Drains queued extraction work in batches and returns processed item count. */ export async function drainCommitmentExtractionQueue(): Promise { if (draining) { return 0; diff --git a/src/compat/legacy-names.ts b/src/compat/legacy-names.ts index 435e727a3be6..0011bf86abc6 100644 --- a/src/compat/legacy-names.ts +++ b/src/compat/legacy-names.ts @@ -1,9 +1,12 @@ +// Product/package naming constants that bridge current OpenClaw manifests with +// legacy Clawdbot keys still seen in older configs and packages. export const PROJECT_NAME = "openclaw" as const; const LEGACY_PROJECT_NAMES = ["clawdbot"] as const; export const MANIFEST_KEY = PROJECT_NAME; +/** Manifest keys accepted only for legacy compatibility. */ export const LEGACY_MANIFEST_KEYS = LEGACY_PROJECT_NAMES; export const MACOS_APP_SOURCES_DIR = "apps/macos/Sources/OpenClaw" as const; diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index e1f08d0e4ed5..b96abde246c2 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -12,6 +12,7 @@ type DuplicateAgentDir = { agentIds: string[]; }; +/** Error thrown when multiple configured agents resolve to the same state directory. */ export class DuplicateAgentDirError extends Error { readonly duplicates: DuplicateAgentDir[]; @@ -25,6 +26,7 @@ export class DuplicateAgentDirError extends Error { function canonicalizeAgentDir(agentDir: string): string { const resolved = path.resolve(agentDir); if (process.platform === "darwin" || process.platform === "win32") { + // Agent dirs collide case-insensitively on the common macOS/Windows filesystems. return normalizeLowercaseStringOrEmpty(resolved); } return resolved; @@ -78,6 +80,7 @@ function resolveEffectiveAgentDir( return path.join(root, "agents", id, "agent"); } +/** Finds agent ids whose effective agentDir would share auth/session state. */ export function findDuplicateAgentDirs( cfg: OpenClawConfig, deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string }, @@ -98,6 +101,7 @@ export function findDuplicateAgentDirs( return [...byDir.values()].filter((v) => v.agentIds.length > 1); } +/** Formats duplicate agentDir conflicts with the remediation operators should take. */ export function formatDuplicateAgentDirError(dups: DuplicateAgentDir[]): string { const lines: string[] = [ "Duplicate agentDir detected (multi-agent config).", diff --git a/src/config/agent-limits.ts b/src/config/agent-limits.ts index 14b1b66aa45f..fb001b75059f 100644 --- a/src/config/agent-limits.ts +++ b/src/config/agent-limits.ts @@ -1,12 +1,17 @@ import type { OpenClawConfig } from "./types.js"; +/** Default maximum concurrent top-level agent runs. */ export const DEFAULT_AGENT_MAX_CONCURRENT = 4; +/** Default maximum concurrent child-agent runs across subagent execution. */ export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8; +/** Default maximum direct children a single agent run may spawn. */ export const DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = 5; +/** Default age before completed subagent state is archived. */ export const DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES = 60; // Keep depth-1 subagents as leaves unless config explicitly opts into nesting. export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1; +/** Resolves top-level agent concurrency, flooring finite values and clamping to at least one. */ export function resolveAgentMaxConcurrent(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.maxConcurrent; if (typeof raw === "number" && Number.isFinite(raw)) { @@ -15,6 +20,7 @@ export function resolveAgentMaxConcurrent(cfg?: OpenClawConfig): number { return DEFAULT_AGENT_MAX_CONCURRENT; } +/** Resolves subagent concurrency, flooring finite values and clamping to at least one. */ export function resolveSubagentMaxConcurrent(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent; if (typeof raw === "number" && Number.isFinite(raw)) { diff --git a/src/config/agent-timeout-defaults.ts b/src/config/agent-timeout-defaults.ts index b88a4d114da9..339b63e99782 100644 --- a/src/config/agent-timeout-defaults.ts +++ b/src/config/agent-timeout-defaults.ts @@ -1 +1,2 @@ +/** Default idle timeout for agent LLM turns when no provider override exists. */ export const DEFAULT_LLM_IDLE_TIMEOUT_SECONDS = 120; diff --git a/src/config/allowed-values.ts b/src/config/allowed-values.ts index 4030d60caaf1..2a93c57b31e0 100644 --- a/src/config/allowed-values.ts +++ b/src/config/allowed-values.ts @@ -47,12 +47,14 @@ function toAllowedValueDedupKey(value: unknown): string { return "null:null"; } const kind = typeof value; + // Preserve schema distinctions such as numeric 1 vs string "1" even when labels match. if (kind === "string") { return `string:${value as string}`; } return `${kind}:${safeStringify(value)}`; } +/** Summarizes enum/allowed-value candidates for compact validation error hints. */ export function summarizeAllowedValues( values: ReadonlyArray, ): AllowedValuesSummary | null { @@ -92,6 +94,7 @@ function messageAlreadyIncludesAllowedValues(message: string): boolean { return lower.includes("(allowed:") || lower.includes("expected one of"); } +/** Appends an allowed-values hint unless the validation message already includes one. */ export function appendAllowedValuesHint(message: string, summary: AllowedValuesSummary): string { if (messageAlreadyIncludesAllowedValues(message)) { return message; diff --git a/src/config/backup-rotation.ts b/src/config/backup-rotation.ts index 2eb35c5efb1c..a4de6992a527 100644 --- a/src/config/backup-rotation.ts +++ b/src/config/backup-rotation.ts @@ -13,6 +13,12 @@ interface BackupMaintenanceFs extends BackupRotationFs { copyFile: (from: string, to: string) => Promise; } +/** + * Advances the config `.bak` ring before a new primary backup is copied in. + * + * Missing slots are ignored so interrupted writes or first-run configs do not + * block the next config write. + */ export async function rotateConfigBackups( configPath: string, ioFs: BackupRotationFs, @@ -36,10 +42,10 @@ export async function rotateConfigBackups( } /** - * Harden file permissions on all .bak files in the rotation ring. - * copyFile does not guarantee permission preservation on all platforms - * (e.g. Windows, some NFS mounts), so we explicitly chmod each backup - * to owner-only (0o600) to match the main config file. + * Sets owner-only permissions on every backup slot when chmod exists. + * + * Backups are copied on mixed filesystems, so copy mode preservation is not a + * portable security guarantee. */ export async function hardenBackupPermissions( configPath: string, @@ -49,11 +55,9 @@ export async function hardenBackupPermissions( return; } const backupBase = `${configPath}.bak`; - // Harden the primary .bak await ioFs.chmod(backupBase, 0o600).catch(() => { // best-effort }); - // Harden numbered backups for (let i = 1; i < CONFIG_BACKUP_COUNT; i++) { await ioFs.chmod(`${backupBase}.${i}`, 0o600).catch(() => { // best-effort @@ -61,14 +65,7 @@ export async function hardenBackupPermissions( } } -/** - * Remove orphan .bak files that fall outside the managed rotation ring. - * These can accumulate from interrupted writes, manual copies, or PID-stamped - * backups (e.g. openclaw.json.bak.1772352289, openclaw.json.bak.before-marketing). - * - * Only files matching `.bak.*` are considered; the primary - * `.bak` and numbered `.bak.1` through `.bak.{N-1}` are preserved. - */ +/** Prunes stale `.bak.*` files that are outside the managed numbered ring. */ export async function cleanOrphanBackups( configPath: string, ioFs: BackupRotationFs, @@ -80,7 +77,6 @@ export async function cleanOrphanBackups( const base = path.basename(configPath); const bakPrefix = `${base}.bak.`; - // Build the set of valid numbered suffixes: "1", "2", ..., "{N-1}" const validSuffixes = new Set(); for (let i = 1; i < CONFIG_BACKUP_COUNT; i++) { validSuffixes.add(String(i)); @@ -101,7 +97,6 @@ export async function cleanOrphanBackups( if (validSuffixes.has(suffix)) { continue; } - // This is an orphan — remove it await ioFs.unlink(path.join(dir, entry)).catch(() => { // best-effort }); @@ -120,6 +115,12 @@ interface PreUpdateSnapshotFs { const preUpdateConfigSnapshotsWritten = new Set(); +/** + * Captures the first on-disk config state for an update attempt. + * + * The snapshot is outside the rotating `.bak` ring so repeated writes during + * one process keep an operator-visible rollback point for the original file. + */ export async function createPreUpdateConfigSnapshot(params: { configPath: string; fs: PreUpdateSnapshotFs; @@ -131,6 +132,7 @@ export async function createPreUpdateConfigSnapshot(params: { if (preUpdateConfigSnapshotsWritten.has(snapshotKey)) { return; } + // Mark before I/O so a failed best-effort write cannot loop on every later write. preUpdateConfigSnapshotsWritten.add(snapshotKey); const snapshotPath = `${params.configPath}.pre-update`; try { @@ -145,10 +147,7 @@ export async function createPreUpdateConfigSnapshot(params: { } } -/** - * Run the full backup maintenance cycle around config writes. - * Order matters: rotate ring -> create new .bak -> harden modes -> prune orphan .bak.* files. - */ +/** Runs rotation, primary copy, permission hardening, then orphan pruning. */ export async function maintainConfigBackups( configPath: string, ioFs: BackupMaintenanceFs, diff --git a/src/config/bindings.ts b/src/config/bindings.ts index 377af04466c3..a6148375d816 100644 --- a/src/config/bindings.ts +++ b/src/config/bindings.ts @@ -2,9 +2,11 @@ import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.a import type { OpenClawConfig } from "./types.openclaw.js"; function normalizeBindingType(binding: AgentBinding): "route" | "acp" { + // Missing `type` is the legacy/default route binding shape. return binding.type === "acp" ? "acp" : "route"; } +/** Narrows a configured binding to the channel route form. */ export function isRouteBinding(binding: AgentBinding): binding is AgentRouteBinding { return normalizeBindingType(binding) === "route"; } @@ -13,14 +15,17 @@ function isAcpBinding(binding: AgentBinding): binding is AgentAcpBinding { return normalizeBindingType(binding) === "acp"; } +/** Returns the configured binding list, treating missing/non-array config as empty. */ export function listConfiguredBindings(cfg: OpenClawConfig): AgentBinding[] { return Array.isArray(cfg.bindings) ? cfg.bindings : []; } +/** Lists channel route bindings, including legacy bindings without an explicit type. */ export function listRouteBindings(cfg: OpenClawConfig): AgentRouteBinding[] { return listConfiguredBindings(cfg).filter(isRouteBinding); } +/** Lists ACP conversation bindings only. */ export function listAcpBindings(cfg: OpenClawConfig): AgentAcpBinding[] { return listConfiguredBindings(cfg).filter(isAcpBinding); } diff --git a/src/config/byte-size.ts b/src/config/byte-size.ts index 4b76f4958689..f335c70210dd 100644 --- a/src/config/byte-size.ts +++ b/src/config/byte-size.ts @@ -15,6 +15,7 @@ export function parseNonNegativeByteSize(value: unknown): number | null { return null; } try { + // Bare numbers in config strings are bytes, matching numeric config values. const bytes = parseByteSize(trimmed, { defaultUnit: "b" }); return bytes >= 0 ? bytes : null; } catch { @@ -24,6 +25,7 @@ export function parseNonNegativeByteSize(value: unknown): number | null { return null; } +/** Validates byte-size strings accepted by agent default byte-threshold config. */ export function isValidNonNegativeByteSizeString(value: string): boolean { return parseNonNegativeByteSize(value) !== null; } diff --git a/src/config/cache-utils.ts b/src/config/cache-utils.ts index 81216c8f043f..8fc274dccefd 100644 --- a/src/config/cache-utils.ts +++ b/src/config/cache-utils.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +/** Resolves a cache TTL from an env override, falling back unless the override is exact. */ export function resolveCacheTtlMs(params: { envValue: string | undefined; defaultTtlMs: number; @@ -15,6 +16,7 @@ export function resolveCacheTtlMs(params: { return defaultTtlMs; } +/** Returns whether a TTL keeps cache reads and writes active. */ export function isCacheEnabled(ttlMs: number): boolean { return ttlMs > 0; } @@ -58,6 +60,7 @@ function isCacheEntryExpired(storedAt: number, now: number, ttlMs: number): bool return now - storedAt > ttlMs; } +/** Creates a small synchronous map cache with dynamic TTLs and explicit pruning hooks. */ export function createExpiringMapCache(options: { ttlMs: CacheTtlResolver; pruneIntervalMs?: CachePruneIntervalResolver; @@ -68,6 +71,8 @@ export function createExpiringMapCache(options: { let lastPruneAt = 0; function getTtlMs(): number { + // Re-read TTL on every operation so callers can disable or shrink caches without rebuilding + // the cache object. return Math.max(0, Math.floor(resolveCacheNumeric(options.ttlMs))); } @@ -75,6 +80,8 @@ export function createExpiringMapCache(options: { if (!isCacheEnabled(ttlMs)) { return; } + // Pruning is opportunistic; individual reads still check expiry so skipped sweeps cannot + // return stale values. if (nowMs - lastPruneAt < resolvePruneIntervalMs(ttlMs, options.pruneIntervalMs)) { return; } @@ -146,6 +153,7 @@ type FileStatSnapshot = { sizeBytes: number; }; +/** Captures the file attributes used by cache invalidation without exposing fs.Stats. */ export function getFileStatSnapshot(filePath: string): FileStatSnapshot | undefined { try { const stats = fs.statSync(filePath); diff --git a/src/config/channel-capabilities.ts b/src/config/channel-capabilities.ts index b2d4338b6f1b..dd9df42a1c2b 100644 --- a/src/config/channel-capabilities.ts +++ b/src/config/channel-capabilities.ts @@ -37,6 +37,7 @@ function resolveAccountCapabilities(params: { if (accounts && typeof accounts === "object") { const match = resolveAccountEntry(accounts, normalizedAccountId); if (match) { + // Account capabilities override provider capabilities; empty/object account values fall back. return normalizeCapabilities(match.capabilities) ?? normalizeCapabilities(cfg.capabilities); } } @@ -44,6 +45,7 @@ function resolveAccountCapabilities(params: { return normalizeCapabilities(cfg.capabilities); } +/** Resolves normalized string capabilities for a channel/account config pair. */ export function resolveChannelCapabilities(params: { cfg?: Partial; channel?: string | null; diff --git a/src/config/channel-compat-normalization.ts b/src/config/channel-compat-normalization.ts index 876113af2815..45eabeb134ba 100644 --- a/src/config/channel-compat-normalization.ts +++ b/src/config/channel-compat-normalization.ts @@ -6,6 +6,7 @@ import { export { normalizeLegacyDmAliases }; export type { CompatMutationResult }; +/** Resolved streaming values a channel doctor supplies while migrating legacy aliases. */ export type LegacyStreamingAliasOptions = { resolvedMode: string; includePreviewChunk?: boolean; @@ -13,6 +14,7 @@ export type LegacyStreamingAliasOptions = { offModeLegacyNotice?: (pathPrefix: string) => string; }; +/** Account-level channel config passed to channel-specific doctor migrations. */ export type NormalizeLegacyChannelAccountParams = { account: Record; accountId: string; @@ -20,12 +22,14 @@ export type NormalizeLegacyChannelAccountParams = { changes: string[]; }; +/** Narrows unknown config JSON values to mutable object records. */ export function asObjectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } +/** Checks whether any account entry still carries a channel-specific legacy alias. */ export function hasLegacyAccountStreamingAliases( value: unknown, match: (entry: unknown) => boolean, @@ -40,11 +44,18 @@ export function hasLegacyAccountStreamingAliases( function ensureNestedRecord(owner: Record, key: string): Record { const existing = asObjectRecord(owner[key]); if (existing) { + // Clone nested records before migration so callers keep immutable before/after snapshots. return { ...existing }; } return {}; } +/** + * Moves legacy flat streaming aliases into the nested `streaming` config shape. + * + * Existing nested values win over legacy aliases, matching doctor migration rules + * that preserve explicit modern config while removing stale compatibility keys. + */ export function normalizeLegacyStreamingAliases( params: { entry: Record; @@ -75,6 +86,7 @@ export function normalizeLegacyStreamingAliases( const block = ensureNestedRecord(streaming, "block"); const preview = ensureNestedRecord(streaming, "preview"); + // Only fill `streaming.mode` when the modern nested field is absent. if ( (hadLegacyStreamMode || typeof beforeStreaming === "boolean" || @@ -177,6 +189,12 @@ export function normalizeLegacyStreamingAliases( return { entry: updated, changed }; } +/** + * Runs generic channel doctor alias migration for the root entry and accounts. + * + * Channel plugins provide streaming resolution and optional account-specific + * migrations so core can keep one compatibility path for all channel shapes. + */ export function normalizeLegacyChannelAliases(params: { entry: Record; pathPrefix: string; @@ -269,6 +287,7 @@ export function normalizeLegacyChannelAliases(params: { return { entry: updated, changed }; } +/** Detects legacy streaming aliases on one channel or account config entry. */ export function hasLegacyStreamingAliases( value: unknown, options?: { includePreviewChunk?: boolean; includeNativeTransport?: boolean }, diff --git a/src/config/channel-config-metadata.ts b/src/config/channel-config-metadata.ts index fba7727e8d44..12ae9f0af04d 100644 --- a/src/config/channel-config-metadata.ts +++ b/src/config/channel-config-metadata.ts @@ -1,3 +1,7 @@ +/** + * Converts plugin manifest metadata into deterministic config UI metadata for docs, validation, and runtime schema. + * When multiple plugin origins expose the same id/channel, the closest origin owns the surfaced schema. + */ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import type { ChannelUiMetadata, PluginUiMetadata } from "./schema.js"; @@ -7,12 +11,14 @@ type ChannelMetadataRecord = ChannelUiMetadata & { }; const PLUGIN_ORIGIN_RANK: Readonly> = { + // Lower ranks are closer to the operator and should override farther bundled/global metadata. config: 0, workspace: 1, global: 2, bundled: 3, }; +/** Collects plugin config UI metadata with deterministic origin precedence and output ordering. */ export function collectPluginSchemaMetadata(registry: PluginManifestRegistry): PluginUiMetadata[] { const deduped = new Map< string, @@ -24,6 +30,7 @@ export function collectPluginSchemaMetadata(registry: PluginManifestRegistry): P for (const record of registry.plugins) { const current = deduped.get(record.id); const nextRank = PLUGIN_ORIGIN_RANK[record.origin] ?? Number.MAX_SAFE_INTEGER; + // Prefer the closest install origin when the same plugin id appears in multiple registries. if (current && current.originRank <= nextRank) { continue; } @@ -42,6 +49,7 @@ export function collectPluginSchemaMetadata(registry: PluginManifestRegistry): P .map(({ originRank: _originRank, ...record }) => record); } +/** Collects per-channel config UI metadata from plugin manifests and channel config blocks. */ export function collectChannelSchemaMetadata( registry: PluginManifestRegistry, ): ChannelUiMetadata[] { @@ -54,6 +62,8 @@ export function collectChannelSchemaMetadata( for (const channelId of record.channels) { const current = byChannelId.get(channelId); + // Root channel catalog metadata can fill labels/descriptions before a channel-specific + // config block appears, but it must not overwrite a closer-origin channel entry. if (!current || originRank <= current.originRank) { byChannelId.set(channelId, { id: channelId, @@ -73,6 +83,8 @@ export function collectChannelSchemaMetadata( current.originRank < originRank && (current.configSchema !== undefined || current.configUiHints !== undefined) ) { + // A closer-origin channel config owns schema/UI hints even if a farther plugin also + // advertises the same channel id. continue; } byChannelId.set(channelId, { diff --git a/src/config/channel-configured-shared.ts b/src/config/channel-configured-shared.ts index 253cc43c5c08..f05f11d4f3fe 100644 --- a/src/config/channel-configured-shared.ts +++ b/src/config/channel-configured-shared.ts @@ -2,6 +2,7 @@ import { getChannelEnvVars } from "../secrets/channel-env-vars.js"; import { isRecord } from "../utils.js"; import type { OpenClawConfig } from "./config.js"; +/** Returns a channel config object when `channels.` is present and object-shaped. */ export function resolveChannelConfigRecord( cfg: OpenClawConfig, channelId: string, @@ -11,17 +12,20 @@ export function resolveChannelConfigRecord( return isRecord(entry) ? entry : null; } +/** Checks whether a shallow channel config contains activation-relevant values. */ export function hasMeaningfulChannelConfigShallow(value: unknown): boolean { if (!isRecord(value)) { return false; } const keys = Object.keys(value); if (keys.length === 1 && keys[0] === "enabled") { + // `enabled: false` alone is an explicit non-configuration signal, but true opts in. return value.enabled === true; } return keys.some((key) => key !== "enabled"); } +/** Detects static channel configuration from known env vars or `channels.` config. */ export function isStaticallyChannelConfigured( cfg: OpenClawConfig, channelId: string, diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts index 997afe8a551a..ea1fb546e2da 100644 --- a/src/config/channel-configured.ts +++ b/src/config/channel-configured.ts @@ -6,17 +6,22 @@ import { } from "./channel-configured-shared.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Resolves whether a channel has enough config, env, or plugin state to be considered setup. */ export function isChannelConfigured( cfg: OpenClawConfig, channelId: string, env: NodeJS.ProcessEnv = process.env, ): boolean { + // Treat explicit persisted config as configured before consulting channel-specific env/state + // probes; user-authored config should win over inferred setup state. if (hasMeaningfulChannelConfigShallow(resolveChannelConfigRecord(cfg, channelId))) { return true; } + // Bundled channels can expose configured state through env vars or persisted credential files. if (hasBundledChannelConfiguredState({ channelId, cfg, env })) { return true; } + // Bootstrap plugins cover channels that are available before full plugin registry loading. const plugin = getBootstrapChannelPlugin(channelId); return Boolean(plugin?.config?.hasConfiguredState?.({ cfg, env })); } diff --git a/src/config/codex-plugin-diagnostics.ts b/src/config/codex-plugin-diagnostics.ts index 64adb5d11115..818ea85131b1 100644 --- a/src/config/codex-plugin-diagnostics.ts +++ b/src/config/codex-plugin-diagnostics.ts @@ -187,6 +187,12 @@ function openAiDefaultRouteKeepsCodexUnavailable(cfg: OpenClawConfig): boolean { return !isOpenAiCodexDefaultRuntimeSelection({ cfg, raw: policy.id }); } +/** + * Reports whether the default OpenAI route intentionally avoids the Codex plugin. + * + * Route-specific Codex selections still win; this only answers the missing-plugin + * diagnostic question for OpenAI defaults and OpenAI-compatible proxy configs. + */ export function configExplicitlyKeepsCodexUnavailableForOpenAi(cfg: OpenClawConfig): boolean { if (openAiHasCodexDefaultRuntimePolicy(cfg)) { return false; @@ -194,6 +200,12 @@ export function configExplicitlyKeepsCodexUnavailableForOpenAi(cfg: OpenClawConf return openAiDefaultRouteKeepsCodexUnavailable(cfg); } +/** + * Suppresses missing Codex plugin diagnostics when config makes Codex optional. + * + * Explicitly enabled entries still warn so operator intent is honored even when + * all default routes would otherwise stay on the OpenClaw runtime. + */ export function shouldSuppressMissingCodexPluginDiagnostics(cfg: OpenClawConfig): boolean { const entryEnabled = codexPluginEntryEnabled(cfg); if (entryEnabled === true) { diff --git a/src/config/commands.flags.ts b/src/config/commands.flags.ts index 4d1967ec55fc..36086780b701 100644 --- a/src/config/commands.flags.ts +++ b/src/config/commands.flags.ts @@ -1,6 +1,7 @@ import { isPlainObject } from "../infra/plain-object.js"; import type { CommandsConfig } from "./types.js"; +/** Boolean command flags accepted by the normalized commands config. */ export type CommandFlagKey = { [K in keyof CommandsConfig]-?: Exclude extends boolean ? K : never; }[keyof CommandsConfig]; @@ -16,6 +17,7 @@ function getOwnCommandFlagValue( return commands[key]; } +/** Returns true only when a command flag is explicitly enabled. */ export function isCommandFlagEnabled( config: { commands?: unknown } | undefined, key: CommandFlagKey, @@ -23,6 +25,7 @@ export function isCommandFlagEnabled( return getOwnCommandFlagValue(config, key) === true; } +/** Returns the public restart command state; restart defaults on and is disabled only by false. */ export function isRestartEnabled(config?: { commands?: unknown }): boolean { return getOwnCommandFlagValue(config, "restart") !== false; } diff --git a/src/config/commands.ts b/src/config/commands.ts index 42837c871365..7b02bd01449a 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -24,6 +24,7 @@ function resolveAutoDefault( if (typeof options?.autoDefault === "boolean") { return options.autoDefault; } + // Prefer live plugin metadata; fall back to read-only manifest defaults during cold config paths. const commandDefaults = getLoadedChannelPlugin(id)?.commands ?? (options?.config @@ -38,6 +39,7 @@ function resolveAutoDefault( return commandDefaults?.nativeSkillsAutoEnabled === true; } +/** Resolves native skill exposure for a provider, with provider config overriding global config. */ export function resolveNativeSkillsEnabled(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; @@ -51,6 +53,7 @@ export function resolveNativeSkillsEnabled(params: { return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" }); } +/** Resolves native command exposure for a provider, with provider config overriding global config. */ export function resolveNativeCommandsEnabled(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; @@ -86,6 +89,7 @@ function resolveNativeCommandSetting(params: { return resolveAutoDefault(providerId, kind, options); } +/** Returns true only when native commands are explicitly disabled by provider or inherited global config. */ export function isNativeCommandsExplicitlyDisabled(params: { providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; diff --git a/src/config/config-env-vars.ts b/src/config/config-env-vars.ts index ec63a63ac740..1c425f8a8f9c 100644 --- a/src/config/config-env-vars.ts +++ b/src/config/config-env-vars.ts @@ -54,14 +54,18 @@ function collectConfigEnvVarsByTarget(cfg?: OpenClawConfig): Record { return collectConfigEnvVarsByTarget(cfg); } +/** Collects config env vars safe to persist into managed service environments. */ export function collectConfigServiceEnvVars(cfg?: OpenClawConfig): Record { + // Runtime and service envs intentionally share filtering until a target-specific contract exists. return collectConfigEnvVarsByTarget(cfg); } +/** Builds a cloned environment with config env vars applied without mutating the base env. */ export function createConfigRuntimeEnv( cfg: OpenClawConfig, baseEnv: NodeJS.ProcessEnv = process.env, @@ -71,6 +75,7 @@ export function createConfigRuntimeEnv( return env; } +/** Applies config env vars to an environment without overwriting existing non-empty values. */ export function applyConfigEnvVars( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts index 1bdd9275e325..d6e672588540 100644 --- a/src/config/config-paths.ts +++ b/src/config/config-paths.ts @@ -3,6 +3,7 @@ import { isBlockedObjectKey } from "./prototype-keys.js"; type PathNode = Record; +/** Parses CLI/config dot-notation paths and rejects unsafe object-key segments. */ export function parseConfigPath(raw: string): { ok: boolean; path?: string[]; @@ -22,12 +23,15 @@ export function parseConfigPath(raw: string): { error: "Invalid path. Use dot notation (e.g. foo.bar).", }; } + // These helpers mutate plain objects; block prototype-bearing keys before any setter can create + // or traverse them. if (parts.some((part) => isBlockedObjectKey(part))) { return { ok: false, error: "Invalid path segment." }; } return { ok: true, path: parts }; } +/** Sets a value at a validated config path, creating missing plain-object parents. */ export function setConfigValueAtPath(root: PathNode, path: string[], value: unknown): void { let cursor: PathNode = root; for (let idx = 0; idx < path.length - 1; idx += 1) { @@ -41,6 +45,7 @@ export function setConfigValueAtPath(root: PathNode, path: string[], value: unkn cursor[path[path.length - 1]] = value; } +/** Removes a value at a config path and prunes empty parent objects created by setters. */ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean { const stack: Array<{ node: PathNode; key: string }> = []; let cursor: PathNode = root; @@ -58,6 +63,8 @@ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean return false; } delete cursor[leafKey]; + // Keep config writes tidy: removing foo.bar should also remove foo when it became empty, while + // preserving any parent that still carries sibling config. for (let idx = stack.length - 1; idx >= 0; idx -= 1) { const { node, key } = stack[idx]; const child = node[key]; @@ -70,6 +77,7 @@ export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean return true; } +/** Reads a value from a config path, stopping at the first non-plain-object parent. */ export function getConfigValueAtPath(root: PathNode, path: string[]): unknown { let cursor: unknown = root; for (const key of path) { diff --git a/src/config/config.backup-rotation.test-helpers.ts b/src/config/config.backup-rotation.test-helpers.ts index 773743244430..e9e7045770c1 100644 --- a/src/config/config.backup-rotation.test-helpers.ts +++ b/src/config/config.backup-rotation.test-helpers.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { expect } from "vitest"; +/** Platform flag shared by config backup permission tests. */ export const IS_WINDOWS = process.platform === "win32"; export function resolveConfigPathFromTempState(fileName = "openclaw.json"): string { diff --git a/src/config/config.ts b/src/config/config.ts index 712d470e3bea..784352139770 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,3 +1,4 @@ +// Public config facade for IO, mutation, runtime snapshots, paths, and shared config types. export { clearConfigCache, ConfigRuntimeRefreshError, diff --git a/src/config/context-visibility.ts b/src/config/context-visibility.ts index 10f1ca8943b9..9e3b5248311d 100644 --- a/src/config/context-visibility.ts +++ b/src/config/context-visibility.ts @@ -4,28 +4,43 @@ import type { OpenClawConfig } from "./config.js"; import type { ContextVisibilityMode } from "./types.base.js"; type ChannelContextVisibilityConfig = { + /** + * Channel-wide supplemental context visibility mode. + */ contextVisibility?: ContextVisibilityMode; + /** + * Account-specific visibility overrides keyed by configured channel account id. + */ accounts?: Record; }; type ContextVisibilityDefaultsConfig = { channels?: { defaults?: { + /** + * Global default supplemental context visibility for channels without a local override. + */ contextVisibility?: ContextVisibilityMode; }; }; }; +/** Reads the global channel default supplemental context visibility mode. */ export function resolveDefaultContextVisibility( cfg: ContextVisibilityDefaultsConfig, ): ContextVisibilityMode | undefined { return cfg.channels?.defaults?.contextVisibility; } +/** Resolves supplemental context visibility using explicit, account, channel, default precedence. */ export function resolveChannelContextVisibilityMode(params: { + /** Full OpenClaw config containing channel defaults and per-channel overrides. */ cfg: OpenClawConfig; + /** Channel id whose visibility policy is being resolved. */ channel: string; + /** Optional channel account id used for account-specific overrides. */ accountId?: string | null; + /** Runtime adapter override that takes precedence over config-backed policy. */ configuredContextVisibility?: ContextVisibilityMode; }): ContextVisibilityMode { if (params.configuredContextVisibility) { @@ -36,6 +51,8 @@ export function resolveChannelContextVisibilityMode(params: { | undefined; const accountId = normalizeAccountId(params.accountId); const accountMode = resolveAccountEntry(channelConfig?.accounts, accountId)?.contextVisibility; + // Preserve the public precedence order: adapter override, account override, + // channel override, global default, then permissive legacy default. return ( accountMode ?? channelConfig?.contextVisibility ?? diff --git a/src/config/control-ui-css.ts b/src/config/control-ui-css.ts index 111eef44c4ae..6a5c818d2111 100644 --- a/src/config/control-ui-css.ts +++ b/src/config/control-ui-css.ts @@ -24,6 +24,7 @@ function hasBalancedParentheses(value: string): boolean { function hasAllowedIdentifiers(value: string): boolean { for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) { const identifier = match[0].toLowerCase(); + // Keep the config parser to width-only CSS tokens; reject arbitrary identifiers/properties. if ( !CSS_WIDTH_FUNCTIONS.has(identifier) && !CSS_WIDTH_KEYWORDS.has(identifier) && @@ -35,10 +36,12 @@ function hasAllowedIdentifiers(value: string): boolean { return true; } +/** Normalizes operator-provided Control UI chat max-width CSS values before validation. */ export function normalizeControlUiChatMessageMaxWidth(value: string): string { return value.trim().replace(/\s+/g, " "); } +/** Validates the constrained CSS width grammar accepted by `gateway.controlUi.chatMessageMaxWidth`. */ export function isValidControlUiChatMessageMaxWidth(value: string): boolean { const normalized = normalizeControlUiChatMessageMaxWidth(value); if (normalized.length === 0 || normalized.length > CSS_WIDTH_MAX_LENGTH) { diff --git a/src/config/cron-limits.ts b/src/config/cron-limits.ts index b5310dd2017e..62977014d270 100644 --- a/src/config/cron-limits.ts +++ b/src/config/cron-limits.ts @@ -1,7 +1,9 @@ import type { CronConfig } from "./types.cron.js"; +/** Default maximum number of cron jobs allowed to run at once. */ export const DEFAULT_CRON_MAX_CONCURRENT_RUNS = 8; +/** Resolves cron concurrency config, flooring finite values and clamping to at least one. */ export function resolveCronMaxConcurrentRuns( cronConfig?: Pick, ): number { diff --git a/src/config/dangerous-name-matching.ts b/src/config/dangerous-name-matching.ts index 86a4b81c116d..700a73fda2f0 100644 --- a/src/config/dangerous-name-matching.ts +++ b/src/config/dangerous-name-matching.ts @@ -24,12 +24,14 @@ function asObjectRecord(value: unknown): Record | null { return value as Record; } +/** Returns true only for the explicit dangerous name-matching opt-in flag. */ export function isDangerousNameMatchingEnabled( config: DangerousNameMatchingConfig | null | undefined, ): boolean { return config?.dangerouslyAllowNameMatching === true; } +/** Resolves account-level dangerous name matching, inheriting the provider flag when unset. */ export function resolveDangerousNameMatchingEnabled( input: DangerousNameMatchingResolverInput, ): boolean { @@ -39,6 +41,7 @@ export function resolveDangerousNameMatchingEnabled( return isDangerousNameMatchingEnabled(input.providerConfig); } +/** Collects provider/account scopes that policy and doctor surfaces can audit. */ export function collectProviderDangerousNameMatchingScopes( cfg: OpenClawConfig, provider: string, @@ -82,6 +85,7 @@ export function collectProviderDangerousNameMatchingScopes( scopes.push({ prefix: accountPrefix, account, + // Account config can override the provider opt-in; nullish means inherit provider state. dangerousNameMatchingEnabled: accountDangerousNameMatching ?? providerDangerousNameMatchingEnabled, dangerousFlagPath: diff --git a/src/config/doc-baseline.runtime.ts b/src/config/doc-baseline.runtime.ts index 6180b6a1b9b1..7104bd41a8d3 100644 --- a/src/config/doc-baseline.runtime.ts +++ b/src/config/doc-baseline.runtime.ts @@ -6,6 +6,7 @@ import { } from "./channel-config-metadata.js"; import { buildConfigSchema as buildConfigSchemaImpl } from "./schema.js"; +/** Runtime facade used by docs baseline generation to keep imports narrow. */ export const loadPluginManifestRegistry = loadPluginManifestRegistryImpl; export const collectBundledChannelConfigs = collectBundledChannelConfigsImpl; export const collectChannelSchemaMetadata = collectChannelSchemaMetadataImpl; diff --git a/src/config/env-substitution.ts b/src/config/env-substitution.ts index ae80434f409e..37f57dd77d1a 100644 --- a/src/config/env-substitution.ts +++ b/src/config/env-substitution.ts @@ -26,6 +26,7 @@ import { isPlainObject } from "../utils.js"; const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; +/** Error thrown when a config value references a missing or empty environment variable. */ export class MissingEnvVarError extends Error { constructor( public readonly varName: string, @@ -50,6 +51,7 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null { // Escaped: $${VAR} -> ${VAR} if (next === "$" && afterNext === "{") { + // Parse escaped placeholders before substitutions so "$${VAR}" never resolves from env. const start = index + 3; const end = value.indexOf("}", start); if (end !== -1) { @@ -75,6 +77,7 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null { return null; } +/** Missing environment variable warning emitted when substitution is configured to continue. */ export type EnvSubstitutionWarning = { varName: string; configPath: string; @@ -134,6 +137,7 @@ function substituteString( return chunks.join(""); } +/** Detects unescaped `${VAR}` references without treating escaped `$${VAR}` as references. */ export function containsEnvVarReference(value: string): boolean { if (!value.includes("$")) { return false; diff --git a/src/config/env-vars.ts b/src/config/env-vars.ts index 5708b95acf4e..6af07a5e4ae4 100644 --- a/src/config/env-vars.ts +++ b/src/config/env-vars.ts @@ -1,3 +1,4 @@ +// Public facade for config env var collection and durable state-dir dotenv reads. export { applyConfigEnvVars, collectConfigRuntimeEnvVars, diff --git a/src/config/exec-command-highlighting.ts b/src/config/exec-command-highlighting.ts index 11c68fd18057..4395ee0206e5 100644 --- a/src/config/exec-command-highlighting.ts +++ b/src/config/exec-command-highlighting.ts @@ -1,6 +1,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Resolves whether exec command highlighting is enabled for the current agent scope. */ export function resolveExecCommandHighlighting(params: { config?: OpenClawConfig | null; agentId?: string | null; @@ -12,5 +13,6 @@ export function resolveExecCommandHighlighting(params: { ? config.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec ?.commandHighlighting : undefined; + // Agent-scoped config overrides the global exec setting; absent config stays disabled. return agentValue ?? globalValue ?? false; } diff --git a/src/config/future-version-guard.ts b/src/config/future-version-guard.ts index d8441523d417..633d82b30508 100644 --- a/src/config/future-version-guard.ts +++ b/src/config/future-version-guard.ts @@ -2,9 +2,11 @@ import { VERSION } from "../version.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; import { shouldWarnOnTouchedVersion } from "./version.js"; +/** Override env var for intentional older-binary destructive config actions. */ export const ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV = "OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS"; +/** Block payload shown when an older binary would mutate newer-written config. */ export type FutureConfigActionBlock = { action: string; currentVersion: string; @@ -27,6 +29,7 @@ function allowOlderBinaryDestructiveActions(env: Record 0 ? port : fallback; } +/** Builds loopback plus custom-bind Control UI origins for a resolved gateway port. */ export function buildDefaultControlUiAllowedOrigins(params: { port: number; bind: unknown; @@ -43,6 +48,7 @@ export function buildDefaultControlUiAllowedOrigins(params: { return [...origins]; } +/** Seeds safe default Control UI origins before non-loopback gateway startup validation. */ export function ensureControlUiAllowedOriginsForNonLoopbackBind( config: OpenClawConfig, opts?: { diff --git a/src/config/gateway-dispatch-config.ts b/src/config/gateway-dispatch-config.ts index 6c364c8ddcee..e9be8722be04 100644 --- a/src/config/gateway-dispatch-config.ts +++ b/src/config/gateway-dispatch-config.ts @@ -21,6 +21,7 @@ const GATEWAY_DISPATCH_TOP_LEVEL_KEYS = [ "session", ] as const; +/** Options for reading the reduced config surface used by Gateway dispatch. */ type GatewayDispatchConfigReadOptions = { configPath?: string; env?: NodeJS.ProcessEnv; @@ -58,6 +59,7 @@ function projectGatewayDispatchConfig(value: unknown): OpenClawConfig { return projected as OpenClawConfig; } +// Main session keys are process-local; Gateway dispatch always sees the canonical main key. function applyGatewayDispatchSessionDefaults(config: OpenClawConfig): OpenClawConfig { if (config.session?.mainKey === undefined) { return config; diff --git a/src/config/home-env.test-harness.ts b/src/config/home-env.test-harness.ts index 095f8c9ed51e..7e4f35922e1d 100644 --- a/src/config/home-env.test-harness.ts +++ b/src/config/home-env.test-harness.ts @@ -1,5 +1,6 @@ import { createTempHomeEnv } from "../test-utils/temp-home.js"; +/** Runs config tests with a temporary OpenClaw home and restores state afterward. */ export async function withTempHome( prefix: string, fn: (home: string) => Promise, diff --git a/src/config/includes-scan.ts b/src/config/includes-scan.ts index 16e42e1c04eb..42d9776fd479 100644 --- a/src/config/includes-scan.ts +++ b/src/config/includes-scan.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "./includes.js"; +// Include discovery walks nested config objects because include blocks may be embedded. function listDirectIncludes(parsed: unknown): string[] { const out: string[] = []; const visit = (value: unknown) => { @@ -45,6 +46,7 @@ function resolveIncludePath(baseConfigPath: string, includePath: string): string ); } +/** Collects recursively referenced config include files without requiring a valid full config. */ export async function collectIncludePathsRecursive(params: { configPath: string; parsed: unknown; diff --git a/src/config/io.clobber-snapshot.ts b/src/config/io.clobber-snapshot.ts index 011e7551a8f7..981e5ae26fd9 100644 --- a/src/config/io.clobber-snapshot.ts +++ b/src/config/io.clobber-snapshot.ts @@ -1,5 +1,6 @@ import path from "node:path"; +/** Maximum retained clobbered-config snapshots per config file. */ export const CONFIG_CLOBBER_SNAPSHOT_LIMIT = 32; const CONFIG_CLOBBER_LOCK_STALE_MS = 30_000; diff --git a/src/config/io.invalid-config.ts b/src/config/io.invalid-config.ts index 1519f297f671..cdcfce50ed96 100644 --- a/src/config/io.invalid-config.ts +++ b/src/config/io.invalid-config.ts @@ -1,23 +1,32 @@ +/** + * Shared invalid-config formatting, logging, and error helpers for config reads and mutations. + * All terminal-facing text is sanitized here so callers can reuse the same failure surface. + */ import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +/** Minimal validation issue shape accepted from schema and mutation validation paths. */ type ConfigValidationIssueLike = { path: string; message: string; }; +/** Formats validation issues as terminal-safe bullet lines for config load failures. */ export function formatInvalidConfigDetails(issues: ConfigValidationIssueLike[]): string { return issues .map( (issue) => + // Validation paths/messages can contain user config text; sanitize before terminal output. `- ${sanitizeTerminalText(issue.path || "")}: ${sanitizeTerminalText(issue.message)}`, ) .join("\n"); } +/** Builds the one-line invalid-config prefix plus preformatted validation details. */ export function formatInvalidConfigLogMessage(configPath: string, details: string): string { return `Invalid config at ${configPath}:\\n${details}`; } +/** Logs an invalid config message once per path during a load sequence. */ export function logInvalidConfigOnce(params: { configPath: string; details: string; @@ -25,19 +34,23 @@ export function logInvalidConfigOnce(params: { loggedConfigPaths: Set; }): void { if (params.loggedConfigPaths.has(params.configPath)) { + // Avoid repeating the same invalid config block when multiple callers observe the same path. return; } params.loggedConfigPaths.add(params.configPath); params.logger.error(formatInvalidConfigLogMessage(params.configPath, params.details)); } +/** Creates the tagged error shape used by callers that need details after catch. */ export function createInvalidConfigError(configPath: string, details: string): Error { const error = new Error(`Invalid config at ${configPath}:\n${details}`); + // Keep metadata non-class-based so cross-module callers can inspect plain Error instances. (error as { code?: string; details?: string }).code = "INVALID_CONFIG"; (error as { code?: string; details?: string }).details = details; return error; } +/** Logs and throws the standard invalid-config error for a validation result. */ export function throwInvalidConfig(params: { configPath: string; issues: ConfigValidationIssueLike[]; diff --git a/src/config/io.meta.ts b/src/config/io.meta.ts index 65f89213c0dc..80cb5a355496 100644 --- a/src/config/io.meta.ts +++ b/src/config/io.meta.ts @@ -1,6 +1,7 @@ import { VERSION } from "../version.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Metadata keys automatically stamped on config writes. */ export const AUTO_MANAGED_CONFIG_META_FIELDS = { lastTouchedVersion: "lastTouchedVersion", lastTouchedAt: "lastTouchedAt", diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index a1b45366d232..b0a20f9c8a28 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -19,6 +19,7 @@ import { } from "./recovery-policy.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; +/** Dependencies injected into config recovery observation for testable filesystem behavior. */ export type ObserveRecoveryDeps = { fs: { promises: { diff --git a/src/config/io.owner-display-secret.ts b/src/config/io.owner-display-secret.ts index d5a1777f85fe..e4c67933f7c4 100644 --- a/src/config/io.owner-display-secret.ts +++ b/src/config/io.owner-display-secret.ts @@ -1,9 +1,15 @@ +/** + * Runtime-only owner display secret retention for config IO. + * Generated secrets stay in memory by config path and are never written back into config files. + */ import type { OpenClawConfig } from "./types.openclaw.js"; +/** Runtime-only owner display secrets keyed by config path during config IO. */ export type OwnerDisplaySecretRuntimeState = { pendingByPath: Map; }; +/** Retains generated owner display secrets in memory without persisting them into config. */ export function retainGeneratedOwnerDisplaySecret(params: { config: OpenClawConfig; configPath: string; @@ -12,10 +18,13 @@ export function retainGeneratedOwnerDisplaySecret(params: { }): OpenClawConfig { const { config, configPath, generatedSecret, state } = params; if (!generatedSecret) { + // Clear stale pending secrets once the config load no longer generated one for this path. state.pendingByPath.delete(configPath); return config; } + // Keep the generated secret available to runtime callers while preserving config object identity + // and avoiding a write of the secret back to disk. state.pendingByPath.set(configPath, generatedSecret); return config; } diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 121fead635ac..0b6b2ff544e4 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -22,10 +22,12 @@ type ManifestModelIdNormalizationProvider = { }[]; }; +// Clone config fragments before patching so mutation preparation never aliases callers. function cloneUnknown(value: T): T { return structuredClone(value); } +/** Builds an RFC-7396-style merge patch between source and target config values. */ export function createMergePatch(base: unknown, target: unknown): unknown { if (!isRecord(base) || !isRecord(target)) { return cloneUnknown(target); diff --git a/src/config/issue-format.ts b/src/config/issue-format.ts index 01e18f8e622b..74d468d2d0a5 100644 --- a/src/config/issue-format.ts +++ b/src/config/issue-format.ts @@ -14,6 +14,7 @@ type ConfigIssueSummaryOptions = ConfigIssueFormatOptions & { maxIssues?: number; }; +/** Normalize missing or blank config issue paths to the root marker used in CLI output. */ export function normalizeConfigIssuePath(path: string | null | undefined): string { if (typeof path !== "string") { return ""; @@ -22,6 +23,7 @@ export function normalizeConfigIssuePath(path: string | null | undefined): strin return trimmed ? trimmed : ""; } +/** Return the public config issue shape with a normalized path and non-empty allowed values. */ export function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIssue { const hasAllowedValues = Array.isArray(issue.allowedValues) && issue.allowedValues.length > 0; return { @@ -36,6 +38,7 @@ export function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValida }; } +/** Normalize a batch of config validation issues for display or JSON output. */ export function normalizeConfigIssues( issues: ReadonlyArray, ): ConfigValidationIssue[] { @@ -52,6 +55,10 @@ function resolveIssuePathForLine( return typeof path === "string" ? path : ""; } +/** + * Format one config issue for terminal output. + * Path and message are sanitized because issues can include user-edited config text. + */ export function formatConfigIssueLine( issue: ConfigIssueLineInput, marker = "-", @@ -63,6 +70,7 @@ export function formatConfigIssueLine( return `${prefix}${path}: ${message}`; } +/** Format config issues as terminal-safe lines with a shared marker prefix. */ export function formatConfigIssueLines( issues: ReadonlyArray, marker = "-", @@ -71,6 +79,7 @@ export function formatConfigIssueLines( return issues.map((issue) => formatConfigIssueLine(issue, marker, opts)); } +/** Build a compact, terminal-safe issue summary for logs and recovery diagnostics. */ export function formatConfigIssueSummary( issues: ReadonlyArray, opts: ConfigIssueSummaryOptions = {}, @@ -88,5 +97,6 @@ export function formatConfigIssueSummary( if (hiddenIssueCount <= 0) { return lines.join("; "); } + // Keep log lines bounded while preserving the exact hidden count for triage. return `${lines.join("; ")}; and ${hiddenIssueCount} more`; } diff --git a/src/config/legacy-config-detection.test-support.ts b/src/config/legacy-config-detection.test-support.ts index ca22b144bf48..8403cd7f3ee8 100644 --- a/src/config/legacy-config-detection.test-support.ts +++ b/src/config/legacy-config-detection.test-support.ts @@ -4,6 +4,7 @@ type SchemaParseResult = | { success: true; data: TData } | { success: false; error: { issues: Array<{ path: PropertyKey[]; message?: string }> } }; +/** Asserts a schema accepts config and exposes the expected normalized value. */ export function expectSchemaConfigValue(params: { schema: { safeParse: (value: unknown) => SchemaParseResult }; config: unknown; diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 9ee7cbcfbdb5..9860910cf22f 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -1 +1,2 @@ +// Public facade for shared legacy config doctor rules. export { LEGACY_CONFIG_RULES } from "../commands/doctor/shared/legacy-config-rules.js"; diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 47a05c2908ed..df7e1f5db749 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -2,6 +2,7 @@ import { LEGACY_CONFIG_RULES } from "./legacy.rules.js"; import type { LegacyConfigRule } from "./legacy.shared.js"; import type { LegacyConfigIssue } from "./types.js"; +// Legacy checks use raw dotted paths so doctor can report exact config keys. function getPathValue(root: Record, path: string[]): unknown { let cursor: unknown = root; for (const key of path) { @@ -13,6 +14,7 @@ function getPathValue(root: Record, path: string[]): unknown { return cursor; } +/** Finds legacy config issues using built-in rules plus optional caller rules. */ export function findLegacyConfigIssues( raw: unknown, sourceRaw?: unknown, diff --git a/src/config/logging.ts b/src/config/logging.ts index 56fa1238ccbf..3cf62201afe6 100644 --- a/src/config/logging.ts +++ b/src/config/logging.ts @@ -10,10 +10,12 @@ type LogConfigUpdatedOptions = { suffix?: string; }; +/** Formats a config path for operator-facing log output. */ export function formatConfigPath(path: string = createConfigIO().configPath): string { return displayPath(path); } +/** Builds the config-updated log message, including backup detail only when it exists. */ export function formatConfigUpdatedMessage( path: string, opts: LogConfigUpdatedOptions = {}, @@ -23,11 +25,14 @@ export function formatConfigUpdatedMessage( const backupPath = opts.backupPath === undefined ? `${path}.bak` : opts.backupPath; const lines = [`Updated config: ${displayConfigPath}${suffix}`]; if (backupPath && fs.existsSync(backupPath)) { + // Only mention backups that were actually written; callers can pass `false` for flows that + // intentionally skip backup creation. lines.push(` Backup: ${theme.muted(formatConfigPath(backupPath))}`); } return lines.join("\n"); } +/** Emits the standard config-updated message through the active runtime logger. */ export function logConfigUpdated(runtime: RuntimeEnv, opts: LogConfigUpdatedOptions = {}): void { runtime.log(formatConfigUpdatedMessage(opts.path ?? createConfigIO().configPath, opts)); } diff --git a/src/config/markdown-tables.types.ts b/src/config/markdown-tables.types.ts index de790daebd58..82fa14a0fc63 100644 --- a/src/config/markdown-tables.types.ts +++ b/src/config/markdown-tables.types.ts @@ -1,6 +1,7 @@ import type { MarkdownTableMode } from "./types.base.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Parameters for resolving markdown table rendering per config and channel. */ export type ResolveMarkdownTableModeParams = { cfg?: Partial; channel?: string | null; diff --git a/src/config/materialize.ts b/src/config/materialize.ts index c6f017d0ed20..580160560f1b 100644 --- a/src/config/materialize.ts +++ b/src/config/materialize.ts @@ -16,6 +16,7 @@ import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./type type ConfigMaterializationMode = "load" | "missing" | "snapshot"; +/** Defaults profile selected for config load, missing-file, or snapshot materialization. */ type MaterializationProfile = { includeCompactionDefaults: boolean; includeContextPruningDefaults: boolean; diff --git a/src/config/mcp-config-normalize.ts b/src/config/mcp-config-normalize.ts index fd365611b450..3de88f90c4f7 100644 --- a/src/config/mcp-config-normalize.ts +++ b/src/config/mcp-config-normalize.ts @@ -14,6 +14,7 @@ function normalizeMcpString(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } +/** Maps CLI-native MCP type aliases to OpenClaw HTTP transport names. */ export function resolveOpenClawMcpTransportAlias( value: unknown, ): OpenClawMcpHttpTransport | undefined { @@ -21,15 +22,23 @@ export function resolveOpenClawMcpTransportAlias( return mapped === "sse" || mapped === "streamable-http" ? mapped : undefined; } +/** Checks whether a raw MCP `type` value is a legacy CLI alias OpenClaw can rewrite. */ export function isKnownCliMcpTypeAlias(value: unknown): boolean { return Object.hasOwn(CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT, normalizeMcpString(value)); } +/** + * Converts operator-friendly MCP server aliases into canonical config keys. + * + * Existing canonical fields win over legacy snake_case or `type` aliases so + * repeated configure commands cannot overwrite already-normalized choices. + */ export function canonicalizeConfiguredMcpServer( server: Record, ): Record { const next = { ...server }; const transportAlias = resolveOpenClawMcpTransportAlias(next.type); + // `transport` is OpenClaw's canonical field; legacy `type` only fills a gap. if (typeof next.transport !== "string" && transportAlias) { next.transport = transportAlias; } @@ -62,6 +71,7 @@ export function canonicalizeConfiguredMcpServer( return next; } +/** Returns a cloned map of object-shaped MCP server configs, dropping invalid entries. */ export function normalizeConfiguredMcpServers(value: unknown): ConfigMcpServers { if (!isRecord(value)) { return {}; diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index 0b1e53987fe4..a89baf9152eb 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -31,6 +31,7 @@ type ConfigMcpWriteResult = } | { ok: false; path: string; error: string }; +/** Include/exclude tool selection stored for a configured MCP server. */ export type McpServerToolSelection = { include?: string[]; exclude?: string[]; diff --git a/src/config/media-audio-field-metadata.ts b/src/config/media-audio-field-metadata.ts index bb04fb9e22ad..4d0e475ab0e3 100644 --- a/src/config/media-audio-field-metadata.ts +++ b/src/config/media-audio-field-metadata.ts @@ -1,3 +1,4 @@ +/** Config paths with user-facing metadata for audio understanding settings. */ export const MEDIA_AUDIO_FIELD_KEYS = [ "tools.media.audio.enabled", "tools.media.audio.maxBytes", diff --git a/src/config/merge-patch.ts b/src/config/merge-patch.ts index 8b76456bf503..39a53fbd035c 100644 --- a/src/config/merge-patch.ts +++ b/src/config/merge-patch.ts @@ -59,6 +59,12 @@ function mergeObjectArraysById( return merged; } +/** + * Applies an RFC 7396-style object merge patch with OpenClaw config safeguards. + * + * Non-object patches replace the base, `null` deletes keys, blocked prototype + * keys are ignored, and id-keyed arrays may merge when the caller opts in. + */ export function applyMergePatch( base: unknown, patch: unknown, @@ -79,6 +85,7 @@ export function applyMergePatch( continue; } if (options.mergeObjectArraysById && Array.isArray(result[key]) && Array.isArray(value)) { + // Config arrays like agents/plugins can patch by id; non-id arrays keep RFC replacement. const mergedArray = mergeObjectArraysById(result[key] as unknown[], value, options); if (mergedArray) { result[key] = mergedArray; diff --git a/src/config/model-input.ts b/src/config/model-input.ts index a4b7e9260a0d..2a4d17a23bf6 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -34,10 +34,12 @@ function modelKeyForConfig(provider: string, model: string): string { type AgentModelInput = AgentModelConfig | AgentToolModelConfig; +/** Returns the primary model ref from either string or object-style agent model config. */ export function resolveAgentModelPrimaryValue(model?: AgentModelInput): string | undefined { return resolvePrimaryStringValue(model); } +/** Returns configured fallback model refs, preserving their configured order. */ export function resolveAgentModelFallbackValues(model?: AgentModelInput): string[] { if (!model || typeof model !== "object") { return []; @@ -45,6 +47,7 @@ export function resolveAgentModelFallbackValues(model?: AgentModelInput): string return Array.isArray(model.fallbacks) ? model.fallbacks : []; } +/** Returns a positive finite tool timeout rounded down to whole milliseconds. */ export function resolveAgentModelTimeoutMsValue(model?: AgentToolModelConfig): number | undefined { if (!model || typeof model !== "object") { return undefined; @@ -56,6 +59,7 @@ export function resolveAgentModelTimeoutMsValue(model?: AgentToolModelConfig): n : undefined; } +/** Converts legacy string model config into the object shape used by model patch helpers. */ export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLike | undefined { if (typeof model === "string") { const primary = normalizeOptionalString(model); @@ -69,6 +73,7 @@ export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLi const GOOGLE_PROVIDER_IDS = new Set(["google", "google-gemini-cli", "google-vertex"]); +/** Canonicalizes provider/model refs before they are persisted to config. */ export function normalizeAgentModelRefForConfig(model: string): string { const trimmed = model.trim(); const slash = trimmed.indexOf("/"); @@ -103,6 +108,7 @@ function mergeAgentModelEntryForConfig(existing: unknown, incoming: unknown): un }; } +/** Normalizes model map keys and merges entries that collapse to the same canonical ref. */ export function normalizeAgentModelMapForConfig>(models: T): T { let mutated = false; const next: Record = {}; @@ -111,6 +117,7 @@ export function normalizeAgentModelMapForConfig>(); const configMutationQueueTails = new Map>(); +/** Raised when a config write loses an optimistic hash race. */ export class ConfigMutationConflictError extends Error { readonly currentHash: string | null; diff --git a/src/config/nix-mode-write-guard.ts b/src/config/nix-mode-write-guard.ts index e52add04887a..4d665dffd90f 100644 --- a/src/config/nix-mode-write-guard.ts +++ b/src/config/nix-mode-write-guard.ts @@ -1,8 +1,11 @@ import { resolveIsNixMode } from "./paths.js"; +/** Agent-first Nix install docs shown when runtime config writes are blocked. */ export const NIX_OPENCLAW_AGENT_FIRST_URL = "https://github.com/openclaw/nix-openclaw#quick-start"; +/** Public OpenClaw Nix overview shown with immutable-config errors. */ export const OPENCLAW_NIX_OVERVIEW_URL = "https://docs.openclaw.ai/install/nix"; +/** Error thrown when a mutating config path is attempted while Nix owns config state. */ export class NixModeConfigMutationError extends Error { readonly code = "OPENCLAW_NIX_MODE_CONFIG_IMMUTABLE"; @@ -12,6 +15,7 @@ export class NixModeConfigMutationError extends Error { } } +/** Build the operator-facing immutable-config message for Nix-managed installs. */ export function formatNixModeConfigMutationMessage(params: { configPath?: string } = {}): string { return [ "Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.", @@ -24,6 +28,7 @@ export function formatNixModeConfigMutationMessage(params: { configPath?: string ].join("\n"); } +/** Throw when the current environment marks OpenClaw config as Nix-managed and immutable. */ export function assertConfigWriteAllowedInCurrentMode( params: { configPath?: string; @@ -33,5 +38,6 @@ export function assertConfigWriteAllowedInCurrentMode( if (!resolveIsNixMode(params.env)) { return; } + // In Nix mode, all writes must happen in the declarative source and then rebuild. throw new NixModeConfigMutationError({ configPath: params.configPath }); } diff --git a/src/config/normalize-exec-safe-bin.ts b/src/config/normalize-exec-safe-bin.ts index f9bb9f52caf8..53fd8464b099 100644 --- a/src/config/normalize-exec-safe-bin.ts +++ b/src/config/normalize-exec-safe-bin.ts @@ -1,7 +1,12 @@ +/** + * Config normalization for exec safe-bin policy before materialized config is consumed. + * Keep this limited to persisted global/per-agent config shape; runtime trust decisions live in infra. + */ import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import type { OpenClawConfig } from "./types.js"; +/** Normalize exec safe-bin profiles and trusted dirs in global and per-agent config scopes. */ export function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void { const normalizeExec = (exec: unknown) => { if (!exec || typeof exec !== "object" || Array.isArray(exec)) { @@ -29,6 +34,7 @@ export function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void normalizedTrustedDirs.length > 0 ? normalizedTrustedDirs : undefined; }; + // Safe-bin config can be set globally or overridden per agent; normalize both persisted scopes. normalizeExec(cfg.tools?.exec); const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; for (const agent of agents) { diff --git a/src/config/plugin-auto-enable.apply.ts b/src/config/plugin-auto-enable.apply.ts index 75bbc5743dfc..3486a86ab640 100644 --- a/src/config/plugin-auto-enable.apply.ts +++ b/src/config/plugin-auto-enable.apply.ts @@ -11,6 +11,7 @@ import type { } from "./plugin-auto-enable.types.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Applies already detected plugin auto-enable candidates to config. */ export function materializePluginAutoEnableCandidates(params: { config?: OpenClawConfig; candidates: readonly PluginAutoEnableCandidate[]; diff --git a/src/config/plugin-auto-enable.detect.ts b/src/config/plugin-auto-enable.detect.ts index 12a14352e70d..8f3d2115f87f 100644 --- a/src/config/plugin-auto-enable.detect.ts +++ b/src/config/plugin-auto-enable.detect.ts @@ -8,6 +8,7 @@ import { import type { PluginAutoEnableCandidate } from "./plugin-auto-enable.types.js"; import type { OpenClawConfig } from "./types.openclaw.js"; +/** Detects installed plugins that should become enabled from existing config usage. */ export function detectPluginAutoEnableCandidates(params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index 3d5ed588f812..d6efd3cea336 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -7,6 +7,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-help const tempDirs: string[] = []; +/** Clears auto-enable plugin caches and temp dirs between tests. */ export function resetPluginAutoEnableTestState(): void { clearCurrentPluginMetadataSnapshot(); clearPluginSetupRegistryCache(); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 232a75ae3ff4..480b20ea75ee 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,3 +1,4 @@ +// Public facade for plugin auto-enable detection, application, and reason types. export { applyPluginAutoEnable, materializePluginAutoEnableCandidates, diff --git a/src/config/plugin-auto-enable.types.ts b/src/config/plugin-auto-enable.types.ts index 3a1146d10206..f7cd84060690 100644 --- a/src/config/plugin-auto-enable.types.ts +++ b/src/config/plugin-auto-enable.types.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "./types.openclaw.js"; +/** Reasons a configured surface can cause a plugin to be auto-enabled. */ export type PluginAutoEnableCandidate = | { pluginId: string; diff --git a/src/config/plugin-install-config-migration.ts b/src/config/plugin-install-config-migration.ts index fb2a713f85ed..e409938de90e 100644 --- a/src/config/plugin-install-config-migration.ts +++ b/src/config/plugin-install-config-migration.ts @@ -13,6 +13,12 @@ function pruneEmptyPluginsObject(plugins: Record): unknown { return Object.keys(rest).length === 0 ? undefined : rest; } +/** + * Reads legacy shipped `plugins.installs` records for migration into the plugin index. + * + * Invalid install maps are ignored so config loading can keep using the stripped + * runtime config while doctor/write paths decide how to report or recover. + */ export function extractShippedPluginInstallConfigRecords( config: unknown, ): Record { @@ -25,6 +31,7 @@ export function extractShippedPluginInstallConfigRecords( : {}; } +/** Removes legacy shipped `plugins.installs` without mutating the original config object. */ export function stripShippedPluginInstallConfigRecords(config: unknown): unknown { if (!isRecord(config) || !isRecord(config.plugins) || !("installs" in config.plugins)) { return config; diff --git a/src/config/plugin-web-search-config.ts b/src/config/plugin-web-search-config.ts index e8d30b8c02fb..9da701144595 100644 --- a/src/config/plugin-web-search-config.ts +++ b/src/config/plugin-web-search-config.ts @@ -11,6 +11,7 @@ type PluginWebSearchConfigCarrier = { }; }; +/** Resolve a plugin-owned `config.webSearch` object without interpreting provider fields. */ export function resolvePluginWebSearchConfig( config: PluginWebSearchConfigCarrier | undefined, pluginId: string, diff --git a/src/config/plugins-allowlist.ts b/src/config/plugins-allowlist.ts index ff8b08aa0772..8ef1f70ae281 100644 --- a/src/config/plugins-allowlist.ts +++ b/src/config/plugins-allowlist.ts @@ -4,12 +4,14 @@ type PluginAllowlistConfigCarrier = { }; }; +/** Return a config copy with `pluginId` appended to an existing restrictive plugin allowlist. */ export function ensurePluginAllowlisted( cfg: T, pluginId: string, ): T { const allow = cfg.plugins?.allow; if (!Array.isArray(allow) || allow.includes(pluginId)) { + // Missing allowlist means unrestricted plugin loading; avoid creating a new restrictive list. return cfg; } return { diff --git a/src/config/port-defaults.ts b/src/config/port-defaults.ts index b09f590658e9..7cb5ecb52ed5 100644 --- a/src/config/port-defaults.ts +++ b/src/config/port-defaults.ts @@ -12,17 +12,22 @@ function derivePort(base: number, offset: number, fallback: number): number { return clampPort(base + offset, fallback); } +/** Default browser-CDP sidecar port range used when no browser-control-relative range is safe. */ export const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; +/** Inclusive end of the default browser-CDP sidecar port range. */ export const DEFAULT_BROWSER_CDP_PORT_RANGE_END = 18899; const DEFAULT_BROWSER_CDP_PORT_RANGE_SPAN = DEFAULT_BROWSER_CDP_PORT_RANGE_END - DEFAULT_BROWSER_CDP_PORT_RANGE_START; +/** Derives the browser-CDP sidecar range from the browser-control port when it fits. */ export function deriveDefaultBrowserCdpPortRange(browserControlPort: number): PortRange { const start = derivePort(browserControlPort, 9, DEFAULT_BROWSER_CDP_PORT_RANGE_START); const end = start + DEFAULT_BROWSER_CDP_PORT_RANGE_SPAN; if (end <= 65535) { return { start, end }; } + // Preserve the full expected range width; wrapping or clamping only the end would make the + // dynamic range smaller than callers advertise. return { start: DEFAULT_BROWSER_CDP_PORT_RANGE_START, end: DEFAULT_BROWSER_CDP_PORT_RANGE_END, diff --git a/src/config/prototype-keys.ts b/src/config/prototype-keys.ts index 3ba47c293efa..3268878ae18e 100644 --- a/src/config/prototype-keys.ts +++ b/src/config/prototype-keys.ts @@ -1 +1,2 @@ +// Config-facing facade for prototype-pollution key guards. export { isBlockedObjectKey } from "../infra/prototype-keys.js"; diff --git a/src/config/provider-policy.ts b/src/config/provider-policy.ts index 90874505d74b..3244031bfd51 100644 --- a/src/config/provider-policy.ts +++ b/src/config/provider-policy.ts @@ -2,6 +2,7 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { resolveBundledProviderPolicySurface } from "../plugins/provider-public-artifacts.js"; import type { ModelProviderConfig, OpenClawConfig } from "./types.js"; +/** Applies bundled provider-owned normalization to one provider config during config defaults. */ export function normalizeProviderConfigForConfigDefaults(params: { provider: string; providerConfig: ModelProviderConfig; @@ -13,9 +14,12 @@ export function normalizeProviderConfigForConfigDefaults(params: { provider: params.provider, providerConfig: params.providerConfig, }); + // Preserve object identity when the provider policy declines to change config; defaults callers + // use identity to avoid unnecessary config rewrites. return normalized && normalized !== params.providerConfig ? normalized : params.providerConfig; } +/** Applies bundled provider-owned defaults to the full config when that provider has policy. */ export function applyProviderConfigDefaultsForConfig(params: { provider: string; config: OpenClawConfig; diff --git a/src/config/read-best-effort-config.runtime.ts b/src/config/read-best-effort-config.runtime.ts index c7103b0a2521..47641a5ec078 100644 --- a/src/config/read-best-effort-config.runtime.ts +++ b/src/config/read-best-effort-config.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for best-effort config reads used by plugin and gateway code. export { readBestEffortConfig, readSourceConfigBestEffort } from "./io.js"; diff --git a/src/config/recovery-policy.ts b/src/config/recovery-policy.ts index 97b6baee2600..6fdf70a7303d 100644 --- a/src/config/recovery-policy.ts +++ b/src/config/recovery-policy.ts @@ -25,6 +25,7 @@ function isPluginPolicyIssue(issue: ConfigValidationIssue): boolean { ); } +/** Return true for plugin validation issues caused by missing compiled runtime output. */ export function isPluginPackagingRuntimeOutputIssue(issue: ConfigValidationIssue): boolean { const path = issue.path.trim(); const message = issue.message.trim().toLowerCase(); @@ -57,8 +58,8 @@ function extractPluginNotFoundIssuePluginId(issue: ConfigValidationIssue): strin } /** - * Returns true when an invalid config snapshot is blocked by an installed plugin - * package that shipped TypeScript source without compiled JavaScript output. + * Return true when an invalid config snapshot is blocked only by plugin packaging fallout. + * This lets callers show plugin repair hints instead of treating user config as corrupted. */ export function isPluginPackagingRuntimeOutputInvalidConfigSnapshot( snapshot: Pick & @@ -81,6 +82,7 @@ export function isPluginPackagingRuntimeOutputInvalidConfigSnapshot( if (isPluginPackagingRuntimeOutputIssue(issue)) { return true; } + // Missing-plugin fallout must belong to the same plugin that emitted the packaging error. const pluginId = extractPluginNotFoundIssuePluginId(issue); return pluginId !== null && packagingPluginIds.has(pluginId); }) @@ -88,7 +90,8 @@ export function isPluginPackagingRuntimeOutputInvalidConfigSnapshot( } /** - * Returns true when an invalid config snapshot is scoped entirely to stale plugin refs. + * Return true when an invalid config snapshot is scoped entirely to stale plugin refs. + * Whole-file recovery is skipped for these snapshots so plugin cleanup can preserve user config. */ export function isPluginLocalInvalidConfigSnapshot( snapshot: Pick, @@ -100,7 +103,8 @@ export function isPluginLocalInvalidConfigSnapshot( } /** - * Decides whether whole-file last-known-good recovery is safe for a snapshot. + * Decide whether whole-file last-known-good recovery is appropriate for an invalid snapshot. + * Plugin-local failures stay on the current file so targeted plugin cleanup can run. */ export function shouldAttemptLastKnownGoodRecovery( snapshot: Pick, diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index ec7723fdf95f..585cee0244cd 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import JSON5 from "json5"; +/** Replaces known sensitive values in raw config text while preserving parseable structure. */ export function replaceSensitiveValuesInRaw(params: { raw: string; sensitiveValues: string[]; @@ -14,11 +15,13 @@ export function replaceSensitiveValuesInRaw(params: { .toSorted((a, b) => b.length - a.length); let result = params.raw; for (const value of values) { + // Replace longer overlapping values first so a short prefix cannot hide the full secret. result = result.replaceAll(value, params.redactedSentinel); } return result; } +/** Returns whether raw string redaction changed semantics and structured redaction is needed. */ export function shouldFallbackToStructuredRawRedaction(params: { redactedRaw: string; originalConfig: unknown; @@ -30,6 +33,7 @@ export function shouldFallbackToStructuredRawRedaction(params: { if (!restored.ok) { return true; } + // Raw replacement is only safe when parsing and restoring produces the original config shape. return !isDeepStrictEqual(restored.result, params.originalConfig); } catch { return true; diff --git a/src/config/redact-snapshot.secret-ref.ts b/src/config/redact-snapshot.secret-ref.ts index 20af40c6f19c..56dff8498cfb 100644 --- a/src/config/redact-snapshot.secret-ref.ts +++ b/src/config/redact-snapshot.secret-ref.ts @@ -1,9 +1,11 @@ +/** Narrows plain objects that carry the minimum SecretRef fields used by redaction. */ export function isSecretRefShape( value: Record, ): value is Record & { source: string; id: string } { return typeof value.source === "string" && typeof value.id === "string"; } +/** Redacts a SecretRef id while preserving non-secret structural fields for restore matching. */ export function redactSecretRefId(params: { value: Record & { source: string; id: string }; values: string[]; @@ -13,6 +15,8 @@ export function redactSecretRefId(params: { const { value, values, redactedSentinel, isEnvVarPlaceholder } = params; const redacted: Record = { ...value }; if (!isEnvVarPlaceholder(value.id)) { + // `${ENV_VAR}` placeholders are already indirect references; collect and redact only concrete + // ids so raw replacement cannot erase harmless template syntax. values.push(value.id); redacted.id = redactedSentinel; } diff --git a/src/config/redact-snapshot.test-helpers.ts b/src/config/redact-snapshot.test-helpers.ts index 12abf5b05975..7768f6dbdced 100644 --- a/src/config/redact-snapshot.test-helpers.ts +++ b/src/config/redact-snapshot.test-helpers.ts @@ -3,6 +3,7 @@ import { restoreRedactedValues as restoreRedactedValues_orig } from "./redact-sn import type { ConfigUiHints } from "./schema.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; +/** Complete snapshot shape used by redaction tests. */ export type TestSnapshot> = ConfigFileSnapshot & { parsed: TConfig; sourceConfig: TConfig; diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index cdeed112735e..bc4e36ecd5eb 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -580,6 +580,8 @@ function maybeRestoreSecretRefId(params: { const originalObj = toObjectRecord(params.original); if (!isSecretRefWithProvider(originalObj)) { + // Automatic restore needs provider as part of the identity; source+id alone can match the + // wrong secret provider after config edits. if (isSecretRefShape(originalObj)) { throw new RedactionError( params.path, @@ -593,6 +595,8 @@ function maybeRestoreSecretRefId(params: { } if (!isSecretRefWithProvider(incomingObj)) { + // A redacted id is only restorable when the incoming object still carries the stable SecretRef + // identity fields that were visible in the redacted snapshot. throw new RedactionError( params.path, `SecretRef at ${params.path} must include source, provider, and id when redacted placeholders are present.`, @@ -600,6 +604,8 @@ function maybeRestoreSecretRefId(params: { } if (incomingObj.source !== originalObj.source || incomingObj.provider !== originalObj.provider) { + // Changing source/provider while keeping a redacted id would silently bind the old secret id to + // a different backend. Require an explicit id for that edit. throw new RedactionError( params.path, `SecretRef at ${params.path} changed source/provider while id is redacted. Provide an explicit id when changing source/provider.`, diff --git a/src/config/runtime-group-policy.ts b/src/config/runtime-group-policy.ts index 8303cb23fcd3..eb8fa6e13330 100644 --- a/src/config/runtime-group-policy.ts +++ b/src/config/runtime-group-policy.ts @@ -14,6 +14,10 @@ type RuntimeGroupPolicyParams = { missingProviderFallbackPolicy?: GroupPolicy; }; +/** + * Resolve the effective group policy for a channel/provider runtime. + * Missing provider config can fail closed separately from configured providers. + */ export function resolveRuntimeGroupPolicy( params: RuntimeGroupPolicyParams, ): RuntimeGroupPolicyResolution { @@ -41,10 +45,12 @@ type GroupPolicyDefaultsConfig = { }; }; +/** Read the shared channels default group policy used by provider-specific resolvers. */ export function resolveDefaultGroupPolicy(cfg: GroupPolicyDefaultsConfig): GroupPolicy | undefined { return cfg.channels?.defaults?.groupPolicy; } +/** Human labels for the access surface blocked by a missing-provider fallback. */ export const GROUP_POLICY_BLOCKED_LABEL = { group: "group messages", guild: "guild messages", @@ -54,9 +60,8 @@ export const GROUP_POLICY_BLOCKED_LABEL = { } as const; /** - * Standard provider runtime policy: - * - configured provider fallback: open - * - missing provider fallback: allowlist (fail-closed) + * Resolve the standard channel-provider policy. + * Configured providers default open; missing provider config defaults allowlist. */ export function resolveOpenProviderRuntimeGroupPolicy( params: ResolveProviderRuntimeGroupPolicyParams, @@ -71,9 +76,8 @@ export function resolveOpenProviderRuntimeGroupPolicy( } /** - * Strict provider runtime policy: - * - configured provider fallback: allowlist - * - missing provider fallback: allowlist (fail-closed) + * Resolve the strict channel-provider policy. + * Configured and missing provider config both default allowlist. */ export function resolveAllowlistProviderRuntimeGroupPolicy( params: ResolveProviderRuntimeGroupPolicyParams, @@ -89,6 +93,10 @@ export function resolveAllowlistProviderRuntimeGroupPolicy( const warnedMissingProviderGroupPolicy = new Set(); +/** + * Log the missing-provider fail-closed fallback once per provider/account. + * Returns true only when this call emitted the warning. + */ export function warnMissingProviderGroupPolicyFallbackOnce(params: { providerMissingFallbackApplied: boolean; providerKey: string; diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts index 44b772e0b1cc..49ae6cdf68ba 100644 --- a/src/config/runtime-overrides.ts +++ b/src/config/runtime-overrides.ts @@ -20,6 +20,7 @@ function sanitizeOverrideValue(value: unknown, seen = new WeakSet()): un seen.add(value); const sanitized: OverrideTree = {}; for (const [key, entry] of Object.entries(value)) { + // Overrides can come from debug commands, so strip prototype keys before they reach config. if (entry === undefined || isBlockedObjectKey(key)) { continue; } @@ -43,14 +44,17 @@ function mergeOverrides(base: unknown, override: unknown): unknown { return next; } +/** Return the process-local runtime override tree used by debug config commands. */ export function getConfigOverrides(): OverrideTree { return overrides; } +/** Clear all process-local runtime overrides. Intended for debug reset flows and tests. */ export function resetConfigOverrides(): void { overrides = {}; } +/** Set one runtime override at a parsed config path after sanitizing object values. */ export function setConfigOverride( pathRaw: string, value: unknown, @@ -66,6 +70,7 @@ export function setConfigOverride( return { ok: true }; } +/** Remove one runtime override path and report whether an override was present. */ export function unsetConfigOverride(pathRaw: string): { ok: boolean; removed: boolean; @@ -83,6 +88,7 @@ export function unsetConfigOverride(pathRaw: string): { return { ok: true, removed }; } +/** Merge the current runtime overrides over a loaded config without mutating the input config. */ export function applyConfigOverrides(cfg: OpenClawConfig): OpenClawConfig { if (!overrides || Object.keys(overrides).length === 0) { return cfg; diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts index b952d518c6f4..db3b8a76fc0a 100644 --- a/src/config/runtime-schema.ts +++ b/src/config/runtime-schema.ts @@ -8,6 +8,7 @@ import { getRuntimeConfig, readConfigFileSnapshot } from "./config.js"; import type { OpenClawConfig } from "./config.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; +// Runtime schemas include currently loaded plugin/channel metadata for accurate UI fields. function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); return resolvePluginMetadataSnapshot({ @@ -18,6 +19,7 @@ function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { }).manifestRegistry; } +/** Builds the config schema from the active runtime config and plugin metadata. */ export function loadGatewayRuntimeConfigSchema(): ConfigSchemaResponse { const config = getRuntimeConfig(); const registry = loadManifestRegistry(config); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 23ba0c24bf2e..bb55fc39edc0 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -104,6 +104,7 @@ function isKernelOwnedChannelHintPath(path: string): boolean { ); } +/** Return whether a channel hint path belongs to a plugin-owned channel namespace. */ export function isPluginOwnedChannelHintPath(path: string): boolean { if (!path.startsWith(CHANNEL_NAMESPACE_PREFIX)) { return false; @@ -113,6 +114,7 @@ export function isPluginOwnedChannelHintPath(path: string): boolean { export { isSensitiveConfigPath }; +/** Build core config UI hints while leaving plugin-owned channel hints to plugin schemas. */ export function buildBaseHints(): ConfigUiHints { const hints: ConfigUiHints = {}; for (const [group, label] of Object.entries(GROUP_LABELS)) { @@ -146,6 +148,7 @@ export function buildBaseHints(): ConfigUiHints { return applyDerivedTags(hints); } +/** Mark sensitive config paths in a hint map without overwriting explicit sensitivity metadata. */ export function applySensitiveHints( hints: ConfigUiHints, allowedKeys?: ReadonlySet, @@ -164,6 +167,7 @@ export function applySensitiveHints( return next; } +/** Add the sensitive-url tag to hint paths that carry URLs with credential risk. */ export function applySensitiveUrlHints( hints: ConfigUiHints, allowedKeys?: ReadonlySet, @@ -185,6 +189,7 @@ export function applySensitiveUrlHints( return next; } +/** Walk a Zod schema and collect concrete/wildcard paths accepted by `matchesPath`. */ export function collectMatchingSchemaPaths( schema: z.ZodType, path: string, @@ -255,6 +260,7 @@ function isUnwrappable(object: unknown): object is ZodDummy { ); } +/** Walk a Zod schema and mark hints for fields registered with the sensitive schema marker. */ export function mapSensitivePaths( schema: z.ZodType, path: string, diff --git a/src/config/schema.shared.ts b/src/config/schema.shared.ts index eb9452953b42..af9c59a60b72 100644 --- a/src/config/schema.shared.ts +++ b/src/config/schema.shared.ts @@ -8,10 +8,12 @@ type JsonSchemaObject = { oneOf?: JsonSchemaObject[]; }; +/** Deep-clone schema payloads before callers mutate plugin or base schema fragments. */ export function cloneSchema(value: T): T { return structuredClone(value); } +/** Narrow unknown JSON-schema fragments to non-array objects. */ export function asSchemaObject(value: unknown): object | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -19,6 +21,7 @@ export function asSchemaObject(value: unknown): object | null { return value; } +/** Return whether a schema node exposes nested fields through properties, items, or unions. */ export function schemaHasChildren(schema: JsonSchemaObject): boolean { if (schema.properties && Object.keys(schema.properties).length > 0) { return true; @@ -37,6 +40,7 @@ export function schemaHasChildren(schema: JsonSchemaObject): boolean { return Boolean(schema.items && typeof schema.items === "object"); } +/** Find the most specific wildcard UI hint that matches a concrete config path. */ export function findWildcardHintMatch(params: { uiHints: Record; path: string; @@ -76,6 +80,7 @@ export function findWildcardHintMatch(params: { if (!matches) { continue; } + // Fewer wildcards means the hint is closer to the concrete path and should win. if (!bestMatch || wildcardCount < bestMatch.wildcardCount) { bestMatch = { path: hintPath, hint, wildcardCount }; } diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 7f9b786323c2..355682e0b44c 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -1,6 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { ConfigUiHint, ConfigUiHints } from "../shared/config-ui-hints-types.js"; +/** Stable config UI tag vocabulary used for filtering and grouping schema hints. */ export const CONFIG_TAGS = [ "security", "auth", @@ -147,6 +148,7 @@ function addTags(set: Set, tags: ReadonlyArray): void { } } +/** Derive known config UI tags from a schema path and optional hint metadata. */ export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] { const lowerPath = normalizeLowercaseStringOrEmpty(path); const override = resolveOverride(path); @@ -194,11 +196,13 @@ export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[ return normalizeTags([...tags]); } +/** Return hints with derived known tags merged ahead of any existing custom tags. */ export function applyDerivedTags(hints: ConfigUiHints): ConfigUiHints { const next: ConfigUiHints = {}; for (const [path, hint] of Object.entries(hints)) { const existingTags = Array.isArray(hint?.tags) ? hint.tags : []; const derivedTags = deriveTagsForPath(path, hint); + // Preserve unknown tags after known tags so external/custom UI tags survive normalization. const tags = [ ...normalizeTags([...derivedTags, ...existingTags]), ...collectUnknownTags(existingTags), diff --git a/src/config/sensitive-paths.ts b/src/config/sensitive-paths.ts index d4a7e572add7..ca55c870f02f 100644 --- a/src/config/sensitive-paths.ts +++ b/src/config/sensitive-paths.ts @@ -46,8 +46,15 @@ function isLocalServiceEnvValuePath(path: string): boolean { return lowerPath.includes("localservice.env."); } +/** + * Classifies config paths whose values should be redacted from UI/API output. + * + * This intentionally works from path labels, not schema nodes, so plugin-owned + * fields and raw local-service env vars get the same conservative treatment. + */ export function isSensitiveConfigPath(path: string): boolean { return ( + // Every local service env value is sensitive, even innocuous-looking names. isLocalServiceEnvValuePath(path) || (!isWhitelistedSensitivePath(path) && matchesSensitivePattern(path)) ); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 635c9687d727..f6266574f15b 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -1,3 +1,4 @@ +// Public facade for session stores, metadata, lifecycle, reset, transcript, and cleanup APIs. export * from "./sessions/combined-store-gateway.js"; export * from "./sessions/group.js"; export * from "./sessions/goals.js"; diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 454af3bb386f..04887264ede0 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -14,6 +14,7 @@ import { } from "./targets.js"; import type { SessionEntry } from "./types.js"; +// Template-backed stores need per-agent scans before they can be merged for Gateway views. function isStorePathTemplate(store?: string): boolean { return typeof store === "string" && store.includes("{agentId}"); } diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index 33fef5450216..1a76132bb637 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -34,6 +34,12 @@ function hasRoutableDeliveryContext(context?: { return Boolean(context?.channel && context?.to); } +/** + * Extracts the routable delivery context and thread id for a persisted session key. + * + * Thread/topic keys first try their exact store entry, then fall back to the base session when + * the thread entry has no delivery route of its own. + */ export function extractDeliveryInfo( sessionKey: string | undefined, options?: { cfg?: OpenClawConfig }, @@ -161,6 +167,8 @@ function findSessionEntryInStore( acceptCandidate(store[trimmed]); } if (trimmed !== normalized || !foundRoutableCandidate) { + // Build the normalized index only after direct/exact probes fail; large session stores can + // stay on the cheap path when the queried key already has routable delivery context. normalizedIndex ??= buildFreshestSessionEntryIndex(store); const freshest = normalizedIndex.get(normalized); if (!hasMismatchedCaseSensitiveDeliveryProof(freshest, normalized)) { @@ -247,6 +255,8 @@ function loadDeliverySessionEntry(params: { continue; } fallback ??= { entry, baseEntry }; + // Prefer the first store that can actually route delivery; keep a non-routable fallback only + // so callers can still inspect thread ids when no target-bearing session exists. if ( hasRoutableDeliveryContext(deliveryContextFromSession(entry)) || hasRoutableDeliveryContext(deliveryContextFromSession(baseEntry)) diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index afcec3ee59d5..9dae2d9f06d1 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -7,6 +7,7 @@ import { getLoadedChannelPlugin, listChannelPlugins } from "../../channels/plugi import { normalizeSessionKeyPreservingOpaquePeerIds } from "../../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; +// Candidate channels come from context and key shape because explicit keys may be prefixed. function resolveExplicitSessionKeyNormalizerCandidates( sessionKey: string, ctx: Pick, @@ -36,6 +37,7 @@ function resolveExplicitSessionKeyNormalizerCandidates( return [...candidates]; } +/** Normalizes caller-supplied session keys through the matching channel plugin when available. */ export function normalizeExplicitSessionKey(sessionKey: string, ctx: MsgContext): string { const normalized = normalizeSessionKeyPreservingOpaquePeerIds(sessionKey); for (const channelId of resolveExplicitSessionKeyNormalizerCandidates(normalized, ctx)) { diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index aef34d00220a..7bd4e9d80c91 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -45,6 +45,8 @@ function resolveOriginatingGroupTargetId(params: { return null; } + // Some channels send the sender in `From` and the actual group/channel route in `To`. + // Prefer that route when it carries a recognized provider/kind prefix. const head = normalizeLowercaseStringOrEmpty(parts[0]); const second = normalizeOptionalLowercaseString(parts[1]); const secondIsKind = second === "group" || second === "channel"; @@ -71,6 +73,7 @@ function shortenGroupId(value?: string) { return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; } +/** Builds a compact display label for group sessions from channel metadata or ids. */ export function buildGroupDisplayName(params: { provider?: string; subject?: string; @@ -102,6 +105,12 @@ export function buildGroupDisplayName(params: { return token ? `${providerKey}:${token}` : providerKey; } +/** + * Resolves channel/group chat context into the persisted group session key. + * + * Provider-prefixed ids use channel-owned normalization, while legacy plugin resolvers remain a + * fallback for older channel surfaces that cannot yet express the generic route shape. + */ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { const from = normalizeOptionalString(ctx.From) ?? ""; const chatType = normalizeOptionalLowercaseString(ctx.ChatType); @@ -126,6 +135,8 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const headIsSurface = head ? getGroupSurfaces().has(head) : false; if (!headIsSurface && !providerHint && legacyResolution) { + // Without a provider hint, trust the plugin-owned legacy resolver; guessing from `From` + // would merge unrelated channel/group keys. return legacyResolution; } @@ -143,6 +154,8 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu : "group"; const originatingGroupTargetId = !secondIsKind && normalizedChatType ? resolveOriginatingGroupTargetId({ ctx, provider }) : null; + // Originating targets preserve provider-native group ids, including case-sensitive Signal ids + // that would be corrupted by normalizing the sender-shaped `From` fallback. const id = originatingGroupTargetId ? originatingGroupTargetId : headIsSurface diff --git a/src/config/sessions/inbound.runtime.ts b/src/config/sessions/inbound.runtime.ts index d7bfbb9d6f0c..9ff003c96a5c 100644 --- a/src/config/sessions/inbound.runtime.ts +++ b/src/config/sessions/inbound.runtime.ts @@ -1,2 +1,3 @@ +// Runtime facade for inbound session store updates. export { resolveStorePath } from "./paths.js"; export { recordSessionMetaFromInbound, updateLastRoute } from "./store.js"; diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 6cee12c5a57d..8a6c7284ee24 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -12,6 +12,7 @@ type SessionLifecycleEntry = Pick< "sessionId" | "sessionFile" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt" >; +// Transcript headers are read lazily to recover startedAt without parsing full files. function resolveTimestamp(value: number | undefined): number | undefined { const timestampMs = asDateTimestampMs(value); return timestampMs !== undefined && timestampMs >= 0 ? timestampMs : undefined; @@ -48,6 +49,7 @@ function readFirstLine(filePath: string): string | undefined { } } +/** Reads session start time from a transcript header when store metadata is missing. */ export function readSessionHeaderStartedAtMs(params: { entry: SessionLifecycleEntry | undefined; agentId?: string; diff --git a/src/config/sessions/main-session.runtime.ts b/src/config/sessions/main-session.runtime.ts index cb4a912a1935..c776ff63a94c 100644 --- a/src/config/sessions/main-session.runtime.ts +++ b/src/config/sessions/main-session.runtime.ts @@ -1,6 +1,7 @@ import { getRuntimeConfig } from "../io.js"; import { resolveMainSessionKey } from "./main-session.js"; +/** Resolves the main session key from the active runtime config. */ export function resolveMainSessionKeyFromConfig(): string { return resolveMainSessionKey(getRuntimeConfig()); } diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index 50397dabae22..c20b3dd48063 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -10,6 +10,7 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { buildGroupDisplayName, resolveGroupSessionKey } from "./group.js"; import type { GroupKeyResolution, SessionEntry, SessionOrigin } from "./types.js"; +// Origin updates merge sparse channel metadata without deleting previously known fields. const mergeOrigin = ( existing: SessionOrigin | undefined, next: SessionOrigin | undefined, @@ -51,6 +52,7 @@ const mergeOrigin = ( return Object.keys(merged).length > 0 ? merged : undefined; }; +/** Derives session origin metadata from an inbound message context. */ export function deriveSessionOrigin( ctx: MsgContext, opts?: { skipSystemEventOrigin?: boolean }, diff --git a/src/config/sessions/model-override-provenance.ts b/src/config/sessions/model-override-provenance.ts index c00fbb7ac3fe..0e473b8b1a43 100644 --- a/src/config/sessions/model-override-provenance.ts +++ b/src/config/sessions/model-override-provenance.ts @@ -1,6 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SessionEntry } from "./types.js"; +/** Detects model overrides created by automatic fallback provenance. */ export function hasSessionAutoModelFallbackProvenance( entry: | Pick< diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index a3dcd422aaad..7c535e031f7b 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -21,16 +21,19 @@ export type SessionFreshness = { export const DEFAULT_RESET_MODE: SessionResetMode = "daily"; export const DEFAULT_RESET_AT_HOUR = 4; +/** Returns the most recent daily reset boundary for the supplied wall-clock time. */ export function resolveDailyResetAtMs(now: number, atHour: number): number { const normalizedAtHour = normalizeResetAtHour(atHour); const resetAt = new Date(now); resetAt.setHours(normalizedAtHour, 0, 0, 0); if (now < resetAt.getTime()) { + // Before today's reset hour, the active reset boundary is yesterday's scheduled reset. resetAt.setDate(resetAt.getDate() - 1); } return resetAt.getTime(); } +/** Resolves the effective reset policy for direct, group, or thread sessions. */ export function resolveSessionResetPolicy(params: { sessionCfg?: SessionConfig; resetType: SessionResetType; @@ -38,7 +41,7 @@ export function resolveSessionResetPolicy(params: { }): SessionResetPolicy { const sessionCfg = params.sessionCfg; const baseReset = params.resetOverride ?? sessionCfg?.reset; - // Backward compat: accept legacy "dm" key as alias for "direct" + // Backward compat: accept legacy "dm" key as alias for "direct". const typeReset = params.resetOverride ? undefined : (sessionCfg?.resetByType?.[params.resetType] ?? @@ -48,6 +51,7 @@ export function resolveSessionResetPolicy(params: { const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; const configured = Boolean(baseReset || typeReset || legacyIdleMinutes != null); + // Legacy `idleMinutes` implied idle reset only when no modern reset block was configured. const mode = typeReset?.mode ?? baseReset?.mode ?? @@ -70,6 +74,7 @@ export function resolveSessionResetPolicy(params: { return { mode, atHour, idleMinutes, configured }; } +/** Evaluates whether a persisted session is still fresh under the resolved reset policy. */ export function evaluateSessionFreshness(params: { updatedAt: number; sessionStartedAt?: number; @@ -93,7 +98,8 @@ export function evaluateSessionFreshness(params: { const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt; const staleReason = staleDaily && staleIdle - ? (dailyResetAt ?? Number.POSITIVE_INFINITY) <= (idleExpiresAt ?? Number.POSITIVE_INFINITY) + ? // When both policies mark the session stale, report the boundary that went stale first. + (dailyResetAt ?? Number.POSITIVE_INFINITY) <= (idleExpiresAt ?? Number.POSITIVE_INFINITY) ? "daily" : "idle" : staleIdle diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index ac578e6cc316..0af76162e05e 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -5,6 +5,7 @@ import { import { resolveLoadedSessionThreadInfo } from "../../channels/plugins/session-thread-info-loaded.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js"; +/** Public reset policy exports plus helpers that classify direct, group, and thread sessions. */ export { DEFAULT_RESET_AT_HOUR, DEFAULT_RESET_MODE, @@ -20,6 +21,7 @@ import type { SessionResetType } from "./reset-policy.js"; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; +/** Returns true when a session key is known to represent a thread. */ export function isThreadSessionKey(sessionKey?: string | null): boolean { return Boolean(resolveLoadedSessionThreadInfo(sessionKey).threadId); } diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 6ae2e48e1611..57eb7b0bb171 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -4,6 +4,7 @@ import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { SessionMaintenanceMode } from "../types.base.js"; import type { SessionEntry, GroupKeyResolution } from "./types.js"; +/** Runtime hook for reading a session store entry timestamp. */ export type ReadSessionUpdatedAt = (params: { storePath: string; sessionKey: string; diff --git a/src/config/sessions/session-file-rotation.ts b/src/config/sessions/session-file-rotation.ts index 789117de04dc..1087a9ff8f47 100644 --- a/src/config/sessions/session-file-rotation.ts +++ b/src/config/sessions/session-file-rotation.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Rewrites transcript file paths when a session id changes during reset or fork. */ export function rewriteSessionFileForNewSessionId(params: { sessionFile?: string; previousSessionId: string; diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts index 5eea82e7edcf..4b62d638c7f7 100644 --- a/src/config/sessions/session-file.ts +++ b/src/config/sessions/session-file.ts @@ -3,6 +3,7 @@ import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js"; import { updateSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; +/** Resolves a transcript file path and persists it into the session store when needed. */ export async function resolveAndPersistSessionFile(params: { sessionId: string; sessionKey: string; diff --git a/src/config/sessions/session-key.test-helpers.ts b/src/config/sessions/session-key.test-helpers.ts index ecda8a55afb3..c467445543e1 100644 --- a/src/config/sessions/session-key.test-helpers.ts +++ b/src/config/sessions/session-key.test-helpers.ts @@ -7,6 +7,7 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; +/** Builds the minimum message context needed by session key tests. */ export function makeCtx(overrides: Partial): MsgContext { return { Body: "", diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index efe08553f1c0..7aba41e8b829 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -10,7 +10,12 @@ import { normalizeExplicitSessionKey } from "./explicit-session-key-normalizatio import { resolveGroupSessionKey } from "./group.js"; import type { SessionScope } from "./types.js"; -// Decide which session bucket to use (per-sender vs global). +/** + * Derives the raw session bucket from message context before agent/main-key normalization. + * + * Direct chats use sender identity, groups use channel-owned group keys, and global scope bypasses + * sender routing entirely. + */ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { if (scope === "global") { return "global"; @@ -24,8 +29,10 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { } /** - * Resolve the session key with a canonical direct-chat bucket (default: "main"). - * All non-group direct chats collapse to this bucket; groups stay isolated. + * Resolves the persisted session-store key for an inbound message. + * + * Explicit session keys pass through the compatibility normalizer, direct chats collapse to the + * agent's canonical main bucket, and group/channel sessions stay isolated under the same agent. */ export function resolveSessionKey( scope: SessionScope, @@ -51,5 +58,7 @@ export function resolveSessionKey( if (!isGroup) { return canonical; } + // Keep channel/group sessions separate from direct main sessions while still namespacing them + // by agent id so multi-agent stores do not collide on provider-owned group keys. return `agent:${canonicalAgentId}:${raw}`; } diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 6f57148bb2e6..ea55bacd25f8 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -2,6 +2,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { validateSessionId } from "./paths.js"; import type { SessionEntry } from "./types.js"; +// Persisted stores may contain old or malformed ids; reject path-like ids before use. function isSafeSessionId(value: unknown): value is string { if (typeof value !== "string") { return false; @@ -31,6 +32,7 @@ function normalizeOptionalTimestamp(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; } +/** Normalizes persisted session store entries before they reach runtime callers. */ export function normalizePersistedSessionEntryShape(value: unknown): SessionEntry | undefined { if (!isRecord(value)) { return undefined; diff --git a/src/config/sessions/store-read.ts b/src/config/sessions/store-read.ts index 1cc5eb3bff37..795750723ad5 100644 --- a/src/config/sessions/store-read.ts +++ b/src/config/sessions/store-read.ts @@ -8,6 +8,7 @@ const SessionStoreSchema = z.record(z.string(), z.unknown()) as z.ZodType< Record >; +/** Reads a session store without mutating it and drops malformed entries. */ export function readSessionStoreReadOnly( storePath: string, ): Record { diff --git a/src/config/sessions/store-writer-state.ts b/src/config/sessions/store-writer-state.ts index 973d383543d8..be556af2ff2e 100644 --- a/src/config/sessions/store-writer-state.ts +++ b/src/config/sessions/store-writer-state.ts @@ -6,11 +6,13 @@ import { } from "../../shared/store-writer-queue.js"; import { clearSessionStoreCaches } from "./store-cache.js"; +/** Queued session store write task type. */ export type SessionStoreWriterTask = StoreWriterTask; export type SessionStoreWriterQueue = StoreWriterQueue; export const WRITER_QUEUES = new Map(); +/** Clears session store writer queues and cache for tests. */ export function clearSessionStoreCacheForTest(): void { clearSessionStoreCaches(); clearStoreWriterQueuesForTest(WRITER_QUEUES, "session store queue cleared for test"); diff --git a/src/config/sessions/store-writer.ts b/src/config/sessions/store-writer.ts index 4cf058b13f9d..971683208a2f 100644 --- a/src/config/sessions/store-writer.ts +++ b/src/config/sessions/store-writer.ts @@ -1,6 +1,7 @@ import { runQueuedStoreWrite } from "../../shared/store-writer-queue.js"; import { WRITER_QUEUES } from "./store-writer-state.js"; +/** Runs a callback under the same per-store writer queue used in production. */ export async function withSessionStoreWriterForTest( storePath: string, fn: () => Promise, diff --git a/src/config/sessions/store.runtime.ts b/src/config/sessions/store.runtime.ts index 19a7957b7b34..174a2b8a5191 100644 --- a/src/config/sessions/store.runtime.ts +++ b/src/config/sessions/store.runtime.ts @@ -1,3 +1,4 @@ +// Runtime facade for session store mutation helpers. export { applySessionStoreEntryPatch, updateSessionStore, diff --git a/src/config/sessions/test-helpers.ts b/src/config/sessions/test-helpers.ts index 46a80dbeffaf..261e344e15da 100644 --- a/src/config/sessions/test-helpers.ts +++ b/src/config/sessions/test-helpers.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach } from "vitest"; +/** Creates and cleans a temporary session store fixture around each test. */ export function useTempSessionsFixture(prefix: string) { let tempDir = ""; let storePath = ""; diff --git a/src/config/sessions/transcript-header.ts b/src/config/sessions/transcript-header.ts index ad47ce57ec45..ea5781a7d827 100644 --- a/src/config/sessions/transcript-header.ts +++ b/src/config/sessions/transcript-header.ts @@ -1,11 +1,13 @@ import { randomUUID } from "node:crypto"; import { CURRENT_SESSION_VERSION } from "./version.js"; +/** Inputs for the first JSONL entry in a session transcript. */ export type SessionTranscriptHeaderParams = { sessionId?: string; cwd?: string; }; +/** Creates a session transcript header entry with current version metadata. */ export function createSessionTranscriptHeader(params: SessionTranscriptHeaderParams = {}) { return { type: "session", diff --git a/src/config/sessions/transcript-jsonl.ts b/src/config/sessions/transcript-jsonl.ts index 4d01590e79df..449feef25a5c 100644 --- a/src/config/sessions/transcript-jsonl.ts +++ b/src/config/sessions/transcript-jsonl.ts @@ -7,6 +7,7 @@ type WriteJsonlFileOptions = { mode?: number; }; +/** Serializes one JSONL entry and appends the newline terminator. */ export function serializeJsonlEntry(entry: unknown): string { return `${serializeJsonlLine(entry)}\n`; } diff --git a/src/config/sessions/transcript-mirror.ts b/src/config/sessions/transcript-mirror.ts index 7a926b958201..a4dcf98d8df9 100644 --- a/src/config/sessions/transcript-mirror.ts +++ b/src/config/sessions/transcript-mirror.ts @@ -1,5 +1,6 @@ import path from "node:path"; +// Media transcript mirrors use stable filenames instead of raw URLs with tokens/query strings. function stripQuery(value: string): string { const noHash = value.split("#")[0] ?? value; return noHash.split("?")[0] ?? noHash; @@ -31,6 +32,7 @@ function extractFileNameFromMediaUrl(value: string): string | null { } } +/** Resolves compact text to mirror into session transcripts for text or media messages. */ export function resolveMirroredTranscriptText(params: { text?: string; mediaUrls?: string[]; diff --git a/src/config/sessions/transcript-resolve.runtime.ts b/src/config/sessions/transcript-resolve.runtime.ts index fc28ca02de39..79b22d30a48e 100644 --- a/src/config/sessions/transcript-resolve.runtime.ts +++ b/src/config/sessions/transcript-resolve.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for transcript file path resolution. export { resolveSessionTranscriptFile } from "./transcript.js"; diff --git a/src/config/sessions/transcript-write-context.ts b/src/config/sessions/transcript-write-context.ts index 36a29567b9f1..7dc80445fac0 100644 --- a/src/config/sessions/transcript-write-context.ts +++ b/src/config/sessions/transcript-write-context.ts @@ -12,6 +12,7 @@ type OwnedSessionTranscriptWriteContext = { const ownedTranscriptWriteContext = new AsyncLocalStorage(); +// Compare resolved paths when available; fall back to session keys for lock reuse. function normalizePathForCompare(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? path.resolve(trimmed) : undefined; @@ -33,6 +34,7 @@ function contextMatches(params: { return Boolean(contextSessionKey && sessionKey && contextSessionKey === sessionKey); } +/** Runs transcript writes with an owned write-lock context. */ export async function withOwnedSessionTranscriptWrites( context: OwnedSessionTranscriptWriteContext, run: () => Promise, diff --git a/src/config/sessions/transcript.runtime.ts b/src/config/sessions/transcript.runtime.ts index 2bcd5a6073ae..be9d39936bc6 100644 --- a/src/config/sessions/transcript.runtime.ts +++ b/src/config/sessions/transcript.runtime.ts @@ -1,3 +1,4 @@ +// Runtime facade for transcript append helpers used outside config internals. export { appendAssistantMessageToSessionTranscript, appendExactAssistantMessageToSessionTranscript, diff --git a/src/config/sessions/version.ts b/src/config/sessions/version.ts index eac351136971..090cfe48be29 100644 --- a/src/config/sessions/version.ts +++ b/src/config/sessions/version.ts @@ -1 +1,2 @@ +/** Current persisted session transcript/header schema version. */ export const CURRENT_SESSION_VERSION = 3; diff --git a/src/config/shell-env-expected-keys.ts b/src/config/shell-env-expected-keys.ts index 945e067b5179..699e1ca305b3 100644 --- a/src/config/shell-env-expected-keys.ts +++ b/src/config/shell-env-expected-keys.ts @@ -4,6 +4,12 @@ import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.j const CORE_SHELL_ENV_EXPECTED_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]; +/** + * Lists env vars worth importing from login-shell fallback for this config load. + * + * Provider/channel helpers inspect the current environment so optional plugin + * and auth aliases only trigger shell probing when their configured keys matter. + */ export function resolveShellEnvExpectedKeys(env: NodeJS.ProcessEnv): string[] { return uniqueStrings([ ...listKnownProviderAuthEnvVarNames({ env }), diff --git a/src/config/silent-reply.ts b/src/config/silent-reply.ts index ebf1b5e7bd0f..a6aaf047cdc0 100644 --- a/src/config/silent-reply.ts +++ b/src/config/silent-reply.ts @@ -26,6 +26,7 @@ function resolveSilentReplyConversationContext(params: ResolveSilentReplyParams) conversationType: params.conversationType, }); const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + // Surfaces are stored under normalized ids; keep explicit conversationType untouched. const surface = normalizedSurface ? params.cfg?.surfaces?.[normalizedSurface] : undefined; return { conversationType, @@ -34,6 +35,7 @@ function resolveSilentReplyConversationContext(params: ResolveSilentReplyParams) }; } +/** Resolves the effective silent-reply settings for a routed conversation. */ export function resolveSilentReplySettings(params: ResolveSilentReplyParams): { policy: SilentReplyPolicy; } { @@ -43,6 +45,7 @@ export function resolveSilentReplySettings(params: ResolveSilentReplyParams): { }; } +/** Returns just the effective silent-reply policy for callers that do not need metadata. */ export function resolveSilentReplyPolicy(params: ResolveSilentReplyParams): SilentReplyPolicy { return resolveSilentReplySettings(params).policy; } diff --git a/src/config/state-dir-dotenv.ts b/src/config/state-dir-dotenv.ts index c59d21fbd6a5..cd228e79333a 100644 --- a/src/config/state-dir-dotenv.ts +++ b/src/config/state-dir-dotenv.ts @@ -26,6 +26,7 @@ function unwrapMatchingLiteralQuotes(value: string): string { return value; } +/** Returns true when a dotenv value is only a shell reference, not an expanded secret. */ export function isUnresolvedShellReference(value: string): boolean { const candidate = unwrapMatchingLiteralQuotes(value.trim()); // Match only values whose entire content is a shell variable reference: @@ -81,6 +82,7 @@ function parseStateDirDotEnvContent(content: string): ParsedStateDirDotEnv { return { entries, skippedShellReferenceKeys }; } +/** Reads a specific state directory `.env` as managed service env vars. */ export function readStateDirDotEnvVarsFromStateDir(stateDir: string): Record { return readStateDirDotEnvFromStateDir(stateDir).entries; } @@ -112,12 +114,14 @@ export function readStateDirDotEnvVars( return readStateDirDotEnvVarsFromStateDir(stateDir); } +/** Split view of durable gateway service env sources before precedence is applied. */ export type DurableServiceEnvVarSources = { stateDirDotEnvEnvironment: Record; configEnvironment: Record; durableEnvironment: Record; }; +/** Collects durable service env vars from state-dir `.env` and config, preserving each source. */ export function collectDurableServiceEnvVarSources(params: { env: Record; config?: OpenClawConfig; diff --git a/src/config/talk-defaults.ts b/src/config/talk-defaults.ts index ddbd2e4f90c8..08d80bee2b3d 100644 --- a/src/config/talk-defaults.ts +++ b/src/config/talk-defaults.ts @@ -1,9 +1,11 @@ +/** Platform-specific silence windows for talk/voice turn segmentation. */ export const TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM = { macos: 700, android: 700, ios: 900, } as const; +/** Formats the talk silence defaults for config help text. */ export function describeTalkSilenceTimeoutDefaults(): string { const macos = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.macos; const ios = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.ios; diff --git a/src/config/talk.ts b/src/config/talk.ts index c81685f94280..255c9b3239b4 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -165,6 +165,10 @@ function activeProviderFromTalk(talk: TalkConfig): string | undefined { return providerIds.length === 1 ? providerIds[0] : undefined; } +/** + * Normalize persisted Talk config into the canonical provider/providers shape. + * Legacy flat provider fields are ignored here so core config stays provider-agnostic. + */ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig | undefined { if (!isRecord(value)) { return undefined; @@ -213,6 +217,7 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig return Object.keys(normalized).length > 0 ? normalized : undefined; } +/** Return a config copy with `talk` normalized when a valid Talk section is present. */ export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig { if (!config.talk) { return config; @@ -227,6 +232,10 @@ export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig { }; } +/** + * Resolve the single active Talk speech provider and its provider-owned config. + * Ambiguous multi-provider config stays unresolved until `talk.provider` names one. + */ export function resolveActiveTalkProviderConfig( talk: TalkConfig | undefined, ): ResolvedTalkConfig | undefined { @@ -244,6 +253,10 @@ export function resolveActiveTalkProviderConfig( }; } +/** + * Build the gateway `talk.config` payload from persisted config. + * The response includes canonical provider data plus the resolved provider when selection is unambiguous. + */ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined { if (!isRecord(value)) { return undefined; @@ -277,6 +290,8 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un payload.realtime = normalized.realtime; } + // Keep legacy flat ElevenLabs fields readable for clients while migration moves writes to + // talk.provider/providers; normalizeTalkSection intentionally excludes those provider details. const resolved = resolveActiveTalkProviderConfig(normalized) ?? (legacyCompat ? { provider: "elevenlabs", config: legacyCompat } : undefined); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 6d4d29a7bda0..ce6fc3750e56 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -13,10 +13,15 @@ import type { } from "./types.base.js"; import type { MemorySearchConfig } from "./types.tools.js"; +/** Workspace bootstrap-file injection policy for agent system prompts. */ export type AgentContextInjection = "always" | "continuation-skip" | "never"; +/** Optional bootstrap files that setup can skip while still creating required agent files. */ export type OptionalBootstrapFileName = "SOUL.md" | "USER.md" | "HEARTBEAT.md" | "IDENTITY.md"; +/** Embedded runner behavior contract used by strict-agentic provider flows. */ export type EmbeddedAgentExecutionContract = "default" | "strict-agentic"; +/** Prompt-only default for how strongly agents should delegate to sub-agents. */ export type SubagentDelegationMode = "suggest" | "prefer"; +/** Image compression/detail preference used before sending image inputs to models. */ export type AgentImageQualityPreference = "auto" | "efficient" | "balanced" | "high"; export type Gpt5PromptOverlayConfig = { @@ -30,6 +35,7 @@ export type PromptOverlaysConfig = { }; export type AgentModelEntryConfig = { + /** Optional display/lookup alias for this provider/model entry. */ alias?: string; /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ params?: Record; @@ -40,29 +46,43 @@ export type AgentModelEntryConfig = { }; export type AgentModelListConfig = { + /** Primary provider/model ref. */ primary?: string; + /** Ordered provider/model fallback refs. */ fallbacks?: string[]; }; export type AgentContextPruningConfig = { + /** Pruning mode for old tool results in model context. */ mode?: "off" | "cache-ttl"; /** TTL to consider cache expired (duration string, default unit: minutes). */ ttl?: string; + /** Number of most recent assistant turns preserved from pruning. */ keepLastAssistants?: number; + /** Context pressure ratio where soft trimming starts. */ softTrimRatio?: number; + /** Context pressure ratio where hard clearing starts. */ hardClearRatio?: number; + /** Minimum tool-result size before pruning considers it worthwhile. */ minPrunableToolChars?: number; tools?: { + /** Tool names eligible for context pruning. */ allow?: string[]; + /** Tool names excluded from context pruning. */ deny?: string[]; }; softTrim?: { + /** Maximum retained characters for softly trimmed tool results. */ maxChars?: number; + /** Leading characters retained during soft trim. */ headChars?: number; + /** Trailing characters retained during soft trim. */ tailChars?: number; }; hardClear?: { + /** Replace oversized old tool results with a placeholder at high pressure. */ enabled?: boolean; + /** Placeholder text inserted when a tool result is hard-cleared. */ placeholder?: string; }; }; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index b9cee3d6ea00..e2cef3b03434 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -5,6 +5,7 @@ import type { SandboxSshSettings, } from "./types.sandbox.js"; +/** Agent model selector: a single provider/model ref or primary+fallback chain. */ export type AgentModelConfig = | string | { @@ -14,6 +15,7 @@ export type AgentModelConfig = fallbacks?: string[]; }; +/** Tool-specific model selector with an optional capability timeout override. */ export type AgentToolModelConfig = | string | { @@ -30,12 +32,15 @@ export type AgentEmbeddedHarnessConfig = { runtime?: string; }; +/** Runtime selection policy attached to providers, models, and agent defaults. */ export type AgentRuntimePolicyConfig = { /** Agent runtime id. Omitted uses "openclaw"; "auto" opts into plugin harness auto-selection. */ id?: string; }; +/** Per-agent sandbox policy shared by embedded agents and sandbox backends. */ export type AgentSandboxConfig = { + /** Sandbox activation mode for this agent. */ mode?: "off" | "non-main" | "all"; /** Sandbox runtime backend id. Default: "docker". */ backend?: string; @@ -49,6 +54,7 @@ export type AgentSandboxConfig = { sessionToolsVisibility?: "spawned" | "all"; /** Container/workspace scope for sandbox isolation. */ scope?: "session" | "agent" | "shared"; + /** Host workspace root mounted or copied into the sandbox. */ workspaceRoot?: string; /** Docker-specific sandbox settings. */ docker?: SandboxDockerSettings; diff --git a/src/config/types.auth.ts b/src/config/types.auth.ts index 6aae74fe0edb..e60390493c92 100644 --- a/src/config/types.auth.ts +++ b/src/config/types.auth.ts @@ -1,4 +1,5 @@ export type AuthProfileConfig = { + /** Provider id this auth profile can satisfy. */ provider: string; /** * Auth route selected by this profile id. @@ -8,13 +9,18 @@ export type AuthProfileConfig = { * - aws-sdk: AWS SDK default credential chain (no secret in auth-profiles.json) */ mode: "api_key" | "aws-sdk" | "oauth" | "token"; + /** Optional account email shown in profile selection/status surfaces. */ email?: string; + /** Optional human-readable label shown in profile selection/status surfaces. */ displayName?: string; }; export type AuthConfig = { + /** Named auth profiles keyed by profile id. */ profiles?: Record; + /** Preferred profile order per provider id. */ order?: Record; + /** Backoff and same-provider rotation policy for auth/profile failures. */ cooldowns?: { /** Default billing backoff (hours). Default: 5. */ billingBackoffHours?: number; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 3b8db38e74a8..6847373035db 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -1,15 +1,26 @@ import type { ChatType } from "../channels/chat-type.js"; +/** Reply handling mode for chat command surfaces. */ export type ReplyMode = "text" | "command"; +/** Typing indicator timing policy shared by channel configs. */ export type TypingMode = "never" | "instant" | "thinking" | "message"; +/** Session-key ownership model for inbound messages. */ export type SessionScope = "per-sender" | "global"; +/** DM session-key granularity across peers, channels, and accounts. */ export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; +/** Which source messages outbound replies should thread or quote against. */ export type ReplyToMode = "off" | "first" | "all" | "batched"; +/** Group-chat admission policy for channels with allowlists. */ export type GroupPolicy = "open" | "disabled" | "allowlist"; +/** Direct-message admission policy for channels with pairing/allowlists. */ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +/** How much non-allowlisted context is visible to an agent. */ export type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote"; +/** Text splitting strategy for outbound channel delivery. */ export type TextChunkMode = "length" | "newline"; +/** Preview/progress delivery mode while an agent response is still streaming. */ export type StreamingMode = "off" | "partial" | "block" | "progress"; +/** How command text is represented in streaming progress previews. */ export type ChannelStreamingCommandTextMode = "raw" | "status"; export type OutboundRetryConfig = { @@ -24,14 +35,20 @@ export type OutboundRetryConfig = { }; export type BlockStreamingCoalesceConfig = { + /** Minimum buffered characters before coalesced block delivery. */ minChars?: number; + /** Maximum buffered characters before a block must be flushed. */ maxChars?: number; + /** Idle time in ms before flushing a partial coalesced block. */ idleMs?: number; }; export type BlockStreamingChunkConfig = { + /** Minimum preview chunk size before sending another draft update. */ minChars?: number; + /** Maximum preview chunk size before forcing a draft update. */ maxChars?: number; + /** Preferred natural boundary when splitting preview chunks. */ breakPreference?: "paragraph" | "newline" | "sentence"; }; @@ -95,6 +112,7 @@ export type ChannelStreamingConfig = { export type ChannelDeliveryStreamingConfig = Pick; +/** Streaming subset used by channels that render visible preview/progress replies. */ export type ChannelPreviewStreamingConfig = Pick< ChannelStreamingConfig, "mode" | "chunkMode" | "preview" | "progress" | "block" @@ -118,7 +136,9 @@ export type HumanDelayConfig = { export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { + /** Channel/provider id match. */ channel?: string; + /** Direct/group/thread classification when the caller has channel metadata. */ chatType?: ChatType; /** * Session key prefix match. @@ -129,11 +149,15 @@ export type SessionSendPolicyMatch = { rawKeyPrefix?: string; }; export type SessionSendPolicyRule = { + /** Action applied when match criteria select this rule. */ action: SessionSendPolicyAction; + /** Optional match filter; omitted match behaves as a catch-all rule. */ match?: SessionSendPolicyMatch; }; export type SessionSendPolicyConfig = { + /** Fallback action when no send-policy rule matches. */ default?: SessionSendPolicyAction; + /** Ordered allow/deny rules; first matching rule wins. */ rules?: SessionSendPolicyRule[]; }; @@ -222,6 +246,7 @@ export type SessionWriteLockConfig = { export type SessionMaintenanceMode = "enforce" | "warn"; +/** Session-store cleanup policy for transcript count, age, archives, and disk budget. */ export type SessionMaintenanceConfig = { /** Whether to enforce maintenance or warn only. Default: "warn". */ mode?: SessionMaintenanceMode; @@ -298,10 +323,15 @@ export type DiagnosticsOtelConfig = { }; export type DiagnosticsCacheTraceConfig = { + /** Write prompt-cache trace artifacts for debugging deterministic cache input. */ enabled?: boolean; + /** Optional output path for cache trace artifacts. */ filePath?: string; + /** Include normalized messages in cache trace output. */ includeMessages?: boolean; + /** Include prompt payload text in cache trace output. */ includePrompt?: boolean; + /** Include system-message content in cache trace output. */ includeSystem?: boolean; }; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 85fe3f560e9c..461e63a90d46 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -22,7 +22,9 @@ export type { export type { ChannelBotLoopProtectionConfig } from "./types.bot-loop-protection.js"; export type ChannelDefaultsConfig = { + /** Default group-chat admission policy inherited by channels that support groups. */ groupPolicy?: GroupPolicy; + /** Default history/context visibility inherited by channel configs. */ contextVisibility?: ContextVisibilityMode; /** Default heartbeat visibility for all channels. */ heartbeat?: ChannelHeartbeatVisibilityConfig; @@ -30,22 +32,32 @@ export type ChannelDefaultsConfig = { botLoopProtection?: ChannelBotLoopProtectionConfig; }; +/** Provider/channel/target model override map used by channel dispatch. */ export type ChannelModelByChannelConfig = Record>; export type ExtensionNestedPolicyConfig = { + /** Channel/plugin-owned nested policy mode, such as dm/group allowlist policy. */ policy?: string; + /** Sender ids, usernames, or platform ids accepted by the nested policy. */ allowFrom?: Array | ReadonlyArray; + /** Plugin-owned config keys that are intentionally outside the core schema. */ [key: string]: unknown; }; export type ExtensionAccountConfig = ExtensionNestedPolicyConfig & { + /** Account-scoped default delivery target for CLI --deliver. */ defaultTo?: string | number; + /** Account-scoped direct-message policy override. */ dmPolicy?: string; + /** Nested DM policy/config owned by the plugin. */ dm?: ExtensionNestedPolicyConfig; + /** Account-scoped media size limit in megabytes. */ mediaMaxMb?: number; + /** Whether channel setup/doctor flows may write this account config. */ configWrites?: boolean; }; +/** JSON-compatible open-world channel section for plugin ids unknown to core. */ type OpenWorldChannelConfig = ReturnType; /** @@ -53,42 +65,66 @@ type OpenWorldChannelConfig = ReturnType; * Extensions can use this as a starting point for their channel config. */ export type ExtensionChannelConfig = { + /** Enables this plugin-owned channel section. */ enabled?: boolean; + /** Sender ids, usernames, or platform ids allowed by the channel policy. */ allowFrom?: Array | ReadonlyArray; /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ defaultTo?: string | number; /** Optional default account id when multiple accounts are configured. */ defaultAccount?: string; + /** Plugin-owned direct-message policy mode. */ dmPolicy?: string; + /** Plugin-owned group admission policy mode. */ groupPolicy?: GroupPolicy; + /** Mention include/exclude policy shared by channels with group support. */ mentionPatterns?: MentionPatternsPolicyConfig | string[]; + /** Channel-specific context visibility override. */ contextVisibility?: ContextVisibilityMode; + /** Channel health-monitor settings exposed through the shared channel contract. */ healthMonitor?: ChannelHealthMonitorConfig; + /** Nested direct-message config owned by the channel plugin. */ dm?: ExtensionNestedPolicyConfig; + /** Plugin-owned network config, including private-network controls when supported. */ network?: Record; + /** Plugin-owned group config keyed by platform group id/name. */ groups?: Record; + /** Plugin-owned room config keyed by platform room id/name. */ rooms?: Record; + /** Channel-wide media size limit in megabytes. */ mediaMaxMb?: number; + /** Base callback URL used by interaction/webhook-capable channel plugins. */ callbackBaseUrl?: string; + /** Interaction callback config; callbackBaseUrl mirrors the top-level fallback. */ interactions?: { callbackBaseUrl?: string; [key: string]: unknown }; + /** Plugin-owned native exec approval routing config. */ execApprovals?: Record; threadBindings?: { + /** Enables thread-bound session routing for this channel. */ enabled?: boolean; + /** Allows sessions_spawn/native spawn flows to bind spawned sessions to threads. */ spawnSessions?: boolean; + /** Default context mode for thread-bound native subagent spawns. */ defaultSpawnContext?: "isolated" | "fork"; /** @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; /** @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; }; + /** Channel-specific bot loop guard settings. */ botLoopProtection?: ChannelBotLoopProtectionConfig; + /** @deprecated Use threadBindings.spawnSessions instead. */ spawnSubagentSessions?: boolean; + /** Explicit opt-in for channels that need private network callbacks or media fetches. */ dangerouslyAllowPrivateNetwork?: boolean; + /** Account-scoped channel config keyed by plugin-defined account id. */ accounts?: Record; + /** Plugin-owned config keys intentionally stay open-world at this boundary. */ [key: string]: unknown; }; export interface ChannelsConfig { + /** Shared defaults inherited by channel sections unless they override them. */ defaults?: ChannelDefaultsConfig; /** Map provider -> channel id -> model override. */ modelByChannel?: ChannelModelByChannelConfig; diff --git a/src/config/types.crestodian.ts b/src/config/types.crestodian.ts index fbc210a1e18e..fb7ade29997d 100644 --- a/src/config/types.crestodian.ts +++ b/src/config/types.crestodian.ts @@ -1,3 +1,8 @@ +/** + * Crestodian config types for local control-plane and remote rescue behavior. + * Rescue config is deliberately narrow because it can approve state-changing maintainer actions. + */ +/** Remote rescue gate and approval retention policy. */ type CrestodianRescueConfig = { /** * Remote message rescue gate. @@ -10,6 +15,7 @@ type CrestodianRescueConfig = { pendingTtlMinutes?: number; }; +/** Top-level Crestodian config block. */ export type CrestodianConfig = { rescue?: CrestodianRescueConfig; }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 8eebe9c5d799..c6926d6d3c65 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -1,5 +1,6 @@ import type { SecretInput } from "./types.secrets.js"; +/** Gateway bind-address policy for local server startup. */ export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; export type GatewayTlsConfig = { @@ -16,11 +17,13 @@ export type GatewayTlsConfig = { }; export type WideAreaDiscoveryConfig = { + /** Enable DNS-SD style wide-area discovery. */ enabled?: boolean; /** Optional unicast DNS-SD domain (e.g. "openclaw.internal"). */ domain?: string; }; +/** mDNS/Bonjour metadata exposure level for local gateway discovery. */ export type MdnsDiscoveryMode = "off" | "minimal" | "full"; export type MdnsDiscoveryConfig = { @@ -34,7 +37,9 @@ export type MdnsDiscoveryConfig = { }; export type DiscoveryConfig = { + /** Wide-area DNS-SD discovery settings. */ wideArea?: WideAreaDiscoveryConfig; + /** Local mDNS/Bonjour discovery settings. */ mdns?: MdnsDiscoveryConfig; }; @@ -147,6 +152,7 @@ export type GatewayControlUiConfig = { dangerouslyDisableDeviceAuth?: boolean; }; +/** Gateway authentication strategy for WebSocket and HTTP clients. */ export type GatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; /** @@ -209,6 +215,7 @@ export type GatewayAuthRateLimitConfig = { exemptLoopback?: boolean; }; +/** Tailscale exposure mode for gateway HTTP/WebSocket surfaces. */ export type GatewayTailscaleMode = "off" | "serve" | "funnel"; export type GatewayTailscaleConfig = { @@ -248,6 +255,7 @@ export type GatewayRemoteConfig = { sshIdentity?: string; }; +/** Gateway config reload strategy for managed installs. */ export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid"; export type GatewayReloadConfig = { @@ -380,7 +388,9 @@ export type GatewayHttpResponsesImagesConfig = { }; export type GatewayHttpEndpointsConfig = { + /** OpenAI-compatible chat completions endpoint controls. */ chatCompletions?: GatewayHttpChatCompletionsConfig; + /** OpenResponses-compatible responses endpoint controls. */ responses?: GatewayHttpResponsesConfig; }; @@ -395,7 +405,9 @@ export type GatewayHttpSecurityHeadersConfig = { }; export type GatewayHttpConfig = { + /** Per-endpoint HTTP API controls. */ endpoints?: GatewayHttpEndpointsConfig; + /** HTTP security header overrides. */ securityHeaders?: GatewayHttpSecurityHeadersConfig; }; @@ -407,10 +419,12 @@ export type GatewayPushApnsRelayConfig = { }; export type GatewayPushApnsConfig = { + /** External APNs relay used by iOS/mobile notification flows. */ relay?: GatewayPushApnsRelayConfig; }; export type GatewayPushConfig = { + /** Apple Push Notification Service settings. */ apns?: GatewayPushApnsConfig; }; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 168af5b1f30b..5cc349412ce9 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -1,3 +1,7 @@ +/** + * iMessage channel config types shared by core schema, bundled plugin runtime, and plugin SDK exports. + * Root fields apply to the default account; `accounts` entries override them per account. + */ import type { BlockStreamingCoalesceConfig, ContextVisibilityMode, @@ -12,6 +16,7 @@ import type { import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; +/** Private-API and helper actions the iMessage runtime may expose to agents. */ export type IMessageActionConfig = { reactions?: boolean; edit?: boolean; @@ -26,8 +31,10 @@ export type IMessageActionConfig = { sendAttachment?: boolean; }; +/** Inbound tapback notification policy. */ export type IMessageReactionNotificationMode = "off" | "own" | "all"; +/** Per-account iMessage runtime/config shape. */ export type IMessageAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -166,6 +173,7 @@ export type IMessageAccountConfig = { responsePrefix?: string; }; +/** Top-level iMessage config, with optional account map layered over default account fields. */ export type IMessageConfig = { /** Optional per-account iMessage configuration (multi-account). */ accounts?: Record; diff --git a/src/config/types.installs.ts b/src/config/types.installs.ts index adc90daa1cc9..c1503f798872 100644 --- a/src/config/types.installs.ts +++ b/src/config/types.installs.ts @@ -1,3 +1,4 @@ +/** Base persisted install record shared by plugin and skill install tracking. */ export type InstallRecordBase = { source: "npm" | "archive" | "path" | "clawhub" | "git"; spec?: string; diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index 2bb15d11aad6..47f62e4c0f8b 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -1,16 +1,26 @@ +/** + * Memory config types shared by core context-engine paths and memory host/plugin runtimes. + * Builtin memory stays core-owned; qmd settings describe the external QMD integration. + */ import type { SessionSendPolicyConfig } from "./types.base.js"; +/** Memory backend family selected for retrieval and session memory features. */ export type MemoryBackend = "builtin" | "qmd"; +/** Citation rendering mode for memory-injected context. */ export type MemoryCitationsMode = "auto" | "on" | "off"; +/** QMD search command flavor used for retrieval. */ export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; +/** QMD startup/update scheduling mode. */ export type MemoryQmdStartupMode = "off" | "idle" | "immediate"; +/** Top-level memory config block. */ export type MemoryConfig = { backend?: MemoryBackend; citations?: MemoryCitationsMode; qmd?: MemoryQmdConfig; }; +/** QMD-specific memory backend config. */ export type MemoryQmdConfig = { command?: string; mcporter?: MemoryQmdMcporterConfig; @@ -24,6 +34,7 @@ export type MemoryQmdConfig = { scope?: SessionSendPolicyConfig; }; +/** mcporter daemon integration for long-lived QMD MCP access. */ export type MemoryQmdMcporterConfig = { /** * Route QMD searches through mcporter (MCP runtime) instead of spawning `qmd` per query. @@ -38,18 +49,21 @@ export type MemoryQmdMcporterConfig = { startDaemon?: boolean; }; +/** Additional QMD index path entry. */ export type MemoryQmdIndexPath = { path: string; name?: string; pattern?: string; }; +/** Session export settings for QMD memory indexing. */ export type MemoryQmdSessionConfig = { enabled?: boolean; exportDir?: string; retentionDays?: number; }; +/** Background update and embedding schedule for QMD memory. */ export type MemoryQmdUpdateConfig = { interval?: string; debounceMs?: number; @@ -63,6 +77,7 @@ export type MemoryQmdUpdateConfig = { embedTimeoutMs?: number; }; +/** Retrieval and injection limits for QMD memory results. */ export type MemoryQmdLimitsConfig = { maxResults?: number; maxSnippetChars?: number; diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 307188c91a34..96dd3fbc87f0 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -8,6 +8,7 @@ import type { AgentRuntimePolicyConfig } from "./types.agents-shared.js"; import type { ConfiguredModelProviderRequest } from "./types.provider-request.js"; import type { SecretInput } from "./types.secrets.js"; +/** Provider API adapter ids accepted by model/provider config and schema generation. */ export const MODEL_APIS = [ "openai-completions", "openai-responses", @@ -58,6 +59,7 @@ export type SupportedThinkingFormat = | "openrouter" | "together"; +/** Thinking/reasoning payload dialects emitted by OpenAI-compatible providers. */ export const MODEL_THINKING_FORMATS = [ "openai", "openrouter", @@ -68,26 +70,42 @@ export const MODEL_THINKING_FORMATS = [ "zai", ] as const satisfies readonly SupportedThinkingFormat[]; +/** Runtime guard for config-provided thinking format strings. */ export function isModelThinkingFormat(value: string): value is SupportedThinkingFormat { return (MODEL_THINKING_FORMATS as readonly string[]).includes(value); } +/** Provider/model compatibility switches consumed by request builders and tool schema adapters. */ export type ModelCompatConfig = SupportedOpenAICompatFields & SupportedOpenAIResponsesCompatFields & SupportedAnthropicMessagesCompatFields & { + /** Reasoning/thinking payload dialect for provider-compatible APIs. */ thinkingFormat?: SupportedThinkingFormat; + /** Provider-accepted reasoning effort labels. */ supportedReasoningEfforts?: string[]; + /** Maps OpenClaw reasoning effort labels to provider-specific labels. */ reasoningEffortMap?: Record; + /** Reasoning detail block types safe to expose in visible transcripts. */ visibleReasoningDetailTypes?: string[]; + /** Whether this model supports tool/function calling. */ supportsTools?: boolean; + /** Whether provider accepts prompt-cache/session affinity keys. */ supportsPromptCacheKey?: boolean; + /** Whether all message parts must be coerced to plain strings. */ requiresStringContent?: boolean; + /** Whether unknown message payload keys must be stripped before requests. */ strictMessageKeys?: boolean; + /** Named tool-schema profile used by provider adapters. */ toolSchemaProfile?: string; + /** JSON Schema keywords rejected by this provider's tool schema validator. */ unsupportedToolSchemaKeywords?: string[]; + /** Whether this model/provider exposes a native web search tool. */ nativeWebSearchTool?: boolean; + /** Encoding expected for tool-call arguments in provider payloads. */ toolCallArgumentsEncoding?: string; + /** Whether Mistral-compatible tool-call ids must be generated/normalized. */ requiresMistralToolIds?: boolean; + /** Whether OpenAI-style calls must be reshaped to Anthropic-compatible tool payloads. */ requiresOpenAiAnthropicToolPayload?: boolean; }; @@ -105,28 +123,44 @@ export type ModelImageInputConfig = { }; export type ModelMediaInputConfig = { + /** Image input limits and accounting hints for this model. */ image?: ModelImageInputConfig; }; +/** Authentication mode expected by a configured model provider. */ export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token"; export type ModelProviderLocalServiceConfig = { + /** Executable started before model requests are sent. */ command: string; + /** Arguments passed without shell expansion. */ args?: string[]; + /** Working directory for the local service process. */ cwd?: string; + /** Environment variables added to the service process. */ env?: Record; + /** Optional health endpoint polled before the provider is considered ready. */ healthUrl?: string; + /** Startup readiness timeout in milliseconds. */ readyTimeoutMs?: number; + /** Idle timeout in milliseconds before stopping the local service. */ idleStopMs?: number; }; export type ModelDefinitionConfig = { + /** Provider-facing model id. */ id: string; + /** Human-readable display name. */ name: string; + /** Optional API adapter override for this model. */ api?: ModelApi; + /** Optional base URL override for this model. */ baseUrl?: string; + /** Whether the model supports reasoning/thinking controls. */ reasoning: boolean; + /** Supported input modalities for routing and media-tool selection. */ input: Array<"text" | "image" | "video" | "audio">; + /** Token pricing in USD per million tokens. */ cost: { input: number; output: number; @@ -145,6 +179,7 @@ export type ModelDefinitionConfig = { range: [number, number] | [number]; }>; }; + /** Provider/native maximum context window in tokens. */ contextWindow: number; /** * Optional effective runtime cap used for compaction/session budgeting. @@ -152,6 +187,7 @@ export type ModelDefinitionConfig = { * prefer a smaller practical window. */ contextTokens?: number; + /** Maximum completion/output token budget. */ maxTokens: number; /** Maps OpenClaw thinking levels to provider/model-specific values. */ thinkingLevelMap?: ThinkingLevelMap; @@ -159,20 +195,32 @@ export type ModelDefinitionConfig = { params?: Record; /** Optional agent execution runtime override for this provider/model pair. */ agentRuntime?: AgentRuntimePolicyConfig; + /** Static headers merged into requests for this model. */ headers?: Record; + /** Provider compatibility flags for payload shaping and feature gating. */ compat?: ModelCompatConfig; + /** Media input limits used by routing and preflight compression. */ mediaInput?: ModelMediaInputConfig; + /** Metadata source marker for models added by CLI/catalog tooling. */ metadataSource?: "models-add"; }; export type ModelProviderConfig = { + /** Provider API base URL. */ baseUrl: string; + /** API key or secret reference for this provider. */ apiKey?: SecretInput; + /** Authentication mode used when resolving credentials for this provider. */ auth?: ModelProviderAuthMode; + /** Default API adapter for models under this provider. */ api?: ModelApi; + /** Provider-level default context window. */ contextWindow?: number; + /** Provider-level effective runtime context cap. */ contextTokens?: number; + /** Provider-level default max output tokens. */ maxTokens?: number; + /** Provider request timeout in seconds. */ timeoutSeconds?: number; /** Optional provider deployment/API region used by provider plugins that expose regional endpoints. */ region?: string; @@ -183,41 +231,59 @@ export type ModelProviderConfig = { agentRuntime?: AgentRuntimePolicyConfig; /** Optional local service to start before calling this provider. */ localService?: ModelProviderLocalServiceConfig; + /** Secret-bearing headers merged into provider requests. */ headers?: Record; + /** Whether default Authorization header injection is enabled. */ authHeader?: boolean; + /** Provider request transport/retry overrides. */ request?: ConfiguredModelProviderRequest; + /** Model catalog entries exposed by this provider. */ models: ModelDefinitionConfig[]; }; +/** Fully materialized provider declaration emitted by provider catalog plugins. */ export type ModelProviderDeclarationConfig = ModelProviderConfig; +/** User config input shape before provider defaults/models are materialized. */ export type ModelProviderConfigInput = Omit, "models"> & { models?: ModelDefinitionConfig[]; }; export type BedrockDiscoveryConfig = { + /** Enable AWS Bedrock model discovery. */ enabled?: boolean; + /** AWS region to query for models. */ region?: string; + /** Optional provider id filters for discovery. */ providerFilter?: string[]; + /** Discovery cache refresh interval in seconds. */ refreshInterval?: number; + /** Context window applied when discovery cannot infer one. */ defaultContextWindow?: number; + /** Max output tokens applied when discovery cannot infer one. */ defaultMaxTokens?: number; }; export type DiscoveryToggleConfig = { + /** Enables the named discovery source. */ enabled?: boolean; }; export type ModelPricingConfig = { + /** Enable external or generated pricing enrichment. */ enabled?: boolean; }; export type ModelsConfig = { + /** Merge provider config with bundled catalogs or replace bundled catalogs entirely. */ mode?: "merge" | "replace"; + /** Configured provider catalog keyed by provider id. */ providers?: Record; + /** Pricing enrichment settings. */ pricing?: ModelPricingConfig; }; +/** Top-level models config input before provider entries are normalized. */ export type ModelsConfigInput = Omit & { providers?: Record; }; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index fa66e6cc4732..42ca23c8a01c 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -30,6 +30,7 @@ import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; import type { ProxyConfig } from "./zod-schema.proxy.js"; +/** One persisted suppression for a known security audit finding. */ export type SecurityAuditSuppression = { /** Exact security audit check id to suppress. */ checkId: string; @@ -42,6 +43,7 @@ export type SecurityAuditSuppression = { }; export type SecurityConfig = { + /** Security audit policy and accepted standing findings. */ audit?: { /** Accepted security audit findings to omit from active summary/findings. */ suppressions?: SecurityAuditSuppression[]; @@ -76,10 +78,13 @@ export type SecurityConfig = { }; export type SurfaceConfigEntry = { + /** Surface-specific silent reply policy for channels or UI integrations. */ silentReply?: SilentReplyPolicyShape; }; +/** Top-level OpenClaw config as read from user/project config files. */ export type OpenClawConfig = { + /** JSON schema URL used by editors and generated config files. */ $schema?: string; meta?: { /** Last OpenClaw version that wrote this config. */ @@ -87,8 +92,11 @@ export type OpenClawConfig = { /** ISO timestamp when this config was last written. */ lastTouchedAt?: string; }; + /** Authentication provider/profile configuration. */ auth?: AuthConfig; + /** Named access groups used by channel/provider policy allowlists. */ accessGroups?: AccessGroupsConfig; + /** ACP integration settings. */ acp?: AcpConfig; env?: { /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ @@ -107,16 +115,26 @@ export type OpenClawConfig = { | undefined; }; wizard?: { + /** Last setup wizard completion timestamp. */ lastRunAt?: string; + /** OpenClaw version used by the last completed wizard run. */ lastRunVersion?: string; + /** Git commit used by the last completed wizard run, when available. */ lastRunCommit?: string; + /** Command that invoked the last wizard run. */ lastRunCommand?: string; + /** Whether the last wizard run configured a local or remote install. */ lastRunMode?: "local" | "remote"; }; + /** Diagnostics, tracing, and stability debugging settings. */ diagnostics?: DiagnosticsConfig; + /** Log sink, level, rotation, and redaction settings. */ logging?: LoggingConfig; + /** Security audit suppressions and security policy settings. */ security?: SecurityConfig; + /** CLI defaults and command-specific settings. */ cli?: CliConfig; + /** Crestodian rescue/maintenance integration settings. */ crestodian?: CrestodianConfig; update?: { /** Update channel for git + npm installs ("stable", "beta", or "dev"). */ @@ -135,6 +153,7 @@ export type OpenClawConfig = { betaCheckIntervalHours?: number; }; }; + /** Browser automation and browser plugin integration settings. */ browser?: BrowserConfig; ui?: { /** Accent color for OpenClaw UI chrome (hex). */ @@ -146,16 +165,27 @@ export type OpenClawConfig = { avatar?: string; }; }; + /** Secret providers, defaults, and ref-resolution settings. */ secrets?: SecretsConfig; + /** Skill loading and bundled skill configuration. */ skills?: SkillsConfig; + /** Plugin registry/install/runtime configuration. */ plugins?: PluginsConfig; + /** Per-surface policy keyed by channel/UI/runtime surface id. */ surfaces?: Record; + /** Model providers, model catalog, pricing, and catalog merge policy. */ models?: ModelsConfig; + /** Node-host pairing and remote command node settings. */ nodeHost?: NodeHostConfig; + /** Agent definitions, defaults, bindings, and runtime policy. */ agents?: AgentsConfig; + /** Tool exposure, policy, web/media tools, exec, and code-mode settings. */ tools?: ToolsConfig; + /** Legacy/direct agent bindings used by runtime resolution. */ bindings?: AgentBinding[]; + /** Broadcast command and delivery settings. */ broadcast?: BroadcastConfig; + /** Audio command and media handling settings. */ audio?: AudioConfig; media?: { /** Preserve original uploaded filenames when storing inbound media. */ @@ -163,25 +193,41 @@ export type OpenClawConfig = { /** Optional retention window for persisted inbound media cleanup. */ ttlHours?: number; }; + /** Message formatting, delivery, and action settings. */ messages?: MessagesConfig; + /** Chat command settings. */ commands?: CommandsConfig; + /** Human approval workflow settings. */ approvals?: ApprovalsConfig; + /** Session keying, reset, maintenance, send-policy, and thread-binding settings. */ session?: SessionConfig; + /** Web runtime settings, including WhatsApp web transport controls. */ web?: WebConfig; + /** Channel defaults, built-in channel sections, and plugin-owned channel config. */ channels?: ChannelsConfig; + /** Cron schedule and retention settings. */ cron?: CronConfig; + /** Transcript persistence and export settings. */ transcripts?: TranscriptsConfig; + /** Commitment/reminder extraction settings. */ commitments?: CommitmentsConfig; + /** Runtime hook registration and queue behavior. */ hooks?: HooksConfig; + /** Network discovery and service advertisement settings. */ discovery?: DiscoveryConfig; + /** Voice/talk mode configuration. */ talk?: TalkConfig; + /** Gateway server, auth, UI, node-pairing, and dispatch settings. */ gateway?: GatewayConfig; + /** Memory indexing/search configuration. */ memory?: MemoryConfig; + /** MCP client/server and Codex MCP approval configuration. */ mcp?: McpConfig; /** Network-level SSRF protection via an operator-managed forward proxy. */ proxy?: ProxyConfig; }; +/** Config input shape accepted before model provider defaults are fully materialized. */ export type OpenClawConfigInput = Omit & { models?: ModelsConfigInput; }; @@ -192,26 +238,39 @@ type BrandedConfigState = OpenClawConfig & { readonly [openClawConfigStateBrand]?: TState; }; +/** Authored config before include/env resolution and runtime defaults. */ export type SourceConfig = BrandedConfigState<"source">; +/** Source config after includes/env substitution, before runtime defaults. */ export type ResolvedSourceConfig = BrandedConfigState<"resolved-source">; +/** Runtime-materialized config with defaults/normalization applied. */ export type RuntimeConfig = BrandedConfigState<"runtime">; export type ConfigValidationIssue = { + /** Dot-path to the invalid or legacy config value. */ path: string; + /** Human-readable validation message. */ message: string; + /** Optional allowed values shown to the operator. */ allowedValues?: string[]; + /** Number of allowed values omitted from the display list. */ allowedValuesHiddenCount?: number; }; export type LegacyConfigIssue = { + /** Dot-path to the legacy config value. */ path: string; + /** Human-readable migration or rejection message. */ message: string; }; export type ConfigFileSnapshot = { + /** Config file path that was read. */ path: string; + /** Whether the config file exists on disk. */ exists: boolean; + /** Raw file contents before parsing; null when missing. */ raw: string | null; + /** Parsed JSON/JSONC/YAML value before schema normalization. */ parsed: unknown; /** * Config authored on disk after $include resolution and ${ENV} substitution, diff --git a/src/config/types.provider-request.ts b/src/config/types.provider-request.ts index b206f8aacc73..ac5dd5d320a7 100644 --- a/src/config/types.provider-request.ts +++ b/src/config/types.provider-request.ts @@ -1,5 +1,10 @@ +/** + * Config types for provider HTTP transport overrides. + * Values that can carry credentials use SecretInput so redaction and secret refs stay consistent. + */ import type { SecretInput } from "./types.secrets.js"; +/** Authentication override applied to provider requests after model/provider defaults resolve. */ export type ConfiguredProviderRequestAuth = | { mode: "provider-default"; @@ -15,6 +20,7 @@ export type ConfiguredProviderRequestAuth = prefix?: string; }; +/** TLS material and verification knobs for provider or proxy connections. */ export type ConfiguredProviderRequestTls = { ca?: SecretInput; cert?: SecretInput; @@ -24,6 +30,7 @@ export type ConfiguredProviderRequestTls = { insecureSkipVerify?: boolean; }; +/** Proxy selection for provider requests, including optional TLS settings for proxy transport. */ export type ConfiguredProviderRequestProxy = | { mode: "env-proxy"; @@ -35,6 +42,7 @@ export type ConfiguredProviderRequestProxy = tls?: ConfiguredProviderRequestTls; }; +/** Shared provider request overrides used by model providers and media/tool providers. */ export type ConfiguredProviderRequest = { headers?: Record; auth?: ConfiguredProviderRequestAuth; @@ -42,6 +50,7 @@ export type ConfiguredProviderRequest = { tls?: ConfiguredProviderRequestTls; }; +/** Model-provider request overrides plus the private-network opt-in used by model transports. */ export type ConfiguredModelProviderRequest = ConfiguredProviderRequest & { allowPrivateNetwork?: boolean; }; diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 766cf74482ab..8ea328a12f0c 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -1,3 +1,4 @@ +/** Queue handling mode for inbound channel messages. */ export type QueueMode = "steer" | "followup" | "collect" | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index e4e3d756b274..15394ec618ac 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -1,5 +1,6 @@ import { isRecord } from "../utils.js"; +/** Supported secret reference backends in config. */ export type SecretRefSource = "env" | "file" | "exec"; // pragma: allowlist secret /** @@ -15,28 +16,40 @@ export type SecretRef = { id: string; }; +/** Secret-bearing config input: either a literal string or a structured SecretRef. */ export type SecretInput = string | SecretRef; +/** Provider alias used when a SecretRef omits a source-specific provider. */ export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret +/** Strict env-var id shape accepted for env-backed SecretRefs. */ export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; +/** Legacy env SecretRef marker retained for config migration/read compatibility. */ export const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret +/** Older env SecretRef marker retained for migration/read compatibility. */ export const LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX = "__env__:"; // pragma: allowlist secret const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; const ENV_SECRET_SHORTHAND_RE = /^\$([A-Z][A-Z0-9_]{0,127})$/; +/** Secret string read mode: throw on unresolved refs or inspect without resolving. */ export type SecretInputStringResolutionMode = "strict" | "inspect"; +/** Result of reading a secret input without necessarily materializing the secret value. */ export type SecretInputStringResolution = | { status: "available"; value: string; ref: null } | { status: "configured_unavailable"; value: undefined; ref: SecretRef } | { status: "missing"; value: undefined; ref: null }; type SecretDefaults = { + /** Default provider alias for env SecretRefs. */ env?: string; + /** Default provider alias for file SecretRefs. */ file?: string; + /** Default provider alias for exec SecretRefs. */ exec?: string; }; +/** Return whether an env SecretRef id is a supported uppercase environment variable name. */ export function isValidEnvSecretRefId(value: string): boolean { return ENV_SECRET_REF_ID_RE.test(value); } +/** Narrow a value to the canonical SecretRef object shape. */ export function isSecretRef(value: unknown): value is SecretRef { if (!isRecord(value)) { return false; @@ -67,6 +80,7 @@ function isLegacySecretRefWithoutProvider( ); } +/** Parse `$NAME` and `${NAME}` env-secret shorthand strings into env SecretRefs. */ export function parseEnvTemplateSecretRef( value: unknown, provider = DEFAULT_SECRET_PROVIDER_ALIAS, @@ -86,6 +100,7 @@ export function parseEnvTemplateSecretRef( }; } +/** Parse legacy env SecretRef marker strings kept for config migration/read compatibility. */ export function parseLegacySecretRefEnvMarker( value: unknown, provider = DEFAULT_SECRET_PROVIDER_ALIAS, @@ -113,6 +128,7 @@ export function parseLegacySecretRefEnvMarker( }; } +/** Coerce canonical, legacy, and env-shorthand secret inputs into a SecretRef. */ export function coerceSecretRef(value: unknown, defaults?: SecretDefaults): SecretRef | null { if (isSecretRef(value)) { return value; @@ -141,6 +157,7 @@ export function coerceSecretRef(value: unknown, defaults?: SecretDefaults): Secr return null; } +/** Return whether a value contains either a literal secret string or resolvable SecretRef shape. */ export function hasConfiguredSecretInput(value: unknown, defaults?: SecretDefaults): boolean { if (normalizeSecretInputString(value)) { return true; @@ -148,6 +165,7 @@ export function hasConfiguredSecretInput(value: unknown, defaults?: SecretDefaul return coerceSecretRef(value, defaults) !== null; } +/** Trim a literal secret input string while leaving non-string inputs unresolved. */ export function normalizeSecretInputString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -166,6 +184,7 @@ function createUnresolvedSecretInputError(params: { path: string; ref: SecretRef ); } +/** Throw when a secret field still contains an unresolved SecretRef at a read site. */ export function assertSecretInputResolved(params: { value: unknown; refValue?: unknown; @@ -183,6 +202,7 @@ export function assertSecretInputResolved(params: { throw createUnresolvedSecretInputError({ path: params.path, ref }); } +/** Resolve a secret field to either a literal value, a configured-unavailable ref, or missing. */ export function resolveSecretInputString(params: { value: unknown; refValue?: unknown; @@ -220,6 +240,7 @@ export function resolveSecretInputString(params: { }; } +/** Return a strict literal secret value, throwing if the field still points at a SecretRef. */ export function normalizeResolvedSecretInputString(params: { value: unknown; refValue?: unknown; @@ -236,6 +257,7 @@ export function normalizeResolvedSecretInputString(params: { return undefined; } +/** Resolve explicit `refValue` before inline secret references embedded in `value`. */ export function resolveSecretInputRef(params: { value: unknown; refValue?: unknown; @@ -246,6 +268,7 @@ export function resolveSecretInputRef(params: { ref: SecretRef | null; } { const explicitRef = coerceSecretRef(params.refValue, params.defaults); + // Explicit ref fields take precedence so a literal fallback can stay beside a configured ref. const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults); return { explicitRef, diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index af9c92d77009..1258aada3c8e 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -1,12 +1,22 @@ +/** + * Skill-related config types for discovery, installation, limits, and per-skill overrides. + * Secret-bearing skill options use SecretInput so config redaction and secret refs stay consistent. + */ import type { SecretInput } from "./types.secrets.js"; +/** Per-skill runtime override keyed by skill name or source-specific skill key. */ export type SkillConfig = { + /** Disable a discovered skill without removing it from disk. */ enabled?: boolean; + /** Optional secret made available to the skill runtime through skill env handling. */ apiKey?: SecretInput; + /** Plain environment overrides applied when the skill runs. */ env?: Record; + /** Skill-specific structured config consumed by the skill runtime. */ config?: Record; }; +/** Discovery and watcher settings for skill sources. */ export type SkillsLoadConfig = { /** * Additional skill folders to scan (lowest precedence). @@ -24,6 +34,7 @@ export type SkillsLoadConfig = { watchDebounceMs?: number; }; +/** Skill installation preferences and upload policy. */ export type SkillsInstallConfig = { preferBrew?: boolean; nodeManager?: "npm" | "pnpm" | "yarn" | "bun"; @@ -31,6 +42,7 @@ export type SkillsInstallConfig = { allowUploadedArchives?: boolean; }; +/** Limits that bound skill discovery and model-facing prompt expansion. */ export type SkillsLimitsConfig = { /** Max number of immediate child directories to consider under a skills root before treating it as suspicious. */ maxCandidatesPerRoot?: number; @@ -44,6 +56,7 @@ export type SkillsLimitsConfig = { maxSkillFileBytes?: number; }; +/** Autonomous and approval settings for generated skill proposals. */ export type SkillsWorkshopConfig = { /** Autonomous Skill Workshop behavior controlled separately from user-prompted proposals. */ autonomous?: { @@ -58,6 +71,7 @@ export type SkillsWorkshopConfig = { maxSkillBytes?: number; }; +/** Top-level skills config block in openclaw config. */ export type SkillsConfig = { /** Optional bundled-skill allowlist (only affects bundled skills). */ allowBundled?: string[]; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index fe96a51c71d6..4f110fe25677 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -8,18 +8,25 @@ import type { ConfiguredProviderRequest } from "./types.provider-request.js"; import type { SecretInput } from "./types.secrets.js"; export type MediaUnderstandingScopeMatch = { + /** Channel/provider id to match before running media or link understanding. */ channel?: string; + /** Direct/group classification from the channel runtime, when available. */ chatType?: ChatType; + /** Attachment or link key prefix used for narrow per-source routing. */ keyPrefix?: string; }; export type MediaUnderstandingScopeRule = { + /** Policy applied when match criteria select this scope rule. */ action: SessionSendPolicyAction; + /** Optional match filter; omitted match behaves as a catch-all rule. */ match?: MediaUnderstandingScopeMatch; }; export type MediaUnderstandingScopeConfig = { + /** Fallback action when no scope rule matches. */ default?: SessionSendPolicyAction; + /** Ordered allow/block rules; first matching rule wins. */ rules?: MediaUnderstandingScopeRule[]; }; @@ -238,6 +245,7 @@ export type CodeModeConfig = export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all"; export type ToolPolicyConfig = { + /** Exact tool names allowed after the selected profile is applied. */ allow?: string[]; /** * Additional allowlist entries merged into the effective allowlist. @@ -246,14 +254,18 @@ export type ToolPolicyConfig = { * users to replace/duplicate an existing allowlist or profile. */ alsoAllow?: string[]; + /** Exact tool names denied after allow/profile expansion; deny wins. */ deny?: string[]; + /** Built-in profile used as the base policy before allow/deny merges. */ profile?: ToolProfileId; }; export type GroupToolPolicyConfig = { + /** Sender-specific allowlist entries merged into the group tool policy. */ allow?: string[]; /** Additional allowlist entries merged into allow. */ alsoAllow?: string[]; + /** Sender-specific deny entries; deny wins over allow/profile policy. */ deny?: string[]; }; @@ -273,6 +285,8 @@ export function parseToolsBySenderTypedKey( if (!lowered.startsWith(prefix)) { continue; } + // Preserve the original value casing after the typed prefix; usernames and + // display names can be case-sensitive in channel-specific matching code. return { type, value: trimmed.slice(prefix.length), diff --git a/src/config/version.ts b/src/config/version.ts index f3c183b94eaf..d1fbd255903d 100644 --- a/src/config/version.ts +++ b/src/config/version.ts @@ -13,6 +13,7 @@ type OpenClawVersion = { const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; +/** Parses stable, prerelease, and legacy dot-beta OpenClaw versions. */ export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null { if (!raw) { return null; diff --git a/src/config/web-search-legacy-provider-keys.ts b/src/config/web-search-legacy-provider-keys.ts index 4492d5a8477c..b7f4af6c8439 100644 --- a/src/config/web-search-legacy-provider-keys.ts +++ b/src/config/web-search-legacy-provider-keys.ts @@ -1,3 +1,4 @@ +/** Legacy config keys that used to live under web search provider config. */ export const LEGACY_WEB_SEARCH_PROVIDER_CONFIG_KEYS = new Set([ "brave", "duckduckgo", diff --git a/src/config/zod-schema.agent-model.ts b/src/config/zod-schema.agent-model.ts index 19c22395845b..4dec4bb3267f 100644 --- a/src/config/zod-schema.agent-model.ts +++ b/src/config/zod-schema.agent-model.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +/** Schema for agent model config accepting a string or fallback object. */ export const AgentModelSchema = z.union([ z.string(), z diff --git a/src/config/zod-schema.approvals.ts b/src/config/zod-schema.approvals.ts index 9730e7112ef2..5897dbd55fd1 100644 --- a/src/config/zod-schema.approvals.ts +++ b/src/config/zod-schema.approvals.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +/** Native exec approval mode accepted by config. */ export const NativeExecApprovalEnableModeSchema = z.union([z.boolean(), z.literal("auto")]); const ExecApprovalForwardTargetSchema = z diff --git a/src/config/zod-schema.channels.ts b/src/config/zod-schema.channels.ts index 94d6d24caed1..10bd2da41207 100644 --- a/src/config/zod-schema.channels.ts +++ b/src/config/zod-schema.channels.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +/** Optional heartbeat visibility controls shared by channel schemas. */ export const ChannelHeartbeatVisibilitySchema = z .object({ showOk: z.boolean().optional(), diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 73fd382641e3..0bf3fe273b0e 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -20,6 +20,8 @@ const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; function isAbsolutePath(value: string): boolean { + // `path.isAbsolute` follows the host OS, but config files can be authored for Windows from + // macOS/Linux. Accept Windows forms explicitly so cross-platform config validation stays stable. return ( path.isAbsolute(value) || WINDOWS_ABS_PATH_PATTERN.test(value) || @@ -76,12 +78,14 @@ const ExecSecretRefSchema = z }) .strict(); +/** Config-level secret reference schema shared by model/provider/plugin credential fields. */ export const SecretRefSchema = z.discriminatedUnion("source", [ EnvSecretRefSchema, FileSecretRefSchema, ExecSecretRefSchema, ]); +/** Accepts either legacy inline secret strings or structured secret references. */ export const SecretInputSchema = z.union([z.string(), SecretRefSchema]); const SecretsEnvProviderSchema = z @@ -161,17 +165,19 @@ const SecretsExecProviderSchema = z.union([ SecretsPluginIntegrationExecProviderSchema, ]); +/** Schema for one configured env/file/exec secret provider entry. */ export const SecretProviderSchema = z.union([ SecretsEnvProviderSchema, SecretsFileProviderSchema, SecretsExecProviderSchema, ]); +/** Schema for the top-level `secrets` config block. */ export const SecretsConfigSchema = z .object({ providers: z .object({ - // Keep this as a record so users can define multiple providers per source. + // Keep this as a record so users can define multiple named providers per source. }) .catchall(SecretProviderSchema) .optional(), diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index d8acd47b79ec..eaa481f1ee8a 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -10,6 +10,7 @@ const InstallSourceSchema = z.union([ const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]); +/** Zod object shape for persisted generic install records. */ export const InstallRecordShape = { source: InstallSourceSchema, spec: z.string().optional(), diff --git a/src/config/zod-schema.providers-googlechat.ts b/src/config/zod-schema.providers-googlechat.ts index 61fe4e04df3f..c7a42e4ccfc9 100644 --- a/src/config/zod-schema.providers-googlechat.ts +++ b/src/config/zod-schema.providers-googlechat.ts @@ -13,6 +13,7 @@ import { } from "./zod-schema.core.js"; import { sensitive } from "./zod-schema.sensitive.js"; +/** DM policy schema for Google Chat accounts. */ export const GoogleChatDmSchema = z .object({ enabled: z.boolean().optional(), diff --git a/src/config/zod-schema.secret-input-validation.ts b/src/config/zod-schema.secret-input-validation.ts index 5f5798ea65d7..fd00b918825f 100644 --- a/src/config/zod-schema.secret-input-validation.ts +++ b/src/config/zod-schema.secret-input-validation.ts @@ -26,6 +26,7 @@ type SlackConfigLike = { accounts?: Record; }; +// Only enabled accounts need per-account secret requirement checks. function forEachEnabledAccount( accounts: Record | undefined, run: (accountId: string, account: T) => void, @@ -41,6 +42,7 @@ function forEachEnabledAccount( } } +/** Validates Telegram webhook URLs have a usable shared or account webhook secret. */ export function validateTelegramWebhookSecretRequirements( value: TelegramConfigLike, ctx: z.RefinementCtx, diff --git a/src/context-engine/legacy.registration.ts b/src/context-engine/legacy.registration.ts index 769681d33e55..5f80c2b6e478 100644 --- a/src/context-engine/legacy.registration.ts +++ b/src/context-engine/legacy.registration.ts @@ -1,6 +1,8 @@ import { LegacyContextEngine } from "./legacy.js"; import { registerContextEngineForOwner } from "./registry.js"; +// Registers the built-in legacy context engine under the core owner. Refresh is +// allowed so tests/bootstrap can re-register after module-state resets. export function registerLegacyContextEngine(): void { registerContextEngineForOwner("legacy", async () => new LegacyContextEngine(), "core", { allowSameOwnerRefresh: true, diff --git a/src/crestodian/assistant-backends.ts b/src/crestodian/assistant-backends.ts index d89efcb25bfd..bda7fcd6f82b 100644 --- a/src/crestodian/assistant-backends.ts +++ b/src/crestodian/assistant-backends.ts @@ -1,6 +1,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { CrestodianOverview } from "./overview.js"; +/** + * Local planner backend selection for Crestodian assistant mode. + * + * Crestodian only offers planners backed by tools present on the host, and the + * returned backend config is scoped to the workspace being repaired. + */ const CRESTODIAN_CLAUDE_CLI_MODEL = "claude-opus-4-8"; const CRESTODIAN_CODEX_MODEL = "gpt-5.5"; @@ -32,6 +38,7 @@ const CODEX_APP_SERVER_BACKEND: CrestodianLocalPlannerBackend = { buildConfig: buildCodexAppServerPlannerConfig, }; +/** Select local assistant planner backends available for the current overview. */ export function selectCrestodianLocalPlannerBackends( overview: CrestodianOverview, ): CrestodianLocalPlannerBackend[] { diff --git a/src/crestodian/assistant-prompts.ts b/src/crestodian/assistant-prompts.ts index e14799c0831c..c5b62cf6012f 100644 --- a/src/crestodian/assistant-prompts.ts +++ b/src/crestodian/assistant-prompts.ts @@ -1,8 +1,17 @@ import type { CrestodianOverview } from "./overview.js"; +/** + * Prompt construction and response parsing for Crestodian assistant planning. + * + * The assistant is constrained to return one safe Crestodian command as JSON; + * parsing stays deliberately narrow so free-form model text does not execute. + */ +/** Timeout for one assistant planner call. */ export const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 10_000; +/** Maximum assistant planner response budget. */ export const CRESTODIAN_ASSISTANT_MAX_TOKENS = 512; +/** System prompt that limits the assistant to Crestodian's command vocabulary. */ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "You are Crestodian, OpenClaw's ring-zero setup helper.", "Turn the user's request into exactly one safe OpenClaw Crestodian command.", @@ -38,12 +47,14 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "If unsure, choose overview.", ].join("\n"); +/** Parsed assistant plan before it is re-validated as a Crestodian operation. */ export type CrestodianAssistantPlan = { command: string; reply?: string; modelLabel?: string; }; +/** Build the overview-grounded user prompt supplied to assistant planners. */ export function buildCrestodianAssistantUserPrompt(params: { input: string; overview: CrestodianOverview; @@ -84,6 +95,7 @@ export function buildCrestodianAssistantUserPrompt(params: { ].join("\n"); } +/** Parse compact assistant JSON while ignoring surrounding explanatory text. */ export function parseCrestodianAssistantPlanText( rawText: string | undefined, ): CrestodianAssistantPlan | null { @@ -117,6 +129,7 @@ export function parseCrestodianAssistantPlanText( } function extractFirstJsonObject(text: string): string | null { + // Planner output must be JSON, but this tolerates model wrappers before re-validating fields. const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start < 0 || end <= start) { diff --git a/src/crestodian/audit.ts b/src/crestodian/audit.ts index 0760292f7db1..b6cf65e710ab 100644 --- a/src/crestodian/audit.ts +++ b/src/crestodian/audit.ts @@ -3,6 +3,12 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { appendRegularFile } from "../infra/fs-safe.js"; +/** + * Append-only audit log helpers for Crestodian writes. + * + * Discovery and read-only commands stay quiet; persistent operations append a + * JSONL entry under the state directory with config hashes and redacted details. + */ type CrestodianAuditEntry = { timestamp: string; operation: string; @@ -13,6 +19,7 @@ type CrestodianAuditEntry = { details?: Record; }; +/** Resolve the JSONL audit path for Crestodian persistent operations. */ export function resolveCrestodianAuditPath( env: NodeJS.ProcessEnv = process.env, stateDir = resolveStateDir(env), @@ -20,6 +27,7 @@ export function resolveCrestodianAuditPath( return path.join(stateDir, "audit", "crestodian.jsonl"); } +/** Append one Crestodian audit entry and return the file path written. */ export async function appendCrestodianAuditEntry( entry: Omit, opts: { env?: NodeJS.ProcessEnv; auditPath?: string } = {}, diff --git a/src/crestodian/crestodian.test-helpers.ts b/src/crestodian/crestodian.test-helpers.ts index c12a7704ae19..cd96823ed587 100644 --- a/src/crestodian/crestodian.test-helpers.ts +++ b/src/crestodian/crestodian.test-helpers.ts @@ -1,5 +1,12 @@ import type { RuntimeEnv } from "../runtime.js"; +/** + * Test helpers for capturing Crestodian runtime output. + * + * Tests use this lightweight runtime instead of the real CLI runtime so exits + * become thrown errors and logs are easy to assert. + */ +/** Create a RuntimeEnv that records log/error lines for tests. */ export function createCrestodianTestRuntime(): { runtime: RuntimeEnv; lines: string[] } { const lines: string[] = []; return { diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index 550380b39c57..f3476a96d771 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -14,11 +14,18 @@ import { type CrestodianOverview, } from "./overview.js"; +/** + * CLI entry point for Crestodian. + * + * This module chooses JSON, one-shot, or interactive TUI mode and delegates all + * command parsing/execution to dialogue and operation modules. + */ type CrestodianInteractiveRunner = ( opts: RunCrestodianOptions, runtime: RuntimeEnv, ) => Promise; +/** Options accepted by the Crestodian command runner. */ export type RunCrestodianOptions = { message?: string; yes?: boolean; @@ -59,6 +66,7 @@ async function runOneShot( }); } +/** Run Crestodian in JSON, one-shot message, or interactive TUI mode. */ export async function runCrestodian( opts: RunCrestodianOptions = {}, runtime: RuntimeEnv = defaultRuntime, @@ -91,6 +99,7 @@ export async function runCrestodian( const inputIsTty = (input as { isTTY?: boolean }).isTTY === true; const outputIsTty = (output as { isTTY?: boolean }).isTTY === true; if (!interactive || !inputIsTty || !outputIsTty) { + // Without a TTY, Crestodian cannot safely ask for confirmation; require --message instead. runtime.error("Crestodian needs an interactive TTY. Use --message for one command."); runtime.exit(1); return; diff --git a/src/crestodian/dialogue.ts b/src/crestodian/dialogue.ts index 4040dde074c9..d9b11e8904b4 100644 --- a/src/crestodian/dialogue.ts +++ b/src/crestodian/dialogue.ts @@ -7,19 +7,28 @@ import { } from "./operations.js"; import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; +/** + * Dialogue helpers for turning user text into Crestodian operations. + * + * Direct command parsing wins; the assistant planner is only consulted for + * non-empty text that did not parse into a known operation. + */ type CrestodianDialogueOptions = { loadOverview?: typeof loadCrestodianOverview; planWithAssistant?: CrestodianAssistantPlanner; }; +/** Format the interactive approval prompt for a persistent operation. */ export function approvalQuestion(operation: CrestodianOperation): string { return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`; } +/** Parse affirmative approval text accepted by the interactive dialogue. */ export function isYes(input: string): boolean { return /^(y|yes|apply|do it|approved?)$/i.test(input.trim()); } +/** Resolve user input to a Crestodian operation, optionally using the assistant planner. */ export async function resolveCrestodianOperation( input: string, runtime: RuntimeEnv, @@ -59,6 +68,7 @@ function logAssistantPlan( plan: CrestodianAssistantPlan, overview: CrestodianOverview, ): void { + // Assistant plans are echoed before execution so the user can see the interpreted command. const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model"; runtime.log(`[crestodian] planner: ${modelLabel}`); if (plan.reply) { diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 64d3ca857a17..9644dd918a13 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -8,6 +8,12 @@ import { resolveUserPath, shortenHomePath } from "../utils.js"; import { appendCrestodianAuditEntry, resolveCrestodianAuditPath } from "./audit.js"; import type { CrestodianOverview } from "./overview.js"; +/** + * Crestodian command parser and operation executor. + * + * Persistent operations require explicit approval, write audit records, and + * lazy-load heavy CLI modules only when the selected operation needs them. + */ type ConfigModule = typeof import("../config/config.js"); type ConfigFileSnapshot = Awaited>; type CrestodianOverviewLoader = () => Promise; @@ -19,6 +25,7 @@ const loadModelsSharedModule = async () => await import("../commands/models/shar const loadConfigCliModule = async () => await import("../cli/config-cli.js"); const loadDoctorModule = async () => await import("../commands/doctor.js"); +/** Parsed Crestodian operation before approval/execution. */ export type CrestodianOperation = | { kind: "none"; message: string } | { kind: "overview" } @@ -51,6 +58,7 @@ export type CrestodianOperation = | { kind: "open-tui"; agentId?: string; workspace?: string } | { kind: "set-default-model"; model: string }; +/** Result returned by the operation executor. */ export type CrestodianOperationResult = { applied: boolean; exitsInteractive?: boolean; @@ -58,6 +66,7 @@ export type CrestodianOperationResult = { nextInput?: string; }; +/** Injectable command dependencies used by tests and alternate runners. */ export type CrestodianCommandDeps = { formatOverview?: CrestodianOverviewFormatter; loadOverview?: CrestodianOverviewLoader; @@ -120,6 +129,7 @@ const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-8"; const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-8"; const CODEX_APP_SERVER_DEFAULT_MODEL_REF = "openai/gpt-5.5"; +/** Parse one user command into Crestodian's closed operation union. */ export function parseCrestodianOperation(input: string): CrestodianOperation { const trimmed = input.trim(); const lower = trimmed.toLowerCase(); @@ -137,6 +147,7 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { } const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE); if (configSetRefMatch?.groups?.path && configSetRefMatch.groups.id?.trim()) { + // SecretRef commands store references only; raw secret values are never embedded here. const source = configSetRefMatch.groups.source?.toLowerCase() ?? "env"; return { kind: "config-set-ref", @@ -295,11 +306,13 @@ function validateCrestodianPluginInstallSpec(spec: string): string | null { return "Crestodian plugin install accepts one npm or ClawHub package spec."; } if (/^(?:\.{1,2}\/|\/|~\/|file:|git(?:\+ssh|\+https)?:|https?:)/i.test(trimmed)) { + // Crestodian does not install local paths or URLs; those can execute arbitrary package code. return "Crestodian plugin install accepts npm or ClawHub package specs only."; } return null; } +/** Return whether an operation can change local state or process lifecycle. */ export function isPersistentCrestodianOperation(operation: CrestodianOperation): boolean { return ( operation.kind === "set-default-model" || @@ -316,6 +329,7 @@ export function isPersistentCrestodianOperation(operation: CrestodianOperation): ); } +/** Format a user-facing description for an operation requiring approval. */ export function describeCrestodianPersistentOperation(operation: CrestodianOperation): string { switch (operation.kind) { case "set-default-model": @@ -345,6 +359,7 @@ export function describeCrestodianPersistentOperation(operation: CrestodianOpera } } +/** Format the standard approval plan text for a persistent operation. */ export function formatCrestodianPersistentPlan(operation: CrestodianOperation): string { return `Plan: ${describeCrestodianPersistentOperation(operation)}. Say yes to apply.`; } @@ -375,6 +390,7 @@ function chooseSetupModel( model?: string; source: string; } { + // Setup picks an existing/default local credential path before falling back to no model change. if (requestedModel?.trim()) { return { model: requestedModel.trim(), source: "requested" }; } @@ -514,6 +530,7 @@ async function resolveTuiAgentId(params: { return match?.id ?? requested; } +/** Execute a parsed Crestodian operation after applying approval gates and audit logging. */ export async function executeCrestodianOperation( operation: CrestodianOperation, runtime: RuntimeEnv, diff --git a/src/crestodian/probes.ts b/src/crestodian/probes.ts index d432377fb25f..2bccfb887b21 100644 --- a/src/crestodian/probes.ts +++ b/src/crestodian/probes.ts @@ -1,6 +1,13 @@ import { spawn } from "node:child_process"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +/** + * Local environment probes used by Crestodian overview loading. + * + * Probes are bounded by output and timeout limits so setup/status commands do + * not hang or retain unbounded child output. + */ +/** Result from probing a local command binary. */ export type LocalCommandProbe = { command: string; found: boolean; @@ -16,6 +23,7 @@ function appendBounded(previous: string, chunk: string, limit: number): string { return next.length > limit ? next.slice(-limit) : next; } +/** Probe a command by running a small version command with bounded output and timeout. */ export async function probeLocalCommand( command: string, args: string[] = ["--version"], @@ -56,6 +64,7 @@ export async function probeLocalCommand( const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); + // Some CLIs ignore SIGTERM; destroy pipes after a short grace window to finish promptly. killTimer = setTimeout(() => { child.kill("SIGKILL"); child.stdout.destroy(); @@ -95,6 +104,7 @@ export async function probeLocalCommand( }); } +/** Probe a Gateway URL by translating it to its HTTP /healthz endpoint. */ export async function probeGatewayUrl( url: string, opts: { timeoutMs?: number } = {}, diff --git a/src/crestodian/rescue-message.ts b/src/crestodian/rescue-message.ts index e9a93a58e2d9..70e3c5d78097 100644 --- a/src/crestodian/rescue-message.ts +++ b/src/crestodian/rescue-message.ts @@ -20,6 +20,13 @@ import { } from "./operations.js"; import { resolveCrestodianRescuePolicy } from "./rescue-policy.js"; +/** + * Message-channel rescue command handling for Crestodian. + * + * Rescue mode accepts `/crestodian` commands from approved message contexts, + * stores pending persistent operations for explicit confirmation, and captures + * command output without exposing local TUI or plugin-install flows remotely. + */ type RescuePendingOperation = { id: string; createdAt: string; @@ -28,6 +35,7 @@ type RescuePendingOperation = { auditDetails: Record; }; +/** Input required to process one possible `/crestodian` rescue message. */ export type CrestodianRescueMessageInput = { cfg: OpenClawConfig; command: CommandContext; @@ -58,6 +66,7 @@ function createCaptureRuntime(): { runtime: RuntimeEnv; read: () => string } { }; } +/** Extract the command body after `/crestodian`, or null when the message is not for rescue. */ export function extractCrestodianRescueMessage(commandBody: string): string | null { const normalized = commandBody.trim(); const lower = normalized.toLowerCase(); @@ -72,6 +81,7 @@ function resolvePendingDir(env: NodeJS.ProcessEnv = process.env): string { } function resolvePendingPath(input: CrestodianRescueMessageInput): string { + // Pending approval is scoped by sender/channel identity so unrelated chats cannot approve it. const key = JSON.stringify({ channel: input.command.channelId ?? input.command.channel, from: input.command.from, @@ -93,6 +103,7 @@ async function readPending( const expiresAtMs = asDateTimestampMs(Date.parse(parsed.expiresAt)); const nowMs = asDateTimestampMs(now.getTime()); if (expiresAtMs === undefined || nowMs === undefined || expiresAtMs <= nowMs) { + // Expired rescue approvals are deleted before returning so stale writes cannot linger. await fs.rm(pendingPath, { force: true }); return null; } @@ -143,6 +154,7 @@ function formatUnsupportedRemoteOperation(operation: CrestodianOperation): strin return null; } +/** Process one rescue message and return a reply, or null when not a rescue command. */ export async function runCrestodianRescueMessage( input: CrestodianRescueMessageInput, ): Promise { diff --git a/src/crestodian/rescue-policy.ts b/src/crestodian/rescue-policy.ts index 44b0fb930bf7..1fbcdbb4b9b3 100644 --- a/src/crestodian/rescue-policy.ts +++ b/src/crestodian/rescue-policy.ts @@ -1,6 +1,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; +/** + * Policy checks for remote Crestodian rescue commands. + * + * Rescue intentionally opens only for owner-controlled, non-sandboxed YOLO host + * posture unless config explicitly enables it, because remote commands can write local state. + */ type CrestodianRescueDecision = | { allowed: true; @@ -63,6 +69,7 @@ function isYoloHostPosture(cfg: OpenClawConfig, agentId?: string): boolean { return security === "full" && ask === "off"; } +/** Decide whether a message-channel rescue command is allowed for this sender/context. */ export function resolveCrestodianRescuePolicy( input: CrestodianRescuePolicyInput, ): CrestodianRescueDecision { @@ -72,6 +79,7 @@ export function resolveCrestodianRescuePolicy( const pendingTtlMinutes = resolvePendingTtlMinutes(rescue?.pendingTtlMinutes); const sandboxActive = resolveScopedSandboxMode(input.cfg, input.agentId) !== "off"; const yolo = !sandboxActive && isYoloHostPosture(input.cfg, input.agentId); + // "auto" means rescue follows host posture; explicit false/true still keeps owner/DM gates. const enabled = configuredEnabled === "auto" ? yolo : configuredEnabled; if (!enabled) { diff --git a/src/cron/delivery-target-validation.ts b/src/cron/delivery-target-validation.ts index 2a769baa0219..330739e457bc 100644 --- a/src/cron/delivery-target-validation.ts +++ b/src/cron/delivery-target-validation.ts @@ -1,3 +1,4 @@ +// Validation helpers for cron delivery targets before jobs enter runtime dispatch. function assertNonBlankStringField(field: string, value: unknown) { if (value === undefined || value === null || typeof value !== "string") { return; diff --git a/src/cron/delivery.test-helpers.ts b/src/cron/delivery.test-helpers.ts index ce2b8067c061..e8df45cf43db 100644 --- a/src/cron/delivery.test-helpers.ts +++ b/src/cron/delivery.test-helpers.ts @@ -1,5 +1,6 @@ import type { CronJob } from "./types.js"; +/** Builds a minimal cron job fixture with stable defaults for delivery tests. */ export function makeCronJob(overrides: Partial): CronJob { const now = Date.now(); return { diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts index a44c8f640e77..92ea034f38bc 100644 --- a/src/cron/isolated-agent.delivery.test-helpers.ts +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -4,6 +4,7 @@ import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob } from "./isolated-agent.test-harness.js"; +/** Creates mocked CLI delivery deps for isolated-agent delivery tests. */ export function createCliDeps(overrides: Partial = {}): CliDeps { return { sendMessageSlack: vi.fn().mockResolvedValue({ messageTs: "slack-1", channel: "C1" }), diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 13b3970b0408..2dd2cfffb47a 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -4,6 +4,7 @@ import { makeIsolatedAgentParamsFixture, } from "./isolated-agent/job-fixtures.js"; +// Shared Vitest module mocks for isolated-agent cron tests. vi.mock("../agents/embedded-agent.js", () => ({ abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), runEmbeddedAgent: vi.fn(), diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts index 09861c17b27b..447580f3b7ad 100644 --- a/src/cron/isolated-agent.test-harness.ts +++ b/src/cron/isolated-agent.test-harness.ts @@ -4,6 +4,7 @@ import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { CronJob } from "./types.js"; +/** Runs a test callback with an isolated OpenClaw home for cron tests. */ export async function withTempCronHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-cron-" }); } diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index 188927987431..3ea640f9f4b0 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -12,6 +12,7 @@ import { buildChannelOutboundSessionRoute } from "../plugin-sdk/core.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +// Test-only outbound registry for isolated cron turns. type TestSendFn = ( to: string, text: string, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index ad6acffb59cc..0e7a1e95b3a8 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -1 +1,2 @@ +// Public facade for isolated cron agent turn execution. export { type RunCronAgentTurnResult, runCronIsolatedAgentTurn } from "./isolated-agent/run.js"; diff --git a/src/cron/isolated-agent.turn-test-helpers.ts b/src/cron/isolated-agent.turn-test-helpers.ts index d6cc05f75be1..3b865c2c0185 100644 --- a/src/cron/isolated-agent.turn-test-helpers.ts +++ b/src/cron/isolated-agent.turn-test-helpers.ts @@ -12,6 +12,7 @@ import { } from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; +// Reusable turn-level fixtures for isolated cron agent regression tests. export { withTempHome }; export function makeDeps(): CliDeps { diff --git a/src/cron/isolated-agent/delivery-logger.runtime.ts b/src/cron/isolated-agent/delivery-logger.runtime.ts index 57586a2a040b..59668910f2f9 100644 --- a/src/cron/isolated-agent/delivery-logger.runtime.ts +++ b/src/cron/isolated-agent/delivery-logger.runtime.ts @@ -1 +1,2 @@ +// Runtime logging seam for isolated-agent delivery tests. export { logError, logWarn } from "../../logger.js"; diff --git a/src/cron/isolated-agent/delivery-outbound.runtime.ts b/src/cron/isolated-agent/delivery-outbound.runtime.ts index 3ef659decdf8..e7a16824f7fc 100644 --- a/src/cron/isolated-agent/delivery-outbound.runtime.ts +++ b/src/cron/isolated-agent/delivery-outbound.runtime.ts @@ -1,3 +1,4 @@ +// Runtime outbound-delivery seam for isolated cron agent delivery dispatch. export { createOutboundSendDeps } from "../../cli/outbound-send-deps.js"; export { sendDurableMessageBatch } from "../../channels/message/runtime.js"; export { type OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; diff --git a/src/cron/isolated-agent/delivery-subagent-registry.runtime.ts b/src/cron/isolated-agent/delivery-subagent-registry.runtime.ts index 07b4b8295e59..ffdca326890e 100644 --- a/src/cron/isolated-agent/delivery-subagent-registry.runtime.ts +++ b/src/cron/isolated-agent/delivery-subagent-registry.runtime.ts @@ -1 +1,2 @@ +// Runtime subagent registry seam for isolated-agent delivery gating. export { countActiveDescendantRuns } from "../../agents/subagent-registry-read.js"; diff --git a/src/cron/isolated-agent/job-fixtures.ts b/src/cron/isolated-agent/job-fixtures.ts index 3456e7e948d9..5c689b026a8f 100644 --- a/src/cron/isolated-agent/job-fixtures.ts +++ b/src/cron/isolated-agent/job-fixtures.ts @@ -1,5 +1,6 @@ type LooseRecord = Record; +/** Builds a loose cron job fixture for isolated-agent unit tests. */ export function makeIsolatedAgentJobFixture(overrides?: LooseRecord) { return { id: "test-job", diff --git a/src/cron/isolated-agent/run-auth-profile.runtime.ts b/src/cron/isolated-agent/run-auth-profile.runtime.ts index 76945f0619cc..08e1a37a37d4 100644 --- a/src/cron/isolated-agent/run-auth-profile.runtime.ts +++ b/src/cron/isolated-agent/run-auth-profile.runtime.ts @@ -1 +1,2 @@ +// Runtime auth-profile seam for isolated cron agent runs. export { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; diff --git a/src/cron/isolated-agent/run-context.runtime.ts b/src/cron/isolated-agent/run-context.runtime.ts index a9c8f8132436..ac784086a4aa 100644 --- a/src/cron/isolated-agent/run-context.runtime.ts +++ b/src/cron/isolated-agent/run-context.runtime.ts @@ -1 +1,2 @@ +// Runtime context-window seam for isolated cron agent runs. export { lookupContextTokens } from "../../agents/context.js"; diff --git a/src/cron/isolated-agent/run-delivery.runtime.ts b/src/cron/isolated-agent/run-delivery.runtime.ts index 30a03f3e1bb4..a5aedc0bef95 100644 --- a/src/cron/isolated-agent/run-delivery.runtime.ts +++ b/src/cron/isolated-agent/run-delivery.runtime.ts @@ -1,3 +1,4 @@ +// Runtime delivery seam for isolated cron agent run orchestration. export { resolveDeliveryTarget } from "./delivery-target.js"; export { cleanupDirectCronSession, diff --git a/src/cron/isolated-agent/run-embedded.runtime.ts b/src/cron/isolated-agent/run-embedded.runtime.ts index 00e14d0579ca..c9aab3eb52c4 100644 --- a/src/cron/isolated-agent/run-embedded.runtime.ts +++ b/src/cron/isolated-agent/run-embedded.runtime.ts @@ -1,3 +1,4 @@ +// Runtime embedded-agent seam for isolated cron agent execution. export { resolveFastModeState } from "../../agents/fast-mode.js"; export { resolveCronAgentLane } from "../../agents/lanes.js"; export { runEmbeddedAgent } from "../../agents/embedded-agent.js"; diff --git a/src/cron/isolated-agent/run-execution-cli.runtime.ts b/src/cron/isolated-agent/run-execution-cli.runtime.ts index 0be325f01d73..a4efa5166c28 100644 --- a/src/cron/isolated-agent/run-execution-cli.runtime.ts +++ b/src/cron/isolated-agent/run-execution-cli.runtime.ts @@ -1 +1,2 @@ +// Heavy CLI-agent runtime imports kept behind the cron execution lazy boundary. export { getCliSessionId, runCliAgent } from "../../agents/cli-runner.runtime.js"; diff --git a/src/cron/isolated-agent/run-executor.runtime.ts b/src/cron/isolated-agent/run-executor.runtime.ts index 27750600561f..155a42c1a322 100644 --- a/src/cron/isolated-agent/run-executor.runtime.ts +++ b/src/cron/isolated-agent/run-executor.runtime.ts @@ -1 +1,2 @@ +// Runtime executor seam for isolated cron agent runs. export { executeCronRun, type CronExecutionResult } from "./run-executor.js"; diff --git a/src/cron/isolated-agent/run-external-content.runtime.ts b/src/cron/isolated-agent/run-external-content.runtime.ts index f2382495b51e..de7ec1b4f0e0 100644 --- a/src/cron/isolated-agent/run-external-content.runtime.ts +++ b/src/cron/isolated-agent/run-external-content.runtime.ts @@ -1,3 +1,4 @@ +// Runtime external-content safety seam for hook-triggered cron runs. export { buildSafeExternalPrompt, detectSuspiciousPatterns, diff --git a/src/cron/isolated-agent/run-model-catalog.runtime.ts b/src/cron/isolated-agent/run-model-catalog.runtime.ts index 586a99ad338f..6dd11417c161 100644 --- a/src/cron/isolated-agent/run-model-catalog.runtime.ts +++ b/src/cron/isolated-agent/run-model-catalog.runtime.ts @@ -1 +1,2 @@ +// Runtime model catalog seam for isolated cron agent model resolution. export { loadModelCatalog } from "../../agents/model-catalog.js"; diff --git a/src/cron/isolated-agent/run-model-selection.runtime.ts b/src/cron/isolated-agent/run-model-selection.runtime.ts index 4dc2caf01260..62aacf0ae843 100644 --- a/src/cron/isolated-agent/run-model-selection.runtime.ts +++ b/src/cron/isolated-agent/run-model-selection.runtime.ts @@ -1,3 +1,4 @@ +// Runtime model-selection seam for isolated cron agent runs. export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; export { resolveSubagentModelConfigSelectionResult } from "../../agents/agent-scope.js"; export { loadModelCatalog } from "../../agents/model-catalog.js"; diff --git a/src/cron/isolated-agent/run-subagent-registry.runtime.ts b/src/cron/isolated-agent/run-subagent-registry.runtime.ts index 98f01bb64e9a..34d7ef80abec 100644 --- a/src/cron/isolated-agent/run-subagent-registry.runtime.ts +++ b/src/cron/isolated-agent/run-subagent-registry.runtime.ts @@ -1,3 +1,4 @@ +// Runtime subagent registry seam for isolated cron agent execution gating. export { countActiveDescendantRuns, listDescendantRunsForRequester, diff --git a/src/cron/isolated-agent/run.runtime.ts b/src/cron/isolated-agent/run.runtime.ts index 48944528a787..2112b671aee3 100644 --- a/src/cron/isolated-agent/run.runtime.ts +++ b/src/cron/isolated-agent/run.runtime.ts @@ -1,3 +1,4 @@ +// Runtime dependency facade for isolated cron agent turns. export { resolveAgentConfig, resolveAgentDir, diff --git a/src/cron/isolated-agent/run.suite-helpers.ts b/src/cron/isolated-agent/run.suite-helpers.ts index 8cd918747e31..395e7a4bf41b 100644 --- a/src/cron/isolated-agent/run.suite-helpers.ts +++ b/src/cron/isolated-agent/run.suite-helpers.ts @@ -8,6 +8,7 @@ import { restoreFastTestEnv, } from "./run.test-harness.js"; +/** Installs the common before/after hooks for isolated-agent run suites. */ export function setupRunCronIsolatedAgentTurnSuite(options?: { fast?: boolean }) { let previousFastTestEnv: string | undefined; beforeEach(() => { diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 00d0f040c43e..8c8dddda78e9 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -4,6 +4,7 @@ import { resolveFastModeState as resolveFastModeStateImpl } from "../../agents/f import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; +// Central mock harness for isolated cron agent run orchestration tests. type CronSessionEntry = { sessionId: string; updatedAt: number; diff --git a/src/cron/isolated-agent/subagent-followup.runtime.ts b/src/cron/isolated-agent/subagent-followup.runtime.ts index d13df960cf54..f8b2374b8a55 100644 --- a/src/cron/isolated-agent/subagent-followup.runtime.ts +++ b/src/cron/isolated-agent/subagent-followup.runtime.ts @@ -1,3 +1,4 @@ +// Runtime subagent follow-up seam for isolated cron agent completion handling. export { readDescendantSubagentFallbackReply, waitForDescendantSubagentSummary, diff --git a/src/cron/service.issue-regressions.test-helpers.ts b/src/cron/service.issue-regressions.test-helpers.ts index 3e4cdb85fad2..cb26a5096196 100644 --- a/src/cron/service.issue-regressions.test-helpers.ts +++ b/src/cron/service.issue-regressions.test-helpers.ts @@ -16,6 +16,7 @@ import { CronService } from "./service.js"; type CronServiceOptions = ConstructorParameters[0]; +/** Sets up temp store fixtures for cron service issue-regression tests. */ export const setupCronIssueRegressionFixtures = () => setupCronRegressionFixtures({ prefix: "cron-issues-" }); diff --git a/src/daemon/arg-split.ts b/src/daemon/arg-split.ts index 9bdaf74a4fb0..abed1c8299b1 100644 --- a/src/daemon/arg-split.ts +++ b/src/daemon/arg-split.ts @@ -2,6 +2,7 @@ type ArgSplitEscapeMode = "none" | "backslash" | "backslash-quote-only"; type ArgSplitQuoteChar = '"' | "'"; type ArgSplitQuoteStart = "anywhere" | "item-start"; +/** Splits service command strings while preserving quoted arguments across platform parsers. */ export function splitArgsPreservingQuotes( value: string, options?: { diff --git a/src/daemon/cmd-set.ts b/src/daemon/cmd-set.ts index 0688c2859da3..7263a9e536bf 100644 --- a/src/daemon/cmd-set.ts +++ b/src/daemon/cmd-set.ts @@ -1,5 +1,6 @@ type CmdSetAssignment = { key: string; value: string }; +/** Rejects line breaks before rendering values into Windows cmd scripts. */ export function assertNoCmdLineBreak(value: string, field: string): void { if (/[\r\n]/.test(value)) { throw new Error(`${field} cannot contain CR or LF in Windows task scripts.`); diff --git a/src/daemon/container-context.ts b/src/daemon/container-context.ts index 7e80683479fa..8e51b2ea379e 100644 --- a/src/daemon/container-context.ts +++ b/src/daemon/container-context.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Resolves the daemon container hint exposed by managed service environments. */ export function resolveDaemonContainerContext( env: Record = process.env, ): string | null { diff --git a/src/daemon/diagnostics.ts b/src/daemon/diagnostics.ts index f85755a4ad07..5dcd884574ad 100644 --- a/src/daemon/diagnostics.ts +++ b/src/daemon/diagnostics.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { resolveGatewayLogPaths, resolveGatewaySupervisorLogPaths } from "./restart-logs.js"; +// Error patterns worth surfacing from gateway service logs after failed starts. const GATEWAY_LOG_ERROR_PATTERNS = [ /refusing to bind gateway/i, /gateway auth mode/i, diff --git a/src/daemon/exec-file.ts b/src/daemon/exec-file.ts index b38babe47ce0..dbf48bf0fa81 100644 --- a/src/daemon/exec-file.ts +++ b/src/daemon/exec-file.ts @@ -2,6 +2,7 @@ import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_pro type ExecResult = { stdout: string; stderr: string; code: number }; +/** Runs a child process as UTF-8 and returns exit data instead of throwing on nonzero exit. */ export async function execFileUtf8( command: string, args: string[], diff --git a/src/daemon/future-config-guard.ts b/src/daemon/future-config-guard.ts index ff1ea21d1381..41998f140d3c 100644 --- a/src/daemon/future-config-guard.ts +++ b/src/daemon/future-config-guard.ts @@ -5,6 +5,7 @@ import { type FutureConfigActionBlock, } from "../config/future-version-guard.js"; +// Blocks daemon mutations when config was written by a newer OpenClaw. async function readFutureConfigActionBlock( action: string, ): Promise { diff --git a/src/daemon/gateway-entrypoint.ts b/src/daemon/gateway-entrypoint.ts index 9e95927335c3..dced645980cf 100644 --- a/src/daemon/gateway-entrypoint.ts +++ b/src/daemon/gateway-entrypoint.ts @@ -8,6 +8,7 @@ const GATEWAY_DIST_ENTRYPOINT_BASENAMES = [ "entry.mjs", ] as const; +/** Detects built gateway dist entrypoints from service command arguments. */ export function isGatewayDistEntrypointPath(inputPath: string): boolean { return /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(inputPath); } diff --git a/src/daemon/launchd-current-service.ts b/src/daemon/launchd-current-service.ts index 75ece6cef7b0..c52d3f10c25d 100644 --- a/src/daemon/launchd-current-service.ts +++ b/src/daemon/launchd-current-service.ts @@ -4,6 +4,7 @@ export type CurrentProcessLaunchdServiceLabelOptions = { allowConfiguredLabelFallback?: boolean; }; +/** Checks whether the current process appears to be running under the requested launchd label. */ export function isCurrentProcessLaunchdServiceLabel( label: string, env: NodeJS.ProcessEnv = process.env, diff --git a/src/daemon/node-service.ts b/src/daemon/node-service.ts index 0b67c6b477cc..75d79a3c608f 100644 --- a/src/daemon/node-service.ts +++ b/src/daemon/node-service.ts @@ -9,6 +9,7 @@ import { import type { GatewayService, GatewayServiceInstallArgs } from "./service.js"; import { resolveGatewayService } from "./service.js"; +// Wraps the generic gateway service with node-specific service identifiers and env. function withNodeServiceEnv( env: Record, ): Record { diff --git a/src/daemon/output.ts b/src/daemon/output.ts index eeb09fdc6367..a0309fe60e44 100644 --- a/src/daemon/output.ts +++ b/src/daemon/output.ts @@ -1,7 +1,9 @@ import { colorize, isRich, theme } from "../../packages/terminal-core/src/theme.js"; +/** Normalizes Windows separators for command output paths. */ export const toPosixPath = (value: string) => value.replace(/\\/g, "/"); +/** Formats a labeled daemon output line with terminal-aware styling. */ export function formatLine(label: string, value: string): string { const rich = isRich(); return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts index 29abe76bbf9e..0331042fabc4 100644 --- a/src/daemon/paths.ts +++ b/src/daemon/paths.ts @@ -5,6 +5,7 @@ import { resolveGatewayProfileSuffix } from "./constants.js"; const windowsAbsolutePath = /^[a-zA-Z]:[\\/]/; const windowsUncPath = /^\\\\/; +/** Resolves the home directory used for daemon state paths. */ export function resolveHomeDir(env: Record): string { const home = normalizeOptionalString(env.HOME) || normalizeOptionalString(env.USERPROFILE); if (!home) { diff --git a/src/daemon/restart-logs.ts b/src/daemon/restart-logs.ts index db448b6a736c..1360b8659440 100644 --- a/src/daemon/restart-logs.ts +++ b/src/daemon/restart-logs.ts @@ -12,6 +12,7 @@ export type GatewayLogPaths = { stderrPath: string; }; +// Restart logs capture supervisor handoff output when normal service logs are unavailable. function resolveGatewayLogPrefix(env: GatewayServiceEnv): string { return env.OPENCLAW_LOG_PREFIX?.trim() || "gateway"; } diff --git a/src/daemon/runtime-binary.ts b/src/daemon/runtime-binary.ts index 4e127d51e1f7..4963f41d3406 100644 --- a/src/daemon/runtime-binary.ts +++ b/src/daemon/runtime-binary.ts @@ -2,6 +2,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st const NODE_VERSIONED_PATTERN = /^node(?:-\d+|\d+)(?:\.\d+)*(?:\.exe)?$/; +// Accept common Node binary aliases from package managers and Windows installs. function normalizeRuntimeBasename(execPath: string): string { const trimmed = execPath.trim().replace(/^["']|["']$/g, ""); const lastSlash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); diff --git a/src/daemon/runtime-format.ts b/src/daemon/runtime-format.ts index 6eb67a7dd8dd..4ff6c783b06e 100644 --- a/src/daemon/runtime-format.ts +++ b/src/daemon/runtime-format.ts @@ -14,6 +14,7 @@ type ServiceRuntimeLike = { systemd?: { killMode?: string; tasksCurrent?: number; memoryCurrent?: number }; }; +// Windows and systemd expose signal exits as numeric status codes. const SIGNAL_NAMES_BY_STATUS = new Map([ [129, "SIGHUP"], [130, "SIGINT"], diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index ffb11ed2a1a9..389ab67be7b6 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -1,6 +1,7 @@ import { toPosixPath } from "./output.js"; import { resolveGatewayRestartLogPath, resolveGatewaySupervisorLogPaths } from "./restart-logs.js"; +// macOS display paths should not keep Windows drive prefixes from mocked envs. function toDarwinDisplayPath(value: string): string { return toPosixPath(value).replace(/^[A-Za-z]:/, ""); } diff --git a/src/daemon/runtime-parse.ts b/src/daemon/runtime-parse.ts index 21cee5094a6b..b05b33875f4f 100644 --- a/src/daemon/runtime-parse.ts +++ b/src/daemon/runtime-parse.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Parses command output key-value lines using a caller-supplied separator. */ export function parseKeyValueOutput(output: string, separator: string): Record { const entries: Record = {}; for (const rawLine of output.split(/\r?\n/)) { diff --git a/src/daemon/schtasks-exec.ts b/src/daemon/schtasks-exec.ts index 9036c5003ac4..959b7eb33a2c 100644 --- a/src/daemon/schtasks-exec.ts +++ b/src/daemon/schtasks-exec.ts @@ -3,6 +3,7 @@ import { runCommandWithTimeout } from "../process/exec.js"; const SCHTASKS_TIMEOUT_MS = 15_000; const SCHTASKS_NO_OUTPUT_TIMEOUT_MS = 30_000; +/** Runs Windows schtasks with bounded timeouts and normalized process results. */ export async function execSchtasks( args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { diff --git a/src/daemon/service-env-plan.ts b/src/daemon/service-env-plan.ts index 647a01d2468e..2ce80c95afbc 100644 --- a/src/daemon/service-env-plan.ts +++ b/src/daemon/service-env-plan.ts @@ -1,6 +1,7 @@ import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import type { GatewayServiceEnvironmentValueSource } from "./service-types.js"; +/** Provenance labels for environment values rendered into managed services. */ export type ServiceEnvSource = | "state-dotenv" | "config-env" diff --git a/src/daemon/service-env-render-policy.ts b/src/daemon/service-env-render-policy.ts index c0d96e3dc227..9defaae94c66 100644 --- a/src/daemon/service-env-render-policy.ts +++ b/src/daemon/service-env-render-policy.ts @@ -4,6 +4,7 @@ import { writeManagedServiceEnvKeysToEnvironment, } from "./service-managed-env.js"; +// LaunchAgent plists need selected dotenv values inlined so launchd receives them. function isLaunchAgentServiceEnvironment(params: { platform: NodeJS.Platform; serviceEnvironment: Record; diff --git a/src/daemon/service-layout.ts b/src/daemon/service-layout.ts index 0eebd610462f..219c8753e847 100644 --- a/src/daemon/service-layout.ts +++ b/src/daemon/service-layout.ts @@ -4,6 +4,7 @@ import { pathExists } from "../infra/fs-safe.js"; import { readPackageName, readPackageVersion } from "../infra/package-json.js"; import type { GatewayServiceCommandConfig } from "./service-types.js"; +/** Summary of the installed gateway service command and package layout. */ export type GatewayServiceLayoutSummary = { execStart: string; sourcePath?: string; diff --git a/src/daemon/service-managed-env.ts b/src/daemon/service-managed-env.ts index 4810bee3955d..73c35dffa01e 100644 --- a/src/daemon/service-managed-env.ts +++ b/src/daemon/service-managed-env.ts @@ -4,6 +4,7 @@ import type { GatewayServiceEnvironmentValueSource } from "./service-types.js"; const MANAGED_SERVICE_ENV_KEYS_VAR = "OPENCLAW_SERVICE_MANAGED_ENV_KEYS"; +// Tracks which service environment keys OpenClaw owns across reinstall/start flows. type ServiceEnvCommand = { environment?: Record; environmentValueSources?: Record; diff --git a/src/daemon/service-path-policy.ts b/src/daemon/service-path-policy.ts index 81428ed933ed..09cde93c472b 100644 --- a/src/daemon/service-path-policy.ts +++ b/src/daemon/service-path-policy.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Service PATH policy keeps managed services away from user shell package-manager paths. function getPathModule(platform: NodeJS.Platform) { return platform === "win32" ? path.win32 : path.posix; } diff --git a/src/daemon/service-runtime.ts b/src/daemon/service-runtime.ts index 5d70f9f651a9..dd95f9fbaf32 100644 --- a/src/daemon/service-runtime.ts +++ b/src/daemon/service-runtime.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** systemd cgroup fields used to spot unhealthy gateway service supervision. */ export type GatewayServiceSystemdRuntime = { unit?: string; killMode?: string; diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index 41a22a1a6907..6761c5fee1e1 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -1,7 +1,9 @@ import type { GatewayServiceRuntime } from "./service-runtime.js"; +/** Environment map passed to service renderers and platform supervisors. */ export type GatewayServiceEnv = Record; +/** Arguments required to render/install a managed gateway service. */ export type GatewayServiceInstallArgs = { env: GatewayServiceEnv; stdout: NodeJS.WritableStream; diff --git a/src/daemon/service.test-helpers.ts b/src/daemon/service.test-helpers.ts index 432f3f928d44..9267ee4cd8ec 100644 --- a/src/daemon/service.test-helpers.ts +++ b/src/daemon/service.test-helpers.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import type { GatewayService } from "./service.js"; +/** Creates a mock gateway service implementation for daemon service tests. */ export function createMockGatewayService(overrides: Partial = {}): GatewayService { return { label: "LaunchAgent", diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 8030a030ea6a..00a014594d62 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -62,6 +62,7 @@ export type { GatewayServiceState, } from "./service-types.js"; +// Platform service adapter used by CLI commands across launchd, systemd, and schtasks. function ignoreServiceWriteResult( write: (args: TArgs) => Promise, ): (args: TArgs) => Promise { diff --git a/src/daemon/systemd-hints.ts b/src/daemon/systemd-hints.ts index 086af6470722..f4d081a2de8b 100644 --- a/src/daemon/systemd-hints.ts +++ b/src/daemon/systemd-hints.ts @@ -10,6 +10,7 @@ type SystemdUnavailableHintOptions = { container?: boolean; }; +/** Detects details that should get systemd availability repair hints. */ export function isSystemdUnavailableDetail(detail?: string): boolean { return classifySystemdUnavailableDetail(detail) !== null; } diff --git a/src/daemon/systemd-unavailable.ts b/src/daemon/systemd-unavailable.ts index c8ac6d387d44..8ac9585f35db 100644 --- a/src/daemon/systemd-unavailable.ts +++ b/src/daemon/systemd-unavailable.ts @@ -5,6 +5,7 @@ export type SystemdUnavailableKind = | "user_bus_unavailable" | "generic_unavailable"; +// Normalizes platform command output before matching known systemd failure families. function normalizeDetail(detail?: string): string { return normalizeLowercaseStringOrEmpty(detail); } diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index e3f0f950482e..58ee5e81cedf 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -6,6 +6,7 @@ import { schtasksResponses, } from "./schtasks-fixtures.js"; +// Shared Windows schtasks mocks for daemon tests. vi.mock("../schtasks-exec.js", () => ({ execSchtasks: async (argv: string[]) => { schtasksCalls.push(argv); diff --git a/src/daemon/test-helpers/schtasks-fixtures.ts b/src/daemon/test-helpers/schtasks-fixtures.ts index 9755acefae72..f232743f7de9 100644 --- a/src/daemon/test-helpers/schtasks-fixtures.ts +++ b/src/daemon/test-helpers/schtasks-fixtures.ts @@ -13,6 +13,7 @@ export const schtasksCalls: string[][] = []; export const inspectPortUsage: MockFn<(port: number) => Promise> = vi.fn(); export const killProcessTree: MockFn = vi.fn(); +/** Runs a test with Windows-like daemon environment paths and cleans the temp dir. */ export async function withWindowsEnv( prefix: string, run: (params: { tmpDir: string; env: Record }) => Promise, diff --git a/src/flows/bundled-health-checks.ts b/src/flows/bundled-health-checks.ts index 669c30713fe2..282f55319ae5 100644 --- a/src/flows/bundled-health-checks.ts +++ b/src/flows/bundled-health-checks.ts @@ -5,10 +5,12 @@ import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy. import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; import { registerHealthCheck } from "./health-check-registry.js"; +// Bridges bundled plugin doctor checks into the core health registry. type BundledHealthApi = { registerPolicyDoctorChecks?: (host: { registerHealthCheck: typeof registerHealthCheck }) => void; }; +/** Registers bundled health checks that are explicitly enabled by config and owner policy. */ export function registerBundledHealthChecks(params: { cfg: OpenClawConfig; cwd?: string }): void { if (!shouldRegisterPolicyHealth(params)) { return; @@ -25,6 +27,7 @@ function shouldRegisterPolicyHealth(params: { cfg: OpenClawConfig; cwd?: string if (entry === undefined || entry.enabled === false || config.enabled === false) { return false; } + // Policy doctor checks are bundled, but still respect the same manifest owner gate as runtime. if ( !passesManifestOwnerBasePolicy({ plugin: { id: "policy" }, diff --git a/src/flows/channel-setup.prompts.ts b/src/flows/channel-setup.prompts.ts index d6042f09a767..7134b0da5536 100644 --- a/src/flows/channel-setup.prompts.ts +++ b/src/flows/channel-setup.prompts.ts @@ -14,12 +14,15 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; +// Prompt helpers for channel setup flows; keeps wizard copy and config mutation centralized. type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; +/** Formats account ids for channel setup prompts. */ export function formatAccountLabel(accountId: string): string { return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId; } +/** Asks what to do with an already-configured channel account. */ export async function promptConfiguredAction(params: { prompter: WizardPrompter; label: string; @@ -60,6 +63,7 @@ export async function promptConfiguredAction(params: { }); } +/** Selects the account to remove/update when a channel supports multiple accounts. */ export async function promptRemovalAccountId(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -88,6 +92,7 @@ export async function promptRemovalAccountId(params: { return normalizeAccountId(selected) ?? defaultAccountId; } +/** Optionally configures DM access policies for selected channel setup adapters. */ export async function maybeConfigureDmPolicies(params: { cfg: OpenClawConfig; selection: ChannelChoice[]; @@ -115,6 +120,7 @@ export async function maybeConfigureDmPolicies(params: { let cfg = params.cfg; for (const policy of dmPolicies) { const accountId = accountIdsByChannel?.get(policy.channel); + // Multi-account channels can remap the policy keys for the selected account. const { policyKey, allowFromKey } = policy.resolveConfigKeys?.(cfg, accountId) ?? { policyKey: policy.policyKey, allowFromKey: policy.allowFromKey, diff --git a/src/flows/channel-setup.test-helpers.ts b/src/flows/channel-setup.test-helpers.ts index 97a4f1aa9122..f9d4d8ad3859 100644 --- a/src/flows/channel-setup.test-helpers.ts +++ b/src/flows/channel-setup.test-helpers.ts @@ -3,8 +3,10 @@ type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").Channe type ResolveChannelSetupEntries = typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +// Small builders for channel setup tests; mirror discovery shapes without loading real plugins. type ChannelSetupEntries = ReturnType; +/** Builds channel metadata with the defaults most setup tests need. */ export function makeMeta( id: string, label: string, @@ -20,6 +22,7 @@ export function makeMeta( }; } +/** Builds a catalog entry for an installable or installed channel plugin. */ export function makeCatalogEntry( id: string, label: string, @@ -34,6 +37,7 @@ export function makeCatalogEntry( }; } +/** Builds the full discovery result shape used by channel setup flows. */ export function makeChannelSetupEntries( overrides: Partial = {}, ): ChannelSetupEntries { diff --git a/src/flows/doctor-error-message.ts b/src/flows/doctor-error-message.ts index 802eb75694d8..5f679e7519c1 100644 --- a/src/flows/doctor-error-message.ts +++ b/src/flows/doctor-error-message.ts @@ -1,5 +1,7 @@ +// Shared sanitization for doctor/lint/repair errors shown in terminal output. const ERR_MESSAGE_MAX_LEN = 256; +/** Removes control characters and caps error messages before doctor prints them. */ export function scrubDoctorErrorMessage(err: unknown): string { const raw = err instanceof Error ? err.message : String(err); let stripped = ""; diff --git a/src/flows/doctor-health-conversion-plan.ts b/src/flows/doctor-health-conversion-plan.ts index 63a2161309a1..f8981ca4297a 100644 --- a/src/flows/doctor-health-conversion-plan.ts +++ b/src/flows/doctor-health-conversion-plan.ts @@ -1,3 +1,4 @@ +// Tracks migration of legacy doctor contributions into structured health checks. export type DoctorHealthConversionKind = | "already-detect" | "detect-only" @@ -7,6 +8,7 @@ export type DoctorHealthConversionKind = | "terminal-side-effect" | "interactive-maintenance"; +/** Describes one legacy doctor contribution and the structured health target replacing it. */ export interface DoctorHealthConversionRule { readonly contributionId: string; readonly conversion: DoctorHealthConversionKind; @@ -14,6 +16,7 @@ export interface DoctorHealthConversionRule { readonly rule: string; } +/** Ordered conversion map used by tests and maintainers to keep doctor migration explicit. */ export const doctorHealthConversionRules = [ { contributionId: "doctor:gateway-config", diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index 52caa5c508dc..3cdea3917ae7 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -3,6 +3,7 @@ import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style. import type { DoctorOptions } from "../commands/doctor-prompter.js"; import type { RuntimeEnv } from "../runtime.js"; +// Interactive doctor entrypoint; lazy imports keep normal CLI startup light. const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message); const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message); @@ -14,6 +15,7 @@ function loadConfigModule(): Promise { return (configModulePromise ??= import("../config/config.js")); } +/** Runs the full interactive doctor flow against the provided or default runtime. */ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) { const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime; if (options.repair === true || options.yes === true || options.generateGatewayToken === true) { @@ -46,6 +48,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions return; } + // Keep side-effect-heavy legacy checks before structured contributions until fully migrated. const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js"); const { noteSourceInstallIssues } = await import("../commands/doctor-install.js"); const { noteStalePluginRuntimeSymlinks } = diff --git a/src/flows/doctor-lint-flow.ts b/src/flows/doctor-lint-flow.ts index d67a9f19986d..a316ed7db791 100644 --- a/src/flows/doctor-lint-flow.ts +++ b/src/flows/doctor-lint-flow.ts @@ -1,5 +1,5 @@ -import { listHealthChecks } from "./health-check-registry.js"; import { scrubDoctorErrorMessage } from "./doctor-error-message.js"; +import { listHealthChecks } from "./health-check-registry.js"; import { HEALTH_FINDING_SEVERITY_RANK, healthFindingMeetsSeverity, @@ -9,6 +9,7 @@ import { type HealthFindingSeverity, } from "./health-checks.js"; +// Non-mutating health-check runner used by `openclaw doctor --lint`. export interface DoctorLintRunOptions { readonly checks?: readonly HealthCheck[]; readonly skipIds?: ReadonlySet | readonly string[]; @@ -21,6 +22,7 @@ export interface DoctorLintRunResult { readonly checksSkipped: number; } +/** Runs selected health checks in lint mode and returns sorted findings. */ export async function runDoctorLintChecks( ctx: HealthCheckContext, opts: DoctorLintRunOptions = {}, @@ -75,6 +77,7 @@ export async function runDoctorLintChecks( }; } +// Stable ordering keeps CLI output and tests deterministic across registry order changes. function compareFindings(a: HealthFinding, b: HealthFinding): number { const sevDelta = HEALTH_FINDING_SEVERITY_RANK[b.severity] - HEALTH_FINDING_SEVERITY_RANK[a.severity]; @@ -88,6 +91,7 @@ function compareFindings(a: HealthFinding, b: HealthFinding): number { return (a.path ?? "").localeCompare(b.path ?? ""); } +/** Converts findings to a process exit code using the requested minimum severity. */ export function exitCodeFromFindings( findings: readonly HealthFinding[], severityMin: HealthFindingSeverity = "warning", diff --git a/src/flows/doctor-repair-flow.ts b/src/flows/doctor-repair-flow.ts index 3e379afc2985..8a5aadf82f54 100644 --- a/src/flows/doctor-repair-flow.ts +++ b/src/flows/doctor-repair-flow.ts @@ -13,6 +13,7 @@ import type { HealthRepairResult, } from "./health-checks.js"; +// Repair runner for structured doctor health checks; carries config between checks. export interface DoctorRepairRunOptions { readonly checks?: readonly HealthCheck[]; readonly dryRun?: boolean; @@ -32,6 +33,7 @@ export interface DoctorRepairRunResult { readonly checksValidated: number; } +/** Runs health checks in fix mode, applies repair outputs, and validates repaired scopes. */ export async function runDoctorHealthRepairs( ctx: HealthRepairContext, opts: DoctorRepairRunOptions = {}, @@ -115,6 +117,7 @@ async function runSplitHealthCheck( return repairRunResult(cfg, findings, remainingFindings, changes, warnings, diffs, effects); } + // Split checks expose detect/repair separately, so repair output must be validated by detect(). try { const result = await check.repair( { ...ctx, dryRun: opts.dryRun === true, diff: opts.diff === true }, @@ -196,6 +199,7 @@ async function runRunnableHealthCheck( effects.push(...(result.effects ?? [])); const status = result.status ?? "repaired"; const hasRepairOutput = hasHealthRepairOutput(result); + // Runnable checks may report "repairable" during dry-run without mutating config. if (status === "repairable") { changes.push(...(result.changes ?? [])); return repairRunResult(cfg, findings, remainingFindings, changes, warnings, diffs, effects, { @@ -248,6 +252,7 @@ async function runRunnableHealthCheck( }); } +// Only non-empty repair effects count as work; a clean detector with status repaired should not. function hasHealthRepairOutput(result: HealthRepairResult | HealthCheckRunResult): boolean { return ( result.config !== undefined || @@ -281,6 +286,7 @@ function repairRunResult( }; } +// Re-run only the failing paths/ocPaths after repair to avoid unrelated expensive checks. function createValidationScope(findings: readonly HealthFinding[]) { return { findings, diff --git a/src/flows/doctor-startup-channel-maintenance.ts b/src/flows/doctor-startup-channel-maintenance.ts index d9945d6e6f04..4f2df0bcea7c 100644 --- a/src/flows/doctor-startup-channel-maintenance.ts +++ b/src/flows/doctor-startup-channel-maintenance.ts @@ -1,6 +1,7 @@ import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Doctor wrapper for plugin startup maintenance repairs. type DoctorStartupMaintenanceRuntime = { error: (message: string) => void; log: (message: string) => void; @@ -8,6 +9,7 @@ type DoctorStartupMaintenanceRuntime = { type ChannelPluginStartupMaintenanceRunner = typeof runChannelPluginStartupMaintenance; +/** Runs channel plugin startup maintenance when doctor fix mode explicitly permits repairs. */ export async function maybeRunDoctorStartupChannelMaintenance(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -23,6 +25,7 @@ export async function maybeRunDoctorStartupChannelMaintenance(params: { await runStartupMaintenance({ cfg: params.cfg, env: params.env ?? process.env, + // Doctor maps startup warnings to terminal errors so repair output is visible. log: { info: (message) => params.runtime.log(message), warn: (message) => params.runtime.error(message), diff --git a/src/flows/doctor-tool-result-cap-advice.ts b/src/flows/doctor-tool-result-cap-advice.ts index c4a4c8718460..88982cc22597 100644 --- a/src/flows/doctor-tool-result-cap-advice.ts +++ b/src/flows/doctor-tool-result-cap-advice.ts @@ -3,6 +3,7 @@ import { resolveAutoLiveToolResultMaxChars, } from "../agents/embedded-agent-runner/tool-result-truncation.js"; +// Doctor advice for explicit live tool-result caps that fight model-window defaults. export type ToolResultCapDoctorAdviceParams = { contextWindowTokens: number; modelKey: string; @@ -15,6 +16,7 @@ function formatNumber(value: number): string { return String(Math.max(0, Math.floor(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } +/** Builds human-readable doctor lines for stale or ineffective toolResultMaxChars settings. */ export function buildToolResultCapDoctorAdvice(params: ToolResultCapDoctorAdviceParams): string[] { if (!Number.isFinite(params.contextWindowTokens) || params.contextWindowTokens <= 0) { return []; @@ -36,6 +38,7 @@ export function buildToolResultCapDoctorAdvice(params: ToolResultCapDoctorAdvice const lines: string[] = []; const prefix = params.scopeLabel ? `${params.scopeLabel}: ` : ""; + // Deep mode always shows the effective cap, even when no warning is needed. if (params.deep) { lines.push( `- ${prefix}primary model "${params.modelKey}" context window ${formatNumber( diff --git a/src/flows/health-check-adapter.ts b/src/flows/health-check-adapter.ts index 361bf6a152a8..51523f9700fa 100644 --- a/src/flows/health-check-adapter.ts +++ b/src/flows/health-check-adapter.ts @@ -5,6 +5,8 @@ import type { } from "./health-check-runner-types.js"; import type { HealthCheck, HealthRepairContext } from "./health-checks.js"; +// Adapts legacy split detect/repair checks and newer runnable checks to one runner contract. +/** Wraps a detect/repair health check in the runnable health-check contract. */ export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthCheck { return { id: check.id, @@ -19,6 +21,7 @@ export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthChec : (ctx, findings) => check.repair?.(ctx, findings) ?? Promise.resolve({ changes: [] }), async run(ctx, scope): Promise { const findings = await check.detect(ctx, scope); + // Preview repair returns proposed changes without persisting config updates. if ( findings.length === 0 || check.repair === undefined || @@ -49,6 +52,7 @@ export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthChec }; } +/** Normalizes any supported health-check shape before lint/fix execution. */ export function normalizeHealthCheck(check: HealthCheckInput): RegisteredHealthCheck { if ( "detect" in check && diff --git a/src/flows/health-check-registry.ts b/src/flows/health-check-registry.ts index 2dab8be4e4ec..6435152b89e7 100644 --- a/src/flows/health-check-registry.ts +++ b/src/flows/health-check-registry.ts @@ -1,7 +1,9 @@ import type { HealthCheck } from "./health-checks.js"; +// Process-local registry populated by core and plugin doctor checks. const REGISTRY = new Map(); +/** Raised when two checks claim the same stable health-check id. */ export class HealthCheckRegistrationError extends Error { readonly code = "OC_DOCTOR_DUPLICATE_CHECK"; constructor(readonly checkId: string) { @@ -10,6 +12,7 @@ export class HealthCheckRegistrationError extends Error { } } +/** Registers one health check for doctor lint/fix execution. */ export function registerHealthCheck(check: HealthCheck): void { if (REGISTRY.has(check.id)) { throw new HealthCheckRegistrationError(check.id); @@ -17,14 +20,17 @@ export function registerHealthCheck(check: HealthCheck): void { REGISTRY.set(check.id, check); } +/** Returns registered checks in insertion order for deterministic doctor output. */ export function listHealthChecks(): readonly HealthCheck[] { return [...REGISTRY.values()]; } +/** Looks up a registered health check by its stable id. */ export function getHealthCheck(id: string): HealthCheck | undefined { return REGISTRY.get(id); } +/** Clears the process-local registry for isolated tests. */ export function clearHealthChecksForTest(): void { REGISTRY.clear(); } diff --git a/src/flows/health-check-runner-types.ts b/src/flows/health-check-runner-types.ts index 9077128a693f..168c9b462b3a 100644 --- a/src/flows/health-check-runner-types.ts +++ b/src/flows/health-check-runner-types.ts @@ -8,12 +8,14 @@ import type { HealthRepairResult, } from "./health-checks.js"; +// Runnable health-check contracts used by doctor lint/fix orchestration. export interface HealthCheckRunContext extends HealthCheckContext { readonly repair: boolean; readonly diff?: boolean; readonly previewRepair?: boolean; } +/** Result shape for checks that combine detect, preview, and repair in one run() method. */ export interface HealthCheckRunResult extends Omit { readonly findings?: readonly HealthFinding[]; readonly status?: "repairable" | "repaired" | "skipped" | "failed"; @@ -22,6 +24,7 @@ export interface HealthCheckRunResult extends Omit; diff --git a/src/flows/health-checks.ts b/src/flows/health-checks.ts index b76fb60c7010..62f6d88bf72f 100644 --- a/src/flows/health-checks.ts +++ b/src/flows/health-checks.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; +// Public doctor health contracts shared by core checks, plugin checks, lint, and repair. export type HealthFindingSeverity = "info" | "warning" | "error"; export const HEALTH_FINDING_SEVERITY_RANK: Record = { @@ -9,6 +10,7 @@ export const HEALTH_FINDING_SEVERITY_RANK: Record error: 2, }; +/** Parses CLI/config severity input into the closed health-finding severity set. */ export function parseHealthFindingSeverity( input: string | undefined, ): HealthFindingSeverity | null { @@ -18,6 +20,7 @@ export function parseHealthFindingSeverity( return null; } +/** Returns whether a finding meets the configured reporting threshold. */ export function healthFindingMeetsSeverity( finding: Pick, severityMin: HealthFindingSeverity, @@ -27,6 +30,7 @@ export function healthFindingMeetsSeverity( ); } +/** Structured finding emitted by doctor health checks. */ export interface HealthFinding { readonly checkId: string; readonly severity: HealthFindingSeverity; @@ -43,6 +47,7 @@ export interface HealthFinding { export type HealthCheckMode = "doctor" | "lint" | "fix"; +/** Immutable runtime/config context passed to health check detection. */ export interface HealthCheckContext { readonly mode: HealthCheckMode; readonly runtime: RuntimeEnv; @@ -52,12 +57,14 @@ export interface HealthCheckContext { readonly allowExecSecretRefs?: boolean; } +/** Repair-capable health-check context; fixes may emit diffs or dry-run previews. */ export interface HealthRepairContext extends Omit { readonly mode: "fix"; readonly dryRun?: boolean; readonly diff?: boolean; } +/** Optional before/after detail for config or file repair output. */ export interface HealthRepairDiff { readonly kind: "config" | "file"; readonly path: string; @@ -66,6 +73,7 @@ export interface HealthRepairDiff { readonly unifiedDiff?: string; } +/** Side effect descriptor for repairs that touch services, processes, packages, or state. */ export interface HealthRepairEffect { readonly kind: "config" | "file" | "service" | "process" | "package" | "state" | "other"; readonly action: string; @@ -73,6 +81,7 @@ export interface HealthRepairEffect { readonly dryRunSafe?: boolean; } +/** Repair result returned by split health-check repair functions. */ export interface HealthRepairResult { readonly status?: "repaired" | "skipped" | "failed"; readonly reason?: string; @@ -83,12 +92,14 @@ export interface HealthRepairResult { readonly effects?: readonly HealthRepairEffect[]; } +/** Narrow validation scope built from previous findings after a repair runs. */ export interface HealthCheckScope { readonly findings?: readonly HealthFinding[]; readonly paths?: readonly string[]; readonly ocPaths?: readonly string[]; } +/** Split detect/repair health-check contract registered by core or plugins. */ export interface HealthCheck { readonly id: string; readonly kind: "core" | "plugin"; diff --git a/src/flows/model-picker.provider-catalog.ts b/src/flows/model-picker.provider-catalog.ts index bfaad0d0bd2a..dbbe6736ab8b 100644 --- a/src/flows/model-picker.provider-catalog.ts +++ b/src/flows/model-picker.provider-catalog.ts @@ -20,6 +20,7 @@ import { } from "../plugins/provider-discovery.js"; import type { ProviderPlugin } from "../plugins/types.js"; +// Loads live provider model catalogs for the preferred-provider model picker. const log = createSubsystemLogger("model-picker-provider-catalog"); const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; @@ -73,6 +74,7 @@ async function resolvePreferredProviderLiveCatalogProviders(params: { return liveProviders; } + // Fallback activates setup-mode providers only when discovery returned no live catalog runner. const { resolvePluginProviders } = await import("../plugins/providers.runtime.js"); return resolvePluginProviders({ config: params.cfg, @@ -112,6 +114,7 @@ function resolveProviderEnvApiKey( return undefined; } +// Converts provider plugin catalog rows into the model picker shape without losing window metadata. function modelFromProviderCatalog(params: { provider: string; providerConfig: ModelProviderConfig; @@ -136,6 +139,7 @@ function modelFromProviderCatalog(params: { }; } +/** Loads live catalog models for the user's preferred provider, ordered by discovery priority. */ export async function loadPreferredProviderPickerCatalog(params: { cfg: OpenClawConfig; preferredProvider: string; @@ -179,6 +183,7 @@ export async function loadPreferredProviderPickerCatalog(params: { const resolveProviderAuth = createProviderAuthResolver(env, getAuthStore, params.cfg); const resolveFastProviderApiKey = (provider: ProviderPlugin, providerId = provider.id) => { const normalizedProviderId = normalizeProviderId(providerId); + // Prefer direct env keys for the current provider before touching the auth profile store. if (providerAuthIds(provider).includes(normalizedProviderId)) { const fromEnv = resolveProviderEnvApiKey(provider, env); if (fromEnv) { @@ -191,6 +196,7 @@ export async function loadPreferredProviderPickerCatalog(params: { const rows: ModelCatalogEntry[] = []; const seen = new Set(); + // Discovery order is a contract: simple/profile results win over paired/late duplicates. for (const order of DISCOVERY_ORDERS) { for (const provider of byOrder[order]) { let result: Awaited>; diff --git a/src/flows/provider-flow.runtime.ts b/src/flows/provider-flow.runtime.ts index e24e533aa74f..9d0f8c8b645d 100644 --- a/src/flows/provider-flow.runtime.ts +++ b/src/flows/provider-flow.runtime.ts @@ -7,6 +7,7 @@ import type { ProviderPlugin } from "../plugins/types.js"; import type { FlowContribution } from "./types.js"; import { sortFlowContributionsByLabel } from "./types.js"; +// Runtime-backed provider entries for model-picker setup flows. type ProviderModelPickerFlowEntry = ProviderModelPickerEntry; type ProviderModelPickerFlowContribution = FlowContribution & { @@ -37,6 +38,7 @@ function resolveProviderDocsById(params?: { ); } +/** Resolves provider model-picker options without exposing contribution metadata. */ export function resolveProviderModelPickerFlowEntries(params?: { config?: OpenClawConfig; workspaceDir?: string; @@ -47,6 +49,7 @@ export function resolveProviderModelPickerFlowEntries(params?: { ); } +/** Resolves provider model-picker contributions with docs metadata for setup UIs. */ export function resolveProviderModelPickerFlowContributions(params?: { config?: OpenClawConfig; workspaceDir?: string; @@ -58,6 +61,7 @@ export function resolveProviderModelPickerFlowContributions(params?: { const providerId = entry.value.startsWith("provider-plugin:") ? entry.value.slice("provider-plugin:".length).split(":")[0] : entry.value; + // Provider-plugin values encode plugin/provider in the option value; docs attach by provider id. return { id: `provider:model-picker:${entry.value}`, kind: "provider" as const, diff --git a/src/flows/provider-flow.ts b/src/flows/provider-flow.ts index b1e3247b0304..ec99cad42fe9 100644 --- a/src/flows/provider-flow.ts +++ b/src/flows/provider-flow.ts @@ -5,6 +5,7 @@ import * as providerInstallCatalog from "../plugins/provider-install-catalog.js" import type { FlowContribution, FlowOption } from "./types.js"; import { sortFlowContributionsByLabel } from "./types.js"; +// Provider setup contributions from manifests and install catalogs. type ProviderFlowScope = "text-inference" | "image-generation" | "music-generation"; const DEFAULT_PROVIDER_FLOW_SCOPE: ProviderFlowScope = "text-inference"; @@ -28,6 +29,7 @@ function includesProviderFlowScope( scopes: readonly ProviderFlowScope[] | undefined, scope: ProviderFlowScope, ): boolean { + // Missing scope means the historic text-inference onboarding surface only. return scopes ? scopes.includes(scope) : scope === DEFAULT_PROVIDER_FLOW_SCOPE; } diff --git a/src/flows/types.ts b/src/flows/types.ts index b41636f1d06a..cc2f4c4878f5 100644 --- a/src/flows/types.ts +++ b/src/flows/types.ts @@ -1,3 +1,4 @@ +// Shared option/contribution contracts for setup, onboarding, and doctor flow UIs. type FlowDocsLink = { path: string; label?: string; @@ -23,6 +24,7 @@ export type FlowOption = { assistantVisibility?: "visible" | "manual-only"; }; +/** Generic contribution envelope used by plugin/core setup surfaces. */ export type FlowContribution = { id: string; kind: FlowContributionKind; @@ -31,6 +33,7 @@ export type FlowContribution = { source?: string; }; +/** Sorts UI flow contributions deterministically by visible label, then value. */ export function sortFlowContributionsByLabel( contributions: readonly T[], ): T[] { diff --git a/src/gateway/agent-command.test-helpers.ts b/src/gateway/agent-command.test-helpers.ts index 295ba8e9607d..b8878bc6cbf5 100644 --- a/src/gateway/agent-command.test-helpers.ts +++ b/src/gateway/agent-command.test-helpers.ts @@ -1,6 +1,9 @@ import { vi } from "vitest"; import { agentCommand } from "./test-helpers.runtime-state.js"; +/** + * Async wait helpers for gateway tests that assert agent-command invocations. + */ type AgentCommandCall = Record; function agentCommandCalls(): Array<[AgentCommandCall]> { @@ -12,6 +15,7 @@ const sleep = (ms: number) => setTimeout(resolve, ms); }); +/** Waits until the mocked `agentCommand` receives a call for a specific run id. */ export async function waitForAgentCommandCall(runId: string): Promise { for (let elapsed = 0; elapsed <= 2_000; elapsed += 5) { const call = agentCommandCalls() @@ -25,6 +29,7 @@ export async function waitForAgentCommandCall(runId: string): Promise { diff --git a/src/gateway/agent-event-assistant-text.ts b/src/gateway/agent-event-assistant-text.ts index 6341664d7d24..9e8414016a12 100644 --- a/src/gateway/agent-event-assistant-text.ts +++ b/src/gateway/agent-event-assistant-text.ts @@ -1,5 +1,9 @@ import type { AgentEventPayload } from "../infra/agent-events.js"; +// Agent stream events may carry assistant text as either incremental delta or +// full text, depending on provider/runtime. Gateway display paths normalize the +// two shapes here before broadcasting. +/** Extracts the assistant-visible text delta from an agent event payload. */ export function resolveAssistantStreamDeltaText(evt: AgentEventPayload): string { const delta = evt.data.delta; const text = evt.data.text; diff --git a/src/gateway/agent-list.test.ts b/src/gateway/agent-list.test.ts index 600df90e9a1a..0cbc987f3d0c 100644 --- a/src/gateway/agent-list.test.ts +++ b/src/gateway/agent-list.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway agent-list RPC regression tests. + */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { listGatewayAgentsBasic } from "./agent-list.js"; diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts index 16438bddddb5..fd4cb08dcef4 100644 --- a/src/gateway/agent-list.ts +++ b/src/gateway/agent-list.ts @@ -7,6 +7,9 @@ import type { SessionScope } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js"; +// Basic agent list data is used by lightweight gateway/control surfaces that do +// not need full session rows. Configured agents stay authoritative, while disk +// state contributes existing agent ids when config does not explicitly narrow. type GatewayAgentListRow = { id: string; name?: string; @@ -26,6 +29,8 @@ function listExistingAgentIdsFromDisk(): string[] { } } +// Keep the default agent first for UI selection while preserving deterministic +// ordering for the remaining configured/on-disk ids. function listConfiguredAgentIds(cfg: OpenClawConfig): string[] { const ids = new Set(); const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); @@ -48,6 +53,7 @@ function listConfiguredAgentIds(cfg: OpenClawConfig): string[] { : sorted; } +/** Lists gateway-visible agent ids with default/main session metadata. */ export function listGatewayAgentsBasic(cfg: OpenClawConfig): { defaultId: string; mainKey: string; diff --git a/src/gateway/agent-prompt.test.ts b/src/gateway/agent-prompt.test.ts index 4fb2a9d68ac4..77a12b909d5b 100644 --- a/src/gateway/agent-prompt.test.ts +++ b/src/gateway/agent-prompt.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway agent prompt RPC regression tests. + */ import { describe, expect, it } from "vitest"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js"; diff --git a/src/gateway/android-node.capabilities.policy-config.test.ts b/src/gateway/android-node.capabilities.policy-config.test.ts index f3ecc23c8370..108a1e1fa328 100644 --- a/src/gateway/android-node.capabilities.policy-config.test.ts +++ b/src/gateway/android-node.capabilities.policy-config.test.ts @@ -1,3 +1,6 @@ +/** + * Android node capability policy-config regression tests. + */ import { describe, expect, it } from "vitest"; import { unwrapRemoteConfigSnapshot } from "../../test/helpers/gateway/android-node-capabilities-policy-config.js"; diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 98614db287b8..b77ee56ccafb 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -1,3 +1,6 @@ +/** + * Assistant identity resolution tests for gateway-visible agents. + */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; diff --git a/src/gateway/auth-mode-policy.ts b/src/gateway/auth-mode-policy.ts index 4fc0fd104709..6c0345c47174 100644 --- a/src/gateway/auth-mode-policy.ts +++ b/src/gateway/auth-mode-policy.ts @@ -1,9 +1,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; +// Gateway auth mode validation keeps ambiguous token/password configs out of +// runtime credential resolution. The resolver can preserve precedence only +// after config names the intended mode. export const EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR = "Invalid config: gateway.auth.token and gateway.auth.password are both configured, but gateway.auth.mode is unset. Set gateway.auth.mode to token or password."; +/** Returns true when local gateway auth config needs an explicit token/password mode. */ export function hasAmbiguousGatewayAuthModeConfig(cfg: OpenClawConfig): boolean { const auth = cfg.gateway?.auth; if (!auth) { @@ -18,6 +22,7 @@ export function hasAmbiguousGatewayAuthModeConfig(cfg: OpenClawConfig): boolean return tokenConfigured && passwordConfigured; } +/** Throws the public config error used by setup, doctor, and gateway startup validation. */ export function assertExplicitGatewayAuthModeWhenBothConfigured(cfg: OpenClawConfig): void { if (!hasAmbiguousGatewayAuthModeConfig(cfg)) { return; diff --git a/src/gateway/auth-surface-resolution.ts b/src/gateway/auth-surface-resolution.ts index 058f3122d53e..c124461f52c9 100644 --- a/src/gateway/auth-surface-resolution.ts +++ b/src/gateway/auth-surface-resolution.ts @@ -3,6 +3,9 @@ import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined, type ExplicitGatewayAuth } from "./credentials.js"; import { resolveConfiguredSecretInputString } from "./resolve-configured-secret-input-string.js"; +// Gateway auth is resolved differently for passive probes and interactive +// clients. This module owns the shared precedence so CLI, UI, and remote +// surfaces do not silently choose different token/password sources. type GatewayCredentialPath = | "gateway.auth.token" | "gateway.auth.password" @@ -43,6 +46,7 @@ function withDiagnostics(params: { : params.result; } +/** Resolves best-effort credentials for non-mutating local/remote gateway probes. */ export async function resolveGatewayProbeSurfaceAuth(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -53,6 +57,8 @@ export async function resolveGatewayProbeSurfaceAuth(params: { const authMode = params.config.gateway?.auth?.mode; if (params.surface === "remote") { + // Remote probes prefer remote.token and only read remote.password when no + // token was available. This matches managed gateway auth precedence. const remoteToken = await resolveGatewayCredential({ config: params.config, env, @@ -128,6 +134,8 @@ export async function resolveGatewayProbeSurfaceAuth(params: { if (envPassword) { return withDiagnostics({ diagnostics, result: { password: envPassword } }); } + // In implicit local mode, config password is the final fallback after token + // sources and env auth have been exhausted. const password = await resolveGatewayCredential({ config: params.config, env, @@ -141,6 +149,7 @@ export async function resolveGatewayProbeSurfaceAuth(params: { }); } +/** Resolves credentials for client paths that must either authenticate or explain the failure. */ export async function resolveGatewayInteractiveSurfaceAuth(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -164,6 +173,8 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: { : trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); if (params.surface === "remote") { + // Interactive remote clients allow explicit/env password fallback because + // users may connect to a gateway they do not own locally. const remoteToken = explicitToken ? { value: explicitToken } : await resolveGatewayCredential({ diff --git a/src/gateway/auth-token-resolution.ts b/src/gateway/auth-token-resolution.ts index 48660779c763..a475e97cef2d 100644 --- a/src/gateway/auth-token-resolution.ts +++ b/src/gateway/auth-token-resolution.ts @@ -6,9 +6,12 @@ import { type SecretInputUnresolvedReasonStyle, } from "./resolve-configured-secret-input-string.js"; +// Single-token resolver for local gateway auth consumers that need to know +// whether the winning token came from explicit args, config, SecretRef, or env. type GatewayAuthTokenResolutionSource = "explicit" | "config" | "secretRef" | "env"; type GatewayAuthTokenEnvFallback = "never" | "no-secret-ref" | "always"; +/** Resolves gateway.auth.token with configurable env fallback and SecretRef diagnostics. */ export async function resolveGatewayAuthToken(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -71,6 +74,8 @@ export async function resolveGatewayAuthToken(params: { secretRefConfigured: true, }; } + // Env fallback after a configured SecretRef is intentionally opt-in so + // callers can fail closed when unresolved secrets should block startup. if (envFallback === "always" && envToken) { return { token: envToken, diff --git a/src/gateway/auth-token-source-conflict.ts b/src/gateway/auth-token-source-conflict.ts index 5bc5f66856dd..b9ef71fd1e27 100644 --- a/src/gateway/auth-token-source-conflict.ts +++ b/src/gateway/auth-token-source-conflict.ts @@ -5,6 +5,8 @@ import { normalizeSecretInputString, resolveSecretInputRef } from "../config/typ const GATEWAY_ENV_TOKEN = "OPENCLAW_GATEWAY_TOKEN"; const GATEWAY_SERVICE_KIND = "gateway"; +// Doctor/startup warning shape for shells where OPENCLAW_GATEWAY_TOKEN would +// make direct clients use a different token than the managed gateway service. export type GatewayAuthTokenSourceConflict = { checkId: "gateway.env_token_overrides_config"; title: string; @@ -14,6 +16,7 @@ export type GatewayAuthTokenSourceConflict = { diagnostic: string; }; +/** Returns a warning when env token precedence can diverge from configured gateway auth. */ export function resolveGatewayAuthTokenSourceConflict(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; diff --git a/src/gateway/call.runtime.ts b/src/gateway/call.runtime.ts index 15f37217e8ef..ac55382d3fd5 100644 --- a/src/gateway/call.runtime.ts +++ b/src/gateway/call.runtime.ts @@ -1 +1,3 @@ +// Runtime barrel for gateway call clients. Keeping this separate lets tests and +// lazy boundaries import the runtime call implementation without extra exports. export { callGateway } from "./call.js"; diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index e1c229d70b16..c42d8d36aa0f 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -1,3 +1,6 @@ +/** + * Channel health monitor regression tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index 74846d8496ed..905766e32361 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -1,3 +1,6 @@ +/** + * Channel health policy regression tests. + */ import { describe, expect, it } from "vitest"; import { evaluateChannelHealth, resolveChannelRestartReason } from "./channel-health-policy.js"; diff --git a/src/gateway/channel-status-patches.test.ts b/src/gateway/channel-status-patches.test.ts index 15239fcfd2dc..6107d63519ac 100644 --- a/src/gateway/channel-status-patches.test.ts +++ b/src/gateway/channel-status-patches.test.ts @@ -1,3 +1,6 @@ +/** + * Channel status patching tests. + */ import { describe, expect, it } from "vitest"; import { createConnectedChannelStatusPatch, diff --git a/src/gateway/channel-status-patches.ts b/src/gateway/channel-status-patches.ts index 85915c527a45..00738ef409e9 100644 --- a/src/gateway/channel-status-patches.ts +++ b/src/gateway/channel-status-patches.ts @@ -1,13 +1,18 @@ +// Channel status patch factories centralize timestamp fields that multiple +// runtime paths send into the gateway status store. +/** Patch emitted when a channel connection is established. */ export type ConnectedChannelStatusPatch = { connected: true; lastConnectedAt: number; lastEventAt: number; }; +/** Patch emitted when a channel transport reports activity without reconnecting. */ export type TransportActivityChannelStatusPatch = { lastTransportActivityAt: number; }; +/** Creates a connected-channel status patch with matching connection/event timestamps. */ export function createConnectedChannelStatusPatch( at: number = Date.now(), ): ConnectedChannelStatusPatch { @@ -18,6 +23,7 @@ export function createConnectedChannelStatusPatch( }; } +/** Creates a transport-activity patch for health/activity monitors. */ export function createTransportActivityStatusPatch( at: number = Date.now(), ): TransportActivityChannelStatusPatch { diff --git a/src/gateway/chat-sanitize.test.ts b/src/gateway/chat-sanitize.test.ts index 52db2ad59106..ebba82afbab4 100644 --- a/src/gateway/chat-sanitize.test.ts +++ b/src/gateway/chat-sanitize.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway chat sanitization helpers for model-visible payloads. + */ import { describe, expect, test } from "vitest"; import { stripEnvelopeFromMessage } from "./chat-sanitize.js"; diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts index d568251e7d45..e9f793394bfb 100644 --- a/src/gateway/chat-sanitize.ts +++ b/src/gateway/chat-sanitize.ts @@ -6,6 +6,9 @@ import { import { extractInboundSenderLabel } from "../auto-reply/reply/strip-inbound-meta.js"; import { stripEnvelope } from "../shared/chat-envelope.js"; +// Gateway chat history display strips internal/user envelopes while preserving +// sender labels for UI rows. The helpers return original object identities when +// nothing changes so callers can avoid unnecessary snapshot churn. export { stripEnvelope }; function extractMessageSenderLabel(entry: Record): string | null { @@ -36,6 +39,8 @@ function extractMessageSenderLabel(entry: Record): string | nul return null; } +// Text content blocks need role-aware stripping because user messages carry +// inbound envelopes while assistant/tool content may carry internal metadata. function stripEnvelopeFromContentWithRole( content: unknown[], stripUserEnvelope: boolean, @@ -64,6 +69,7 @@ function stripEnvelopeFromContentWithRole( return { content: next, changed }; } +/** Strips OpenClaw envelope metadata from one display message without mutating it. */ export function stripEnvelopeFromMessage(message: unknown): unknown { if (!message || typeof message !== "object") { return message; @@ -107,6 +113,7 @@ export function stripEnvelopeFromMessage(message: unknown): unknown { return changed ? next : message; } +/** Strips envelope metadata from a message array, preserving the original array when unchanged. */ export function stripEnvelopeFromMessages(messages: unknown[]): unknown[] { if (messages.length === 0) { return messages; diff --git a/src/gateway/cli-session-history.merge.ts b/src/gateway/cli-session-history.merge.ts index eeb0d4140d74..839cb5aacd89 100644 --- a/src/gateway/cli-session-history.merge.ts +++ b/src/gateway/cli-session-history.merge.ts @@ -7,6 +7,8 @@ import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js" const DEDUPE_TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; +// Imported CLI history can overlap local transcript writes. Dedupe prefers +// stable imported external ids, then falls back to role/text/timestamp matching. function extractComparableText(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; @@ -57,6 +59,8 @@ function resolveComparableRole(message: unknown): string | undefined { return readStringValue((message as { role?: unknown }).role); } +// External identity survives text edits, so it is the strongest match signal +// for imported messages from Claude CLI or similar external histories. type ImportedExternalIdentity = { externalId: string; importedFrom?: string; @@ -134,6 +138,7 @@ function compareHistoryMessages( return a.order - b.order; } +/** Merges imported CLI transcript messages into local history without duplicating overlaps. */ export function mergeImportedChatHistoryMessages(params: { localMessages: unknown[]; importedMessages: unknown[]; diff --git a/src/gateway/cli-session-history.ts b/src/gateway/cli-session-history.ts index cec3cb64fa13..5ff11b609d9a 100644 --- a/src/gateway/cli-session-history.ts +++ b/src/gateway/cli-session-history.ts @@ -12,6 +12,9 @@ import { mergeImportedChatHistoryMessages } from "./cli-session-history.merge.js const ANTHROPIC_PROVIDER = "anthropic"; +// CLI session history import keeps Claude/Anthropic-bound sessions in sync with +// external CLI transcripts while leaving other provider histories untouched +// once local messages already exist. export { mergeImportedChatHistoryMessages, readClaudeCliFallbackSeed, @@ -21,6 +24,7 @@ export { }; export type { ClaudeCliFallbackSeed }; +/** Augments local chat history with bound Claude CLI session messages when applicable. */ export function augmentChatHistoryWithCliSessionImports(params: { entry: SessionEntry | undefined; provider?: string; diff --git a/src/gateway/client-bootstrap.ts b/src/gateway/client-bootstrap.ts index 768efbf1fa9b..de66d8caf487 100644 --- a/src/gateway/client-bootstrap.ts +++ b/src/gateway/client-bootstrap.ts @@ -3,6 +3,9 @@ import { resolveGatewayConnectionAuth } from "./connection-auth.js"; import { buildGatewayConnectionDetailsWithResolvers } from "./connection-details.js"; import type { ExplicitGatewayAuth } from "./credentials.js"; +/** + * Maps connection-detail source labels to the override kinds that affect auth fallback. + */ export function resolveGatewayUrlOverrideSource(urlSource: string): "cli" | "env" | undefined { if (urlSource === "cli --url") { return "cli"; @@ -13,6 +16,9 @@ export function resolveGatewayUrlOverrideSource(urlSource: string): "cli" | "env return undefined; } +/** + * Resolves the URL, auth material, and handshake tuning needed to start a GatewayClient. + */ export async function resolveGatewayClientBootstrap(params: { config: OpenClawConfig; gatewayUrl?: string; @@ -32,6 +38,8 @@ export async function resolveGatewayClientBootstrap(params: { url: params.gatewayUrl, }); const urlOverrideSource = resolveGatewayUrlOverrideSource(connection.urlSource); + // Only direct CLI/env URL overrides should constrain token/password fallback. Config-derived + // remote URLs are canonical config, not a caller override. const auth = await resolveGatewayConnectionAuth({ config: params.config, explicitAuth: params.explicitAuth, diff --git a/src/gateway/client-callsites.guard.test.ts b/src/gateway/client-callsites.guard.test.ts index 1d38a444473d..810da203dd67 100644 --- a/src/gateway/client-callsites.guard.test.ts +++ b/src/gateway/client-callsites.guard.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway client callsite guard tests. + */ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/src/gateway/client-start-readiness.test.ts b/src/gateway/client-start-readiness.test.ts index a5537417511c..28aa7287a23c 100644 --- a/src/gateway/client-start-readiness.test.ts +++ b/src/gateway/client-start-readiness.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway client startup readiness tests. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js"; import type { GatewayClient } from "./client.js"; diff --git a/src/gateway/client-start-readiness.ts b/src/gateway/client-start-readiness.ts index 8f90fb5bd29b..5ef87d1ebb69 100644 --- a/src/gateway/client-start-readiness.ts +++ b/src/gateway/client-start-readiness.ts @@ -5,11 +5,14 @@ import type { import { startGatewayClientWithReadinessWait } from "../../packages/gateway-client/src/readiness.js"; import { waitForEventLoopReady, type EventLoopReadyResult } from "./event-loop-ready.js"; +// Server-side gateway clients wait for the event loop readiness probe before +// starting so connect attempts do not race immediately after process startup. export type { GatewayClientStartable, GatewayClientStartReadinessOptions, } from "../../packages/gateway-client/src/readiness.js"; +/** Starts a gateway client once the shared event-loop readiness check passes. */ export function startGatewayClientWhenEventLoopReady( client: GatewayClientStartable, options: GatewayClientStartReadinessOptions = {}, diff --git a/src/gateway/config-reload-settings.ts b/src/gateway/config-reload-settings.ts index 955976c357a1..481972f2b89c 100644 --- a/src/gateway/config-reload-settings.ts +++ b/src/gateway/config-reload-settings.ts @@ -1,6 +1,8 @@ import type { GatewayReloadMode } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Gateway reload settings are normalized at startup so file watchers and config +// reload handlers share one debounce/mode contract. export type GatewayReloadSettings = { mode: GatewayReloadMode; debounceMs: number; @@ -11,6 +13,7 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { debounceMs: 300, }; +/** Resolves gateway reload mode/debounce from config with bounded defaults. */ export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReloadSettings { const rawMode = cfg.gateway?.reload?.mode; const mode = diff --git a/src/gateway/connection-auth.ts b/src/gateway/connection-auth.ts index d2ba6897abd1..2c8c0220018b 100644 --- a/src/gateway/connection-auth.ts +++ b/src/gateway/connection-auth.ts @@ -2,8 +2,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGatewayCredentialsWithSecretInputs } from "./credentials-secret-inputs.js"; import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; +// Thin public bridge from OpenClawConfig-shaped callers to the lower-level +// credential resolver. Keep this file policy-free; precedence lives in +// credentials-secret-inputs and credentials. type GatewayCredentialConfigOptions = Parameters[0]; +/** Connection auth options accepted by gateway clients that already loaded config. */ export type GatewayConnectionAuthOptions = Omit & { config: OpenClawConfig; }; @@ -18,6 +22,7 @@ function toGatewayCredentialOptions( }; } +/** Resolves gateway connection credentials, including configured SecretRef inputs. */ export async function resolveGatewayConnectionAuth( params: GatewayConnectionAuthOptions, ): Promise<{ token?: string; password?: string }> { @@ -27,6 +32,7 @@ export async function resolveGatewayConnectionAuth( }); } +/** Resolves already-available config credentials without async SecretRef loading. */ export function resolveGatewayConnectionAuthFromConfig(params: GatewayCredentialConfigOptions): { token?: string; password?: string; diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index d7fd3504428d..c4a0d2ec177b 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -1,7 +1,12 @@ +// Control UI bootstrap contract served by the gateway and consumed by the +// browser app before it knows runtime branding, media roots, or embed policy. +/** HTTP path for the Control UI bootstrap config payload. */ export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json"; +/** Sandbox policy for assistant-provided embed surfaces inside Control UI. */ export type ControlUiEmbedSandboxMode = "strict" | "scripts" | "trusted"; +/** Runtime config consumed by the browser Control UI during bootstrap. */ export type ControlUiBootstrapConfig = { basePath: string; assistantName: string; diff --git a/src/gateway/control-ui-http-utils.ts b/src/gateway/control-ui-http-utils.ts index b670d413dec0..51c84017b3b8 100644 --- a/src/gateway/control-ui-http-utils.ts +++ b/src/gateway/control-ui-http-utils.ts @@ -1,15 +1,20 @@ import type { ServerResponse } from "node:http"; +// Small HTTP response helpers used by Control UI routes before they enter the +// larger gateway JSON/auth stack. +/** Returns true for idempotent HTTP methods that can read Control UI assets. */ export function isReadHttpMethod(method: string | undefined): boolean { return method === "GET" || method === "HEAD"; } +/** Sends a plain-text response with the standard UTF-8 content type. */ export function respondPlainText(res: ServerResponse, statusCode: number, body: string): void { res.statusCode = statusCode; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(body); } +/** Sends the shared plain-text 404 response for Control UI routes. */ export function respondNotFound(res: ServerResponse): void { respondPlainText(res, 404, "Not Found"); } diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index 566985e89ab9..fc309d0fa745 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -1,3 +1,6 @@ +/** + * Control UI gateway routing tests. + */ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest } from "./control-ui-routing.js"; diff --git a/src/gateway/control-ui-shared.ts b/src/gateway/control-ui-shared.ts index 7ba4c61a0ba4..1093c1e6b2c2 100644 --- a/src/gateway/control-ui-shared.ts +++ b/src/gateway/control-ui-shared.ts @@ -6,6 +6,9 @@ import { const CONTROL_UI_AVATAR_PREFIX = "/avatar"; +// Control UI URL helpers keep browser bootstrap paths stable across root and +// subpath deployments, including avatar paths proxied through the gateway. +/** Normalizes a Control UI base path to either "" or a leading-slash path without trailing slash. */ export function normalizeControlUiBasePath(basePath?: string): string { if (!basePath) { return ""; @@ -26,12 +29,14 @@ export function normalizeControlUiBasePath(basePath?: string): string { return normalized; } +/** Builds the gateway-served avatar URL for an agent under the provided base path. */ export function buildControlUiAvatarUrl(basePath: string, agentId: string): string { return basePath ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/${agentId}` : `${CONTROL_UI_AVATAR_PREFIX}/${agentId}`; } +/** Resolves the assistant avatar URL that Control UI should render for the active agent. */ export function resolveAssistantAvatarUrl(params: { avatar?: string | null; agentId?: string | null; @@ -59,10 +64,13 @@ export function resolveAssistantAvatarUrl(params: { if (!params.agentId) { return avatar; } + // Local filesystem-ish avatar config is exposed through the gateway avatar + // route instead of being handed directly to the browser. if (looksLikeAvatarPath(avatar)) { return buildControlUiAvatarUrl(basePath, params.agentId); } return avatar; } +/** URL prefix for gateway-served Control UI avatar assets. */ export { CONTROL_UI_AVATAR_PREFIX }; diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts index c7ae7146b2e7..be6bbd7e096d 100644 --- a/src/gateway/control-ui.auto-root.http.test.ts +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -1,3 +1,6 @@ +/** + * Control UI auto-root HTTP routing tests. + */ import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import os from "node:os"; diff --git a/src/gateway/device-auth.test.ts b/src/gateway/device-auth.test.ts index 8db88428ce99..9f83e4b6f62f 100644 --- a/src/gateway/device-auth.test.ts +++ b/src/gateway/device-auth.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway device-auth regression tests. + */ import { describe, expect, it } from "vitest"; import { buildDeviceAuthPayload, diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index fc84fdc5bda9..97ce01e8c36c 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -1,3 +1,5 @@ +// Re-export the gateway-client device auth helpers from the server package so +// gateway HTTP code and tests share the exact payload normalization contract. export { buildDeviceAuthPayload, buildDeviceAuthPayloadV3, diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index e6d50546f1f7..8de8ea831ae2 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -32,6 +32,9 @@ import { resolveOpenAiCompatibleHttpOperatorScopes, } from "./http-utils.js"; +// OpenAI-compatible `/v1/embeddings` bridge. It maps OpenClaw agent/model +// routing onto configured memory embedding providers while preserving the +// response shape expected by OpenAI SDK clients. type OpenAiEmbeddingsHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; @@ -81,6 +84,8 @@ function encodeEmbeddingBase64(embedding: number[]): string { return Buffer.from(float32.buffer).toString("base64"); } +// Keep request limits local to the HTTP bridge; provider adapters may support +// more, but this endpoint must protect gateway memory and request latency. function validateInputTexts(texts: string[]): string | undefined { if (texts.length > MAX_EMBEDDING_INPUTS) { return `Too many inputs (max ${MAX_EMBEDDING_INPUTS}).`; @@ -117,6 +122,8 @@ async function createConfiguredEmbeddingProvider(params: { }): Promise { const providerId = params.provider === "auto" ? DEFAULT_MEMORY_EMBEDDING_PROVIDER : params.provider; + // Prefer memory-specific adapters because they understand query/document + // input types; generic embedding adapters are adapted only as a fallback. const createWithAdapter = async (adapter: MemoryEmbeddingProviderAdapter) => { const result = await adapter.create({ config: params.cfg, @@ -164,6 +171,8 @@ async function createConfiguredEmbeddingProvider(params: { return provider; } +// Generic embedding providers expose one embed API; memory search expects +// query/document methods so the HTTP endpoint can batch document-style inputs. function adaptGenericEmbeddingProvider( provider: GenericEmbeddingProvider, ): MemoryEmbeddingProvider { @@ -187,6 +196,8 @@ function adaptGenericEmbeddingProvider( }; } +// Request model overrides are constrained to the configured memory provider so +// a gateway client cannot select an arbitrary embedding provider by model name. function resolveEmbeddingsTarget(params: { requestModel: string; configuredProvider: EmbeddingProviderRequest; @@ -216,6 +227,7 @@ function resolveEmbeddingsTarget(params: { return { provider: configuredProvider, model }; } +/** Handles OpenAI-compatible embeddings requests for the configured agent memory provider. */ export async function handleOpenAiEmbeddingsHttpRequest( req: IncomingMessage, res: ServerResponse, diff --git a/src/gateway/env-deprecation.ts b/src/gateway/env-deprecation.ts index f72917b0e0f6..065576040505 100644 --- a/src/gateway/env-deprecation.ts +++ b/src/gateway/env-deprecation.ts @@ -1,10 +1,13 @@ import { isVitestRuntimeEnv } from "../infra/env.js"; +// Legacy env warnings are process-wide and intentionally one-shot so normal +// gateway startup is noisy enough to notice but not spammed by repeated imports. const LEGACY_ENV_PREFIXES = ["CLAWDBOT_", "MOLTBOT_"] as const; type LegacyEnvPrefix = (typeof LEGACY_ENV_PREFIXES)[number]; let warned = false; +/** Emits a one-time warning when ignored legacy CLAWDBOT_/MOLTBOT_ env vars are present. */ export function warnLegacyOpenClawEnvVars(env: NodeJS.ProcessEnv = process.env): void { if (warned || isVitestRuntimeEnv(env)) { return; @@ -37,6 +40,7 @@ export function warnLegacyOpenClawEnvVars(env: NodeJS.ProcessEnv = process.env): warned = true; } +/** Resets the one-shot legacy env warning latch for tests. */ export function resetLegacyOpenClawEnvWarningForTest(): void { warned = false; } diff --git a/src/gateway/event-loop-ready.test.ts b/src/gateway/event-loop-ready.test.ts index 9dfded6bcab3..b1be489cfd43 100644 --- a/src/gateway/event-loop-ready.test.ts +++ b/src/gateway/event-loop-ready.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway event-loop readiness tests. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { waitForEventLoopReady } from "./event-loop-ready.js"; diff --git a/src/gateway/event-loop-ready.ts b/src/gateway/event-loop-ready.ts index 33bf52d6d53b..d644cd319845 100644 --- a/src/gateway/event-loop-ready.ts +++ b/src/gateway/event-loop-ready.ts @@ -1,3 +1,5 @@ +// Re-export the gateway-client readiness primitive through the server gateway +// package so callers use one event-loop readiness contract. export { waitForEventLoopReady, type EventLoopReadyOptions, diff --git a/src/gateway/events.ts b/src/gateway/events.ts index 26ea50ad9a1a..68d031f74ab6 100644 --- a/src/gateway/events.ts +++ b/src/gateway/events.ts @@ -1,11 +1,15 @@ +// Gateway event payload constants shared by server broadcasts and UI clients. +/** Event name emitted when a newer OpenClaw version is available. */ export const GATEWAY_EVENT_UPDATE_AVAILABLE = "update.available" as const; +/** Version metadata included in update-available gateway events. */ export type UpdateAvailableEventData = { currentVersion: string; latestVersion: string; channel: string; }; +/** Gateway event payload for update availability broadcasts. */ export type GatewayUpdateAvailableEventPayload = { updateAvailable: UpdateAvailableEventData | null; }; diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index 5640cdd458c1..c72b6ae11e62 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -1,3 +1,6 @@ +/** + * Tests iOS push notification dispatch for exec approval requests. + */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js"; import { createDeferred } from "./test-helpers.deferred.js"; diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index a7aee8e2e0a8..b8cc9ea92273 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -22,6 +22,9 @@ import { } from "../infra/push-apns.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +// iOS exec-approval push delivery targets paired operator devices with APNs +// registrations. Request pushes require approval scope; cleanup/resolved pushes +// reuse the original targets so badges can clear even after scope changes. const APPROVALS_SCOPE = "operator.approvals"; const OPERATOR_ROLE = "operator"; @@ -187,6 +190,8 @@ async function resolveDeliveryPlan(params: { } const relayConfig = relayConfigByNodeId.values().next().value; + // Relay sends are grouped by one base URL because the wake helpers accept a + // single relay config; targets on other relay origins are skipped this round. return { targets: targets.filter((target) => target.registration.transport === "direct" @@ -253,6 +258,8 @@ async function sendApprovalPushes(params: { logThrown: boolean; send: ApprovalPushSender; }): Promise<{ attempted: number; delivered: number }> { + // Stale registrations are cleared on both direct and relay failures so future + // approval prompts do not keep targeting dead APNs device tokens. const results = await Promise.allSettled( params.plan.targets.map(async (target) => { const result = await params.send({ @@ -318,6 +325,8 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg const pendingDeliveryStateById = new Map>(); const sendCleanupPushForApproval = async (approvalId: string): Promise => { + // A resolve/expire event can arrive before the request push plan finishes; + // wait for the pending state so cleanup reaches the same target set. const deliveryState = approvalDeliveriesById.get(approvalId) ?? (await pendingDeliveryStateById.get(approvalId)); approvalDeliveriesById.delete(approvalId); @@ -345,6 +354,7 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg }; return { + /** Sends the initial approval notification to visible iOS operator devices. */ async handleRequested( request: ExecApprovalRequest, opts?: { isTargetVisible?: (target: ApprovalPushTarget) => boolean }, @@ -399,10 +409,12 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg return true; }, + /** Sends cleanup wakes for resolved approval requests. */ async handleResolved(resolved: ExecApprovalResolved): Promise { await sendCleanupPushForApproval(resolved.id); }, + /** Sends cleanup wakes for expired approval requests. */ async handleExpired(request: ExecApprovalRequest): Promise { await sendCleanupPushForApproval(request.id); }, diff --git a/src/gateway/exec-approval-manager.test.ts b/src/gateway/exec-approval-manager.test.ts index d9d3d18ea813..f8bdc3607c7a 100644 --- a/src/gateway/exec-approval-manager.test.ts +++ b/src/gateway/exec-approval-manager.test.ts @@ -1,3 +1,6 @@ +/** + * Tests exec approval manager state transitions and timeout behavior. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; diff --git a/src/gateway/explicit-connection-policy.ts b/src/gateway/explicit-connection-policy.ts index 229f01ac0f1f..d3b39365389b 100644 --- a/src/gateway/explicit-connection-policy.ts +++ b/src/gateway/explicit-connection-policy.ts @@ -1,10 +1,14 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { trimToUndefined, type ExplicitGatewayAuth } from "./credentials.js"; +// Explicit connection policy lets CLI paths skip config IO only when the caller +// provided both a URL and concrete auth. Cron stays a bypass path because it +// owns gateway startup/config loading separately. function hasExplicitGatewayConnectionAuth(auth?: ExplicitGatewayAuth): boolean { return Boolean(trimToUndefined(auth?.token) || trimToUndefined(auth?.password)); } +/** Returns true when url/auth flags are sufficient and loading OpenClaw config is unnecessary. */ export function canSkipGatewayConfigLoad(params: { config?: OpenClawConfig; urlOverride?: string; @@ -17,6 +21,7 @@ export function canSkipGatewayConfigLoad(params: { ); } +/** Returns true for command families that intentionally bypass gateway config loading. */ export function isGatewayConfigBypassCommandPath(commandPath: readonly string[]): boolean { return commandPath[0] === "cron"; } diff --git a/src/gateway/gateway-acp-spawn-defaults.live.test.ts b/src/gateway/gateway-acp-spawn-defaults.live.test.ts index 7997de65184a..32bed05c057f 100644 --- a/src/gateway/gateway-acp-spawn-defaults.live.test.ts +++ b/src/gateway/gateway-acp-spawn-defaults.live.test.ts @@ -1,3 +1,6 @@ +/** + * Live tests for default ACP spawn settings used by gateway sessions. + */ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import net from "node:net"; diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 4cdcad694469..3f760a7e4c06 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests live helper utilities for gateway CLI backend probes. + */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; diff --git a/src/gateway/gateway-cli-backend.live-probe-helpers.test.ts b/src/gateway/gateway-cli-backend.live-probe-helpers.test.ts index 839bdd29c425..2a4b004fef79 100644 --- a/src/gateway/gateway-cli-backend.live-probe-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-probe-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for live-probe helpers that validate the CLI MCP loopback backend. + */ import { createServer as createHttpServer, type IncomingMessage, diff --git a/src/gateway/gateway-codex-harness.live-helpers.test.ts b/src/gateway/gateway-codex-harness.live-helpers.test.ts index 13f1853708b8..7bc696e98503 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.test.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests live helper utilities used by the Codex gateway harness. + */ import { describe, expect, it } from "vitest"; import { EXPECTED_CODEX_MODELS_COMMAND_TEXT, diff --git a/src/gateway/gateway-codex-harness.live-helpers.ts b/src/gateway/gateway-codex-harness.live-helpers.ts index b4f4dc33a493..c37ea80c81db 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.ts @@ -1,3 +1,9 @@ +/** + * Text matchers shared by live Codex harness tests. + * + * The live CLI can answer with model lists, status cards, or sandbox fallback + * text depending on the host, so tests assert accepted response families here. + */ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [ "Codex models:", "Available Codex models", @@ -75,6 +81,7 @@ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [ "Current OpenClaw session status reports the active model as:", ] as const; +/** Accepted `/codex status` response fragments for live harness probes. */ export const EXPECTED_CODEX_STATUS_COMMAND_TEXT = [ "Codex app-server:", "Model: `codex/", @@ -96,6 +103,7 @@ export const EXPECTED_CODEX_STATUS_COMMAND_TEXT = [ "Ready.", ] as const; +/** Returns true when text matches a known healthy Codex status response shape. */ export function isExpectedCodexStatusCommandText(text: string): boolean { const normalized = text.toLowerCase(); const mentionsOpenClawStatus = @@ -156,6 +164,7 @@ export function isExpectedCodexStatusCommandText(text: string): boolean { ); } +/** Returns true when text matches a known Codex model-list or fallback shape. */ export function isExpectedCodexModelsCommandText(text: string): boolean { const normalized = text.toLowerCase(); const mentionsCodexModelsCommand = @@ -273,6 +282,7 @@ export function isExpectedCodexModelsCommandText(text: string): boolean { ); } +/** Identifies transient live harness errors that are worth retrying. */ export function isRetryableCodexHarnessLiveError(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -280,6 +290,7 @@ export function isRetryableCodexHarnessLiveError(error: unknown): boolean { return error.message.includes("gateway request timeout for sessions.list"); } +/** Skips retryable live errors only when the subagent probe is not under test. */ export function shouldSkipRetryableCodexHarnessLiveError( error: unknown, params: { subagentProbe: boolean }, diff --git a/src/gateway/gateway-connection.test-mocks.ts b/src/gateway/gateway-connection.test-mocks.ts index 8cdac4e0031d..02537de07b2c 100644 --- a/src/gateway/gateway-connection.test-mocks.ts +++ b/src/gateway/gateway-connection.test-mocks.ts @@ -1,5 +1,8 @@ import { vi, type Mock } from "vitest"; +/** + * Shared module mocks for gateway connection/startup tests. + */ type TestMock = Mock< (...args: TArgs) => TResult >; diff --git a/src/gateway/gateway-stability.test.ts b/src/gateway/gateway-stability.test.ts index 41e05cf3a6bd..e724a672568e 100644 --- a/src/gateway/gateway-stability.test.ts +++ b/src/gateway/gateway-stability.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway stability helpers for cleanup and repeated startup cycles. + */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { emitDiagnosticEvent, diff --git a/src/gateway/handshake-timeouts.ts b/src/gateway/handshake-timeouts.ts index 1bdd53f3ff73..de3f109b99f8 100644 --- a/src/gateway/handshake-timeouts.ts +++ b/src/gateway/handshake-timeouts.ts @@ -1,3 +1,5 @@ +// Re-export gateway-client handshake timeout helpers so server code and client +// packages share the same preauth/connect timeout bounds. export { clampConnectChallengeTimeoutMs, DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS, diff --git a/src/gateway/hooks-policy.ts b/src/gateway/hooks-policy.ts index 27ce19b40cf5..4b9ec42aff06 100644 --- a/src/gateway/hooks-policy.ts +++ b/src/gateway/hooks-policy.ts @@ -1,5 +1,8 @@ import { normalizeAgentId } from "../routing/session-key.js"; +// Hook policy config narrows hooks to explicit agent ids. A wildcard means no +// restriction, matching the gateway hook routing contract. +/** Resolves configured hook agent ids, or undefined when all agents are allowed. */ export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { if (!Array.isArray(raw)) { return undefined; diff --git a/src/gateway/hooks-test-helpers.ts b/src/gateway/hooks-test-helpers.ts index 0351b829f284..30d1ad5053ed 100644 --- a/src/gateway/hooks-test-helpers.ts +++ b/src/gateway/hooks-test-helpers.ts @@ -1,6 +1,9 @@ import type { IncomingMessage } from "node:http"; import type { HooksConfigResolved } from "./hooks.js"; +/** + * Hook endpoint fixtures shared by gateway hook tests. + */ export function createHooksConfig(): HooksConfigResolved { return { basePath: "/hooks", @@ -20,6 +23,7 @@ export function createHooksConfig(): HooksConfigResolved { }; } +/** Builds an IncomingMessage-shaped request for hook handler tests. */ export function createGatewayRequest(params: { path: string; authorization?: string; diff --git a/src/gateway/hooks.types.ts b/src/gateway/hooks.types.ts index e56f2e139f06..62c61fd6d139 100644 --- a/src/gateway/hooks.types.ts +++ b/src/gateway/hooks.types.ts @@ -1,3 +1,6 @@ import type { ChannelId } from "../channels/plugins/types.public.js"; +// Gateway hooks use public channel ids so hook payloads stay aligned with plugin +// channel contracts instead of internal runtime ids. +/** Public channel id type carried by gateway hook payloads. */ export type HookMessageChannel = ChannelId; diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index 03bdb5a76a71..e0963f35a18a 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Unit tests for the shared POST JSON endpoint helper used by gateway HTTP surfaces. + */ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedGatewayAuth } from "./auth.js"; diff --git a/src/gateway/http-endpoint-helpers.ts b/src/gateway/http-endpoint-helpers.ts index d9da7128249e..29a9c53b0f9c 100644 --- a/src/gateway/http-endpoint-helpers.ts +++ b/src/gateway/http-endpoint-helpers.ts @@ -13,6 +13,9 @@ import { } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; +// Generic POST+JSON endpoint wrapper used by gateway HTTP surfaces that share +// auth, scope, body-size, and method handling but implement their own payload. +/** Handles a gateway POST JSON endpoint and returns the parsed body when authorized. */ export async function handleGatewayPostJsonEndpoint( req: IncomingMessage, res: ServerResponse, diff --git a/src/gateway/http-utils.model-override.test.ts b/src/gateway/http-utils.model-override.test.ts index 3d67a9bc5848..df0160616998 100644 --- a/src/gateway/http-utils.model-override.test.ts +++ b/src/gateway/http-utils.model-override.test.ts @@ -1,3 +1,6 @@ +/** + * Tests HTTP model override parsing from gateway request headers and URLs. + */ import type { IncomingMessage } from "node:http"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/gateway/http-utils.request-context.test.ts b/src/gateway/http-utils.request-context.test.ts index 340de3c005b6..f7c5b5d62336 100644 --- a/src/gateway/http-utils.request-context.test.ts +++ b/src/gateway/http-utils.request-context.test.ts @@ -1,3 +1,6 @@ +/** + * Tests HTTP request context extraction for gateway auth and routing. + */ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; import { diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index f61ef4147ca5..8b8a15f46130 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -14,6 +14,9 @@ import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-auth-utils.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +// Shared HTTP helpers for OpenAI-compatible routes and gateway-specific JSON +// endpoints. They own agent/model/session derivation so handler files do not +// drift on header/model precedence. export { authorizeGatewayHttpRequestOrReply, authorizeScopedGatewayHttpRequestOrReply, @@ -32,6 +35,7 @@ export { } from "./http-auth-utils.js"; export const OPENCLAW_MODEL_ID = "openclaw"; +/** Default OpenAI-compatible model alias that targets the default OpenClaw agent. */ export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default"; function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined { @@ -45,6 +49,7 @@ function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined { return normalizeAgentId(raw); } +/** Resolves the target agent encoded by an OpenAI-compatible model id. */ export function resolveAgentIdFromModel( model: string | undefined, cfg = getRuntimeConfig(), @@ -68,6 +73,7 @@ export function resolveAgentIdFromModel( return normalizeAgentId(agentId); } +/** Validates and resolves the `x-openclaw-model` override for OpenAI-compatible requests. */ export async function resolveOpenAiCompatModelOverride(params: { req: IncomingMessage; agentId: string; @@ -104,6 +110,8 @@ export async function resolveOpenAiCompatModelOverride(params: { return { errorMessage: "Invalid `x-openclaw-model`." }; } + // Overrides must pass the same visibility policy as model picker surfaces; + // otherwise API clients could target hidden plugin/provider models by header. const catalog = await loadGatewayModelCatalog(); const policy = createModelVisibilityPolicy({ cfg, @@ -124,6 +132,7 @@ export async function resolveOpenAiCompatModelOverride(params: { return { modelOverride: raw }; } +/** Resolves the request agent from headers, model alias, or the configured default. */ export function resolveAgentIdForRequest(params: { req: IncomingMessage; model: string | undefined; @@ -154,6 +163,7 @@ function resolveSessionKey(params: { return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } +/** Resolves gateway agent/session/channel context for OpenAI-compatible handlers. */ export function resolveGatewayRequestContext(params: { req: IncomingMessage; model: string | undefined; diff --git a/src/gateway/input-allowlist.test.ts b/src/gateway/input-allowlist.test.ts index 169e8ac03e22..9dab0427199b 100644 --- a/src/gateway/input-allowlist.test.ts +++ b/src/gateway/input-allowlist.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway input allowlist tests. + */ import { describe, expect, it } from "vitest"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 56669ea39a52..412297e72410 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -1,3 +1,6 @@ +/** + * Tests helper logic for live agent probe configuration and result handling. + */ import { describe, expect, it, vi } from "vitest"; import { assertCronJobMatches, diff --git a/src/gateway/live-chat-projector.ts b/src/gateway/live-chat-projector.ts index 2398b20d12fe..d9e8efe7d60e 100644 --- a/src/gateway/live-chat-projector.ts +++ b/src/gateway/live-chat-projector.ts @@ -11,6 +11,8 @@ import { isSuppressedControlReplyText, } from "./control-reply-text.js"; +// Live chat projection converts streaming assistant runtime events into display +// text while suppressing commentary/control replies and silent-token prefixes. export const MAX_LIVE_CHAT_BUFFER_CHARS = 500_000; function capLiveAssistantBuffer(text: string): string { @@ -20,6 +22,7 @@ function capLiveAssistantBuffer(text: string): string { return text.slice(-MAX_LIVE_CHAT_BUFFER_CHARS); } +/** Merges assistant full-text and delta events into a capped live buffer. */ export function resolveMergedAssistantText(params: { previousText: string; nextText: string; @@ -43,6 +46,7 @@ export function resolveMergedAssistantText(params: { return capLiveAssistantBuffer(previousText); } +/** Removes runtime-only context/directive tags from live assistant event text. */ export function normalizeLiveAssistantEventText(params: { text: string; delta?: unknown }): { text: string; delta: string; @@ -56,6 +60,7 @@ export function normalizeLiveAssistantEventText(params: { text: string; delta?: }; } +/** Projects buffered assistant text into display text or a suppressed/pending state. */ export function projectLiveAssistantBufferedText( rawText: string, options?: { suppressLeadFragments?: boolean }, @@ -85,6 +90,7 @@ export function projectLiveAssistantBufferedText( return { text, suppress: false, pendingLeadFragment: false }; } +/** Returns true when an assistant event phase should not appear in live chat. */ export function shouldSuppressAssistantEventForLiveChat(data: unknown): boolean { return resolveAssistantEventPhase(data) === "commentary"; } diff --git a/src/gateway/live-env-test-helpers.ts b/src/gateway/live-env-test-helpers.ts index aa492b2b43fd..4cae617f0d58 100644 --- a/src/gateway/live-env-test-helpers.ts +++ b/src/gateway/live-env-test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Environment snapshot helpers for live gateway tests. + */ const COMMON_LIVE_ENV_NAMES = [ "OPENCLAW_AGENT_RUNTIME", "OPENCLAW_CONFIG_PATH", @@ -14,6 +17,7 @@ const COMMON_LIVE_ENV_NAMES = [ export type LiveEnvSnapshot = Record; +/** Captures live-test environment variables so tests can restore them later. */ export function snapshotLiveEnv(extraNames: readonly string[] = []): LiveEnvSnapshot { const snapshot: LiveEnvSnapshot = {}; for (const name of [...COMMON_LIVE_ENV_NAMES, ...extraNames]) { @@ -22,6 +26,7 @@ export function snapshotLiveEnv(extraNames: readonly string[] = []): LiveEnvSnap return snapshot; } +/** Restores a previously captured live-test environment snapshot. */ export function restoreLiveEnv(snapshot: LiveEnvSnapshot): void { for (const [name, value] of Object.entries(snapshot)) { if (value === undefined) { diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index a4b2d4b1a013..de78c7e9e006 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for nonce matching and retry heuristics used by live tool probes. + */ import { describe, expect, it } from "vitest"; import { hasExpectedSingleNonce, diff --git a/src/gateway/live-tool-probe-utils.ts b/src/gateway/live-tool-probe-utils.ts index 08695900bc83..a242e79efb29 100644 --- a/src/gateway/live-tool-probe-utils.ts +++ b/src/gateway/live-tool-probe-utils.ts @@ -1,9 +1,14 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Live tool probes check whether providers actually read requested files/tools. +// The retry heuristics distinguish expected nonce output from refusals, +// malformed tool output, and provider-specific partial nonce echoes. +/** Returns true when both expected tool-read nonces are present. */ export function hasExpectedToolNonce(text: string, nonceA: string, nonceB: string): boolean { return text.includes(nonceA) && text.includes(nonceB); } +/** Returns true when the expected exec-read nonce is present. */ export function hasExpectedSingleNonce(text: string, nonce: string): boolean { return text.includes(nonce); } @@ -44,6 +49,7 @@ const PROBE_REFUSAL_MARKERS = [ "authorizing me to run", ]; +/** Detects likely safety refusals for authorized nonce probes. */ export function isLikelyToolNonceRefusal(text: string): boolean { const lower = normalizeLowercaseStringOrEmpty(text); if (PROBE_REFUSAL_MARKERS.some((marker) => lower.includes(marker))) { @@ -83,6 +89,7 @@ function hasMalformedToolOutput(text: string): boolean { return false; } +/** Returns true when a file-read tool probe should retry before failing. */ export function shouldRetryToolReadProbe(params: { text: string; nonceA: string; @@ -110,6 +117,7 @@ export function shouldRetryToolReadProbe(params: { return false; } +/** Returns true when an exec-read probe should retry before failing. */ export function shouldRetryExecReadProbe(params: { text: string; nonce: string; diff --git a/src/gateway/local-request-context.test.ts b/src/gateway/local-request-context.test.ts index 72c75f950611..96b59fe0d419 100644 --- a/src/gateway/local-request-context.test.ts +++ b/src/gateway/local-request-context.test.ts @@ -1,3 +1,6 @@ +/** + * Local gateway request-context tests. + */ import { beforeAll, describe, expect, it } from "vitest"; import type { CliDeps } from "../cli/deps.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 72218ba2b984..15b916d96010 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -11,6 +11,9 @@ import { NodeRegistry } from "./node-registry.js"; import type { ChannelRuntimeSnapshot } from "./server-channel-runtime.types.js"; import type { GatewayRequestContext } from "./server-methods/types.js"; +// Embedded/local agent calls need enough GatewayRequestContext to reuse server +// methods without starting the full gateway. Unsupported subsystems fail loudly +// so local command paths do not silently enqueue cron/channel work. type LocalGatewayRequestContextParams = { deps: CliDeps; getRuntimeConfig: () => OpenClawConfig; @@ -41,6 +44,7 @@ const unavailableCron: CronServiceContract = { wake: () => ({ ok: false, reason: "unwakeable-session-key" }), }; +/** Creates the minimal gateway context used by embedded local agent execution. */ export function createLocalGatewayRequestContext( params: LocalGatewayRequestContextParams, ): GatewayRequestContext { @@ -53,6 +57,8 @@ export function createLocalGatewayRequestContext( const chatDeltaLastBroadcastText: GatewayRequestContext["chatDeltaLastBroadcastText"] = new Map(); const agentDeltaSentAt: GatewayRequestContext["agentDeltaSentAt"] = new Map(); const bufferedAgentEvents: GatewayRequestContext["bufferedAgentEvents"] = new Map(); + // Clear every per-run buffer variant together; streamed assistant/thinking + // deltas share the client run id prefix but are tracked under separate keys. const clearChatRunState = (runId: string) => { chatRunBuffers.delete(runId); chatDeltaSentAt.delete(runId); @@ -144,6 +150,7 @@ export function createLocalGatewayRequestContext( }; } +/** Runs code inside a local gateway request scope unless an outer scope already exists. */ export function withLocalGatewayRequestScope(params: LocalGatewayScopeParams, run: () => T): T { const existing = getPluginRuntimeGatewayRequestScope(); if (existing?.context) { diff --git a/src/gateway/mcp-http.handlers.ts b/src/gateway/mcp-http.handlers.ts index 6116a6eeac06..7a71df1905bd 100644 --- a/src/gateway/mcp-http.handlers.ts +++ b/src/gateway/mcp-http.handlers.ts @@ -15,11 +15,16 @@ import { type McpToolSchemaEntry, } from "./mcp-http.schema.js"; +// JSON-RPC handler for the in-process MCP loopback server. It intentionally +// supports the small method set needed by MCP clients: initialize, tools/list, +// tools/call, and notifications that do not need responses. type McpTextContent = { type: "text"; text: string; }; +// Tool implementations may return MCP content blocks, plain strings, or +// arbitrary JSON. Normalize them into text blocks for consistent loopback output. function normalizeToolCallContent(result: unknown): McpTextContent[] { const content = (result as { content?: unknown })?.content; if (Array.isArray(content)) { @@ -36,6 +41,7 @@ function normalizeToolCallContent(result: unknown): McpTextContent[] { ]; } +/** Handles one MCP loopback JSON-RPC message and returns a response or notification null. */ export async function handleMcpJsonRpc(params: { message: JsonRpcRequest; tools: McpLoopbackTool[]; @@ -48,6 +54,8 @@ export async function handleMcpJsonRpc(params: { switch (method) { case "initialize": { const clientVersion = (methodParams?.protocolVersion as string) ?? ""; + // Prefer the client-requested protocol when supported, otherwise fall + // back to the newest/first supported version advertised by this server. const negotiated = MCP_LOOPBACK_SUPPORTED_PROTOCOL_VERSIONS.find((version) => version === clientVersion) ?? MCP_LOOPBACK_SUPPORTED_PROTOCOL_VERSIONS[0]; @@ -91,6 +99,8 @@ export async function handleMcpJsonRpc(params: { } const toolCallId = `mcp-${crypto.randomUUID()}`; try { + // Gateway before-tool hooks still run for loopback MCP calls so policy + // and audit behavior matches native tool calls from normal chat runs. const hookResult = await runBeforeToolCallHook({ toolName, params: toolArgs, diff --git a/src/gateway/mcp-http.protocol.ts b/src/gateway/mcp-http.protocol.ts index c864e6d39ee9..5245d2e47222 100644 --- a/src/gateway/mcp-http.protocol.ts +++ b/src/gateway/mcp-http.protocol.ts @@ -1,9 +1,13 @@ +/** Server identity advertised by the local MCP loopback initialize response. */ export const MCP_LOOPBACK_SERVER_NAME = "openclaw"; +/** Protocol-facing loopback server version, independent from the OpenClaw app version. */ export const MCP_LOOPBACK_SERVER_VERSION = "0.1.0"; +/** MCP protocol versions accepted by the loopback HTTP bridge, newest first for negotiation. */ export const MCP_LOOPBACK_SUPPORTED_PROTOCOL_VERSIONS = ["2025-03-26", "2024-11-05"] as const; type JsonRpcId = string | number | null | undefined; +/** Minimal JSON-RPC request shape accepted by the MCP loopback HTTP handler. */ export type JsonRpcRequest = { jsonrpc: "2.0"; id?: JsonRpcId; @@ -11,10 +15,16 @@ export type JsonRpcRequest = { params?: Record; }; +/** + * Builds a JSON-RPC success response, using null for notifications or malformed missing ids. + */ export function jsonRpcResult(id: JsonRpcId, result: unknown) { return { jsonrpc: "2.0" as const, id: id ?? null, result }; } +/** + * Builds a JSON-RPC error response with the same id normalization as success responses. + */ export function jsonRpcError(id: JsonRpcId, code: number, message: string) { return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } }; } diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index cfa85f34a4b7..ebe1211e8757 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -8,6 +8,9 @@ import { } from "./mcp-http.schema.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; +// MCP loopback runtime scopes gateway tools to the current session/channel +// context and caches the expensive schema projection for short bursts of tool +// list/call traffic from the same MCP client. const TOOL_CACHE_TTL_MS = 30_000; const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]); @@ -33,6 +36,7 @@ type McpLoopbackScopeParams = { senderIsOwner: boolean | undefined; }; +/** Resolves loopback-visible tools after applying gateway scope and native-tool exclusions. */ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { agentId: string | undefined; tools: McpLoopbackTool[]; @@ -48,6 +52,7 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { }; } +/** Short-lived cache for loopback tool lists keyed by session/channel context. */ export class McpLoopbackToolCache { #entries = new Map(); @@ -66,6 +71,8 @@ export class McpLoopbackToolCache { ].join("\u0000"); const now = Date.now(); const cached = this.#entries.get(cacheKey); + // Config object identity is part of the cache contract so explicit gateway + // reloads invalidate tool scope and schema without filesystem polling. if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) { return cached; } diff --git a/src/gateway/mcp-http.schema.ts b/src/gateway/mcp-http.schema.ts index a69732979100..f444d66783c3 100644 --- a/src/gateway/mcp-http.schema.ts +++ b/src/gateway/mcp-http.schema.ts @@ -3,8 +3,12 @@ import { uniqueValues } from "@openclaw/normalization-core/string-normalization" import { logWarn } from "../logger.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; +// MCP loopback schema projection adapts gateway tool definitions into MCP +// tools/list entries. It flattens provider-hostile union schemas into object +// schemas because some MCP clients cannot render anyOf/oneOf controls. export type McpLoopbackTool = ReturnType["tools"][number]; +/** MCP tools/list schema entry derived from a gateway loopback tool. */ export type McpToolSchemaEntry = { name: string; description: string | undefined; @@ -19,6 +23,7 @@ function readLoopbackToolField(tool: McpLoopbackTool, key: "name" | "description } } +/** Safely reads and normalizes a loopback tool name from plugin-provided tool objects. */ export function readMcpLoopbackToolName(tool: McpLoopbackTool): string | undefined { const value = readLoopbackToolField(tool, "name"); if (typeof value !== "string") { @@ -136,6 +141,7 @@ function isPropertySchema(value: unknown): value is boolean | Record { const name = readMcpLoopbackToolName(tool); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index f11e10e9b9ca..e5b269d49791 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -23,6 +23,9 @@ import { } from "./mcp-http.request.js"; import { McpLoopbackToolCache } from "./mcp-http.runtime.js"; +// Loopback MCP server exposes gateway-scoped tools to local MCP clients over a +// bearer-token HTTP endpoint bound to 127.0.0.1. Only one active server/runtime +// is registered per process. export { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, @@ -51,6 +54,8 @@ function logMcpLoopbackTraffic(step: string, details: Record): console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); } +// Abort tool calls when the request disconnects before completion, but keep +// completed responses alive through normal response close notifications. function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) { const controller = new AbortController(); const abort = () => { @@ -82,6 +87,7 @@ function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) { }; } +/** Starts a new MCP loopback HTTP server and registers its bearer tokens. */ export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise; @@ -226,6 +232,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ return server; } +/** Returns the active MCP loopback server or starts one if none exists. */ export async function ensureMcpLoopbackServer(port = 0): Promise { if (activeMcpLoopbackServer) { return activeMcpLoopbackServer; @@ -243,6 +250,7 @@ export async function ensureMcpLoopbackServer(port = 0): Promise { const server = activeMcpLoopbackServer ?? diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 12aef20c3e87..93cf2c54be52 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway method-scope policy tests. + */ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; diff --git a/src/gateway/methods/registry.test.ts b/src/gateway/methods/registry.test.ts index 5cbc7a6f4812..7af7519d9641 100644 --- a/src/gateway/methods/registry.test.ts +++ b/src/gateway/methods/registry.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway method registry tests. + */ import { describe, expect, it } from "vitest"; import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; import type { GatewayRequestHandler } from "../server-methods/types.js"; diff --git a/src/gateway/minimal-gateway.test-helpers.ts b/src/gateway/minimal-gateway.test-helpers.ts index a0b434cd2245..503fa879c030 100644 --- a/src/gateway/minimal-gateway.test-helpers.ts +++ b/src/gateway/minimal-gateway.test-helpers.ts @@ -3,6 +3,9 @@ import type { WebSocketServer } from "ws"; import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js"; import { rawDataToString } from "../infra/ws.js"; +/** + * Minimal WebSocket gateway fixtures used by client/backend tests. + */ export type MinimalGatewayRequestFrame = { type?: string; id?: string; @@ -13,12 +16,14 @@ export type MinimalGatewayRequestFrame = { }; }; +/** Parses a raw WebSocket frame into the small request shape used by tests. */ export function parseMinimalGatewayRequestFrame( data: WebSocket.RawData, ): MinimalGatewayRequestFrame { return JSON.parse(rawDataToString(data)) as MinimalGatewayRequestFrame; } +/** Sends the connect challenge event expected by minimal gateway clients. */ export function sendMinimalGatewayConnectChallenge(ws: WebSocket, nonce = "test-nonce"): void { ws.send( JSON.stringify({ @@ -29,6 +34,7 @@ export function sendMinimalGatewayConnectChallenge(ws: WebSocket, nonce = "test- ); } +/** Builds a minimal hello-ok payload for fake gateway servers. */ export function buildMinimalGatewayHelloOkPayload(params?: { connId?: string; methods?: string[]; @@ -55,10 +61,12 @@ export function buildMinimalGatewayHelloOkPayload(params?: { }; } +/** Sends a successful response frame from a fake gateway server. */ export function sendMinimalGatewayResponse(ws: WebSocket, id: string, payload: unknown): void { ws.send(JSON.stringify({ type: "res", id, ok: true, payload })); } +/** Terminates all clients and closes a fake WebSocket gateway server. */ export async function closeMinimalGatewayServer(wss: WebSocketServer): Promise { for (const client of wss.clients) { client.terminate(); diff --git a/src/gateway/model-pricing-config.ts b/src/gateway/model-pricing-config.ts index 10a2ad63a212..82f222570e93 100644 --- a/src/gateway/model-pricing-config.ts +++ b/src/gateway/model-pricing-config.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Model pricing is enabled by default; config can explicitly disable it for +// deployments that do not want gateway cost lookups or display metadata. +/** Returns whether gateway model pricing/cost metadata should be shown. */ export function isGatewayModelPricingEnabled(config: OpenClawConfig): boolean { return config.models?.pricing?.enabled !== false; } diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 88a64539237b..4ce2b2a745f2 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway node catalog regression tests. + */ import { describe, expect, it } from "vitest"; import { createKnownNodeCatalog, @@ -140,9 +143,7 @@ describe("gateway/node-catalog", () => { it("surfaces node-pair metadata even when the node is offline", () => { const catalog = createKnownNodeCatalog({ - pairedDevices: [ - pairedDevice(), - ], + pairedDevices: [pairedDevice()], pairedNodes: [ pairedNode({ caps: ["system"], diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index cbcc4db1366e..2fa987b8006c 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -5,6 +5,9 @@ import type { NodePairingPairedNode } from "../infra/node-pairing.js"; import type { NodeListNode } from "../shared/node-list-types.js"; import type { NodeSession } from "./node-registry.js"; +// Known node catalog merges three sources: legacy paired devices, approved +// node-pairing records, and live websocket sessions. Live data wins for current +// metadata; pairing records keep approval and last-seen history. type KnownNodeDevicePairingSource = { nodeId: string; displayName?: string; @@ -92,6 +95,8 @@ function resolveEffectiveLastSeen(params: { devicePairing?: KnownNodeDevicePairingSource; nodePairing?: KnownNodeApprovedSource; }): { lastSeenAtMs?: number; lastSeenReason?: string } { + // Live connected time is the freshest signal; stored last-seen values fill in + // disconnected rows without letting stale device-pairing data override nodes. const candidates: Array<{ atMs: number; reason?: string }> = [ params.live?.connectedAtMs ? { atMs: params.live.connectedAtMs, reason: "connect" } : undefined, params.nodePairing?.lastSeenAtMs @@ -169,6 +174,7 @@ function compareKnownNodes(left: NodeListNode, right: NodeListNode): number { return left.nodeId.localeCompare(right.nodeId); } +/** Builds a node catalog keyed by node id from pairing stores and live sessions. */ export function createKnownNodeCatalog(params: { pairedDevices: readonly PairedDevice[]; pairedNodes?: readonly NodePairingPairedNode[]; @@ -209,12 +215,14 @@ export function createKnownNodeCatalog(params: { return { entriesById }; } +/** Lists known nodes with connected nodes first and deterministic display ordering. */ export function listKnownNodes(catalog: KnownNodeCatalog): NodeListNode[] { return [...catalog.entriesById.values()] .map((entry) => entry.effective) .toSorted(compareKnownNodes); } +/** Returns the merged catalog entry for diagnostics that need source details. */ export function getKnownNodeEntry( catalog: KnownNodeCatalog, nodeId: string, @@ -222,6 +230,7 @@ export function getKnownNodeEntry( return catalog.entriesById.get(nodeId) ?? null; } +/** Returns the effective node row shown to gateway clients. */ export function getKnownNode(catalog: KnownNodeCatalog, nodeId: string): NodeListNode | null { return getKnownNodeEntry(catalog, nodeId)?.effective ?? null; } diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index bce1175e8c30..33d238896b34 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -1,3 +1,6 @@ +/** + * Node command policy regression tests. + */ import { afterEach, describe, expect, it } from "vitest"; import { GATEWAY_CLIENT_IDS, diff --git a/src/gateway/node-connect-reconcile.test.ts b/src/gateway/node-connect-reconcile.test.ts index f2accdaa3cf0..56daa7ba1804 100644 --- a/src/gateway/node-connect-reconcile.test.ts +++ b/src/gateway/node-connect-reconcile.test.ts @@ -1,3 +1,6 @@ +/** + * Node connect reconciliation tests. + */ import { describe, expect, it, vi } from "vitest"; import { GATEWAY_CLIENT_IDS, diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts index 86249809bd93..ec8abdabdd40 100644 --- a/src/gateway/node-connect-reconcile.ts +++ b/src/gateway/node-connect-reconcile.ts @@ -16,6 +16,9 @@ import { resolveNodePairingCommandAllowlist, } from "./node-command-policy.js"; +// Node connect reconciliation turns declared caps/commands/permissions into the +// effective runtime surface. New or upgraded surfaces create a pending pairing +// request while already-approved surfaces are intersected with the declaration. export type NodeConnectPairingReconcileResult = { nodeId: string; declaredCaps: string[]; @@ -37,6 +40,8 @@ function resolveApprovedReconnectCommands(params: { }); } +// Permissions are sorted before comparison/results so reconnects are stable +// even when clients send JSON object keys in different orders. function normalizePermissionMap( value: Record | undefined, ): Record | undefined { @@ -101,6 +106,7 @@ function buildNodePairingRequestInput(params: { }; } +/** Reconciles a connecting node against stored approval and requests pairing when needed. */ export async function reconcileNodePairingOnConnect(params: { cfg: OpenClawConfig; connectParams: ConnectParams; @@ -177,6 +183,8 @@ export async function reconcileNodePairingOnConnect(params: { declared: declaredPermissions, }); + // A reconnect may use only the intersection of old approval and new + // declaration until the upgraded caps/commands/permissions are approved. if (hasCommandUpgrade || hasCapabilityChange || hasPermissionChange) { const pendingPairing = await params.requestPairing( buildNodePairingRequestInput({ diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 61664f869b59..e622641104b8 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -1,3 +1,6 @@ +/** + * Node invoke plugin-policy regression tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_PLUGIN_APPROVAL_TIMEOUT_MS, diff --git a/src/gateway/node-invoke-plugin-policy.ts b/src/gateway/node-invoke-plugin-policy.ts index c2578bfff3f9..666b1a2280e7 100644 --- a/src/gateway/node-invoke-plugin-policy.ts +++ b/src/gateway/node-invoke-plugin-policy.ts @@ -13,6 +13,8 @@ import type { NodeSession } from "./node-registry.js"; import { resolveApprovalRequestRecipientConnIds } from "./server-methods/approval-shared.js"; import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; +// Plugin node.invoke policies are the last gateway-side guard before a +// plugin-declared dangerous node command reaches the node transport. function parseScopes(client: GatewayClient | null): string[] { return Array.isArray(client?.connect?.scopes) ? client.connect.scopes.filter((scope): scope is string => typeof scope === "string") @@ -30,6 +32,8 @@ function parsePayload(payloadJSON: string | null | undefined, payload: unknown): } } +// Dangerous commands must have an explicit policy. Without this check, a plugin +// could mark a command dangerous but rely on the gateway default allow path. function findDangerousPluginNodeCommand(registry: PluginRegistry | null, command: string) { const normalizedCommand = command.trim(); if (!normalizedCommand) { @@ -110,6 +114,7 @@ function createApprovalRuntime(params: { }; } +/** Applies the registered plugin policy for a node.invoke command, if one exists. */ export async function applyPluginNodeInvokePolicy(params: { context: GatewayRequestContext; client: GatewayClient | null; @@ -138,6 +143,8 @@ export async function applyPluginNodeInvokePolicy(params: { const invokeNode: OpenClawPluginNodeInvokePolicyContext["invokeNode"] = async ( override = {}, ): Promise => { + // Policies invoke the real node through this narrowed transport wrapper so + // they can retry/override params without getting direct registry access. const res = await params.context.nodeRegistry.invoke({ nodeId: params.nodeSession.nodeId, command: params.command, diff --git a/src/gateway/node-invoke-sanitize.ts b/src/gateway/node-invoke-sanitize.ts index 651399dce08b..f1ab123cb3f3 100644 --- a/src/gateway/node-invoke-sanitize.ts +++ b/src/gateway/node-invoke-sanitize.ts @@ -2,6 +2,10 @@ import type { ExecApprovalManager } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; import type { GatewayClient } from "./server-methods/types.js"; +// Node invoke forwarding sanitizes command-specific payloads before they leave +// the gateway. system.run carries approval bindings and therefore needs special +// handling; other commands pass through unchanged. +/** Sanitizes node.invoke params before forwarding them to a connected node. */ export function sanitizeNodeInvokeParamsForForwarding(opts: { nodeId: string; command: string; diff --git a/src/gateway/node-invoke-system-run-approval-errors.ts b/src/gateway/node-invoke-system-run-approval-errors.ts index 91f8fe87ef57..bc086548880f 100644 --- a/src/gateway/node-invoke-system-run-approval-errors.ts +++ b/src/gateway/node-invoke-system-run-approval-errors.ts @@ -1,9 +1,12 @@ +// Shared system.run approval guard errors keep gateway/node responses +// machine-readable while preserving the user-facing message string. type SystemRunApprovalGuardError = { ok: false; message: string; details: Record; }; +/** Builds a failed system.run approval guard result with a structured code. */ export function systemRunApprovalGuardError(params: { code: string; message: string; @@ -20,6 +23,7 @@ export function systemRunApprovalGuardError(params: { }; } +/** Builds the standard response for system.run calls that still need approval. */ export function systemRunApprovalRequired(runId: string): SystemRunApprovalGuardError { return systemRunApprovalGuardError({ code: "APPROVAL_REQUIRED", diff --git a/src/gateway/node-invoke-system-run-approval-match.test.ts b/src/gateway/node-invoke-system-run-approval-match.test.ts index 50e7eda0ffe9..1cae4537ea26 100644 --- a/src/gateway/node-invoke-system-run-approval-match.test.ts +++ b/src/gateway/node-invoke-system-run-approval-match.test.ts @@ -1,3 +1,6 @@ +/** + * Node invoke system-run approval matching tests. + */ import { describe, expect, test } from "vitest"; import { buildSystemRunApprovalBinding } from "../infra/system-run-approval-binding.js"; import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; diff --git a/src/gateway/node-invoke-system-run-approval-match.ts b/src/gateway/node-invoke-system-run-approval-match.ts index 8e0c39862ecf..31254e322991 100644 --- a/src/gateway/node-invoke-system-run-approval-match.ts +++ b/src/gateway/node-invoke-system-run-approval-match.ts @@ -6,6 +6,8 @@ import { type SystemRunApprovalMatchResult, } from "../infra/system-run-approval-binding.js"; +// system.run approvals are bound to argv, cwd, agent/session, and env keys so a +// node cannot replay one approval for a different command or execution context. type SystemRunApprovalBinding = { cwd: string | null; agentId: string | null; @@ -23,6 +25,7 @@ function requestMismatch(): SystemRunApprovalMatchResult { export { toSystemRunApprovalMismatchError } from "../infra/system-run-approval-binding.js"; +/** Evaluates whether a node system.run request matches the stored approval binding. */ export function evaluateSystemRunApprovalMatch(params: { argv: string[]; request: ExecApprovalRequestPayload; diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index b0c562d0cc99..b4ea0b4f3d12 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -1,3 +1,6 @@ +/** + * Node invoke system-run approval tests. + */ import { describe, expect, test } from "vitest"; import { buildSystemRunApprovalBinding, diff --git a/src/gateway/node-pairing-auto-approve.test.ts b/src/gateway/node-pairing-auto-approve.test.ts index 9e8353b336e5..9d5a852772f9 100644 --- a/src/gateway/node-pairing-auto-approve.test.ts +++ b/src/gateway/node-pairing-auto-approve.test.ts @@ -1,3 +1,6 @@ +/** + * Node pairing auto-approval tests. + */ import { describe, expect, it } from "vitest"; import { resolveNodePairingClientIpSource, diff --git a/src/gateway/node-pairing-auto-approve.ts b/src/gateway/node-pairing-auto-approve.ts index f14aac02856c..7da5ce7ce0f6 100644 --- a/src/gateway/node-pairing-auto-approve.ts +++ b/src/gateway/node-pairing-auto-approve.ts @@ -1,5 +1,8 @@ import { isTrustedProxyAddress } from "./net.js"; +// Node auto-approval is limited to first-time node pairings from configured +// CIDRs. Browser/control-ui/webchat paths and upgrade requests require manual +// approval because they can expand trust or user-facing capability. export type NodePairingAutoApproveReason = | "not-paired" | "role-upgrade" @@ -12,6 +15,7 @@ type NodePairingAutoApproveClientIpSource = | "loopback-trusted-proxy" | "none"; +/** Classifies how the gateway learned the client IP for node auto-approval. */ export function resolveNodePairingClientIpSource(params: { reportedClientIp?: string; hasProxyHeaders: boolean; @@ -27,6 +31,7 @@ export function resolveNodePairingClientIpSource(params: { return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy"; } +/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */ export function shouldAutoApproveNodePairingFromTrustedCidrs(params: { existingPairedDevice: boolean; role: string; diff --git a/src/gateway/node-pending-work.test.ts b/src/gateway/node-pending-work.test.ts index 189108470398..bc2f5918f354 100644 --- a/src/gateway/node-pending-work.test.ts +++ b/src/gateway/node-pending-work.test.ts @@ -1,3 +1,6 @@ +/** + * Node pending-work tracking tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { acknowledgeNodePendingWork, diff --git a/src/gateway/node-pending-work.ts b/src/gateway/node-pending-work.ts index 3ddec1f9cdab..ef4015df25e7 100644 --- a/src/gateway/node-pending-work.ts +++ b/src/gateway/node-pending-work.ts @@ -6,10 +6,15 @@ import { resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; +// Pending node work is an in-memory per-node queue for gateway prompts such as +// status/location requests. Nodes drain it opportunistically and acknowledge +// item ids after handling them. const NODE_PENDING_WORK_TYPES = ["status.request", "location.request"] as const; +/** Work item types that connected nodes understand today. */ export type NodePendingWorkType = (typeof NODE_PENDING_WORK_TYPES)[number]; const NODE_PENDING_WORK_PRIORITIES = ["default", "normal", "high"] as const; +/** Priority labels used for pending work drain ordering. */ export type NodePendingWorkPriority = (typeof NODE_PENDING_WORK_PRIORITIES)[number]; type NodePendingWorkItem = { @@ -91,6 +96,7 @@ function pruneStateIfEmpty(nodeId: string, state: NodePendingWorkState) { } function sortedItems(state: NodePendingWorkState): NodePendingWorkItem[] { + // Higher priority wins, then older work, then id for deterministic paging. return [...state.itemsById.values()].toSorted((a, b) => { const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]; if (priorityDelta !== 0) { @@ -135,6 +141,8 @@ export function enqueueNodePendingWork(params: { const nowMs = resolveDateTimestampMs(rawNowMs); const state = getOrCreateState(nodeId); pruneExpired(state, nowMs); + // Keep one outstanding item per type so repeated status/location requests + // collapse until the node has a chance to drain and acknowledge them. const existing = [...state.itemsById.values()].find((item) => item.type === params.type); if (existing) { return { revision: state.revision, item: existing, deduped: true }; @@ -152,6 +160,7 @@ export function enqueueNodePendingWork(params: { return { revision: state.revision, item, deduped: false }; } +/** Drains pending work for a node, including a baseline status request unless disabled. */ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): DrainResult { const normalizedNodeId = nodeId.trim(); if (!normalizedNodeId) { @@ -181,6 +190,7 @@ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): D }; } +/** Acknowledges completed pending-work ids and advances the node revision. */ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: string[] }): { revision: number; removedItemIds: string[]; @@ -210,10 +220,12 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st return { revision: state.revision, removedItemIds }; } +/** Clears all pending work state for tests. */ export function resetNodePendingWorkForTests() { stateByNodeId.clear(); } +/** Returns the number of node queues retained in memory for tests. */ export function getNodePendingWorkStateCountForTests(): number { return stateByNodeId.size; } diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 8b945723d317..e58bf96e3a8c 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway node registry tests. + */ import { EventEmitter } from "node:events"; import { MAX_DATE_TIMESTAMP_MS, diff --git a/src/gateway/openai-compat-errors.ts b/src/gateway/openai-compat-errors.ts index a24b14e0c853..05ff604e434d 100644 --- a/src/gateway/openai-compat-errors.ts +++ b/src/gateway/openai-compat-errors.ts @@ -1,6 +1,8 @@ import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; import { describeFailoverError, resolveFailoverStatus } from "../agents/failover-error.js"; +// OpenAI-compatible endpoints translate provider failover errors into OpenAI +// error envelopes while hiding raw upstream/server-error details where needed. export type OpenAiCompatError = { status: number; error: { @@ -50,6 +52,7 @@ function messageForReason(params: { return params.rawError?.trim() || params.message.trim() || "request failed"; } +/** Converts a provider failover error into an OpenAI-compatible error envelope. */ export function resolveOpenAiCompatError(err: unknown): OpenAiCompatError | undefined { const described = describeFailoverError(err); const reason = described.reason; @@ -76,6 +79,7 @@ export function resolveOpenAiCompatError(err: unknown): OpenAiCompatError | unde }; } +/** Validates OpenAI-compatible sampling parameters before provider dispatch. */ export function validateOpenAiSamplingParams(params: { temperature?: unknown; topP?: unknown; diff --git a/src/gateway/openai-compatible-http.test-helpers.ts b/src/gateway/openai-compatible-http.test-helpers.ts index 6b168eb45618..8f8cef96bd74 100644 --- a/src/gateway/openai-compatible-http.test-helpers.ts +++ b/src/gateway/openai-compatible-http.test-helpers.ts @@ -1,6 +1,10 @@ +/** + * OpenAI-compatible HTTP gateway startup helper for tests. + */ type StartGatewayServer = typeof import("./server.js").startGatewayServer; type GatewayServerOptions = NonNullable[1]>; +/** Starts a local gateway with only the OpenAI-compatible HTTP surface configured. */ export async function startOpenAiCompatGatewayServer(options: { startGatewayServer: StartGatewayServer; port: number; diff --git a/src/gateway/openai-http.image-budget.test.ts b/src/gateway/openai-http.image-budget.test.ts index 27ca7a2957e9..b8cf70d7f19d 100644 --- a/src/gateway/openai-http.image-budget.test.ts +++ b/src/gateway/openai-http.image-budget.test.ts @@ -1,3 +1,6 @@ +/** + * Tests image budget handling for OpenAI HTTP gateway requests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const extractImageContentFromSourceMock = vi.fn(); diff --git a/src/gateway/openai-http.usage.test.ts b/src/gateway/openai-http.usage.test.ts index aaf7272a45d8..9a42fd783763 100644 --- a/src/gateway/openai-http.usage.test.ts +++ b/src/gateway/openai-http.usage.test.ts @@ -1,3 +1,6 @@ +/** + * Tests OpenAI HTTP usage extraction and gateway usage accounting. + */ import { describe, expect, it } from "vitest"; import { testOnlyOpenAiHttp } from "./openai-http.js"; diff --git a/src/gateway/openresponses-file-content.ts b/src/gateway/openresponses-file-content.ts index a8d864a58d05..0193539e3001 100644 --- a/src/gateway/openresponses-file-content.ts +++ b/src/gateway/openresponses-file-content.ts @@ -1,5 +1,8 @@ import { wrapExternalContent } from "../security/external-content.js"; +// OpenResponses file content is untrusted model input. The wrapper preserves +// content while marking it as external so prompt assembly keeps the boundary. +/** Wraps untrusted file content for OpenResponses input blocks. */ export function wrapUntrustedFileContent(content: string): string { return wrapExternalContent(content, { source: "unknown", diff --git a/src/gateway/openresponses-phase.test.ts b/src/gateway/openresponses-phase.test.ts index 1b7c70a4eed9..090bd9f97dd5 100644 --- a/src/gateway/openresponses-phase.test.ts +++ b/src/gateway/openresponses-phase.test.ts @@ -1,3 +1,6 @@ +/** + * Tests OpenAI Responses phase tracking for gateway request processing. + */ import { describe, expect, it } from "vitest"; import { CreateResponseBodySchema, OutputItemSchema } from "./open-responses.schema.js"; import { buildAgentPrompt } from "./openresponses-prompt.js"; diff --git a/src/gateway/openresponses-shape.ts b/src/gateway/openresponses-shape.ts index 1d353d6686f7..519343deaac9 100644 --- a/src/gateway/openresponses-shape.ts +++ b/src/gateway/openresponses-shape.ts @@ -1,5 +1,8 @@ import type { OutputItem } from "./open-responses.schema.js"; +// Small OpenResponses output factories keep streamed assistant/function-call +// items in the exact schema shape expected by response assembly and tests. +/** Creates an assistant output message item for OpenResponses-compatible responses. */ export function createAssistantOutputItem(params: { id: string; text: string; @@ -16,6 +19,7 @@ export function createAssistantOutputItem(params: { }; } +/** Creates a function-call output item for OpenResponses-compatible responses. */ export function createFunctionCallOutputItem(params: { id: string; callId: string; diff --git a/src/gateway/operator-approval-runtime-token.ts b/src/gateway/operator-approval-runtime-token.ts index 7212c4fdf125..48e65a631479 100644 --- a/src/gateway/operator-approval-runtime-token.ts +++ b/src/gateway/operator-approval-runtime-token.ts @@ -2,11 +2,17 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; let approvalRuntimeToken: string | null = null; +/** + * Returns the process-local token used to authorize loopback operator-approval clients. + */ export function getOperatorApprovalRuntimeToken(): string { approvalRuntimeToken ??= randomBytes(32).toString("base64url"); return approvalRuntimeToken; } +/** + * Validates a presented loopback approval token without accepting empty or partial matches. + */ export function isOperatorApprovalRuntimeToken(value: string | null | undefined): boolean { const token = value?.trim(); if (!token) { @@ -15,5 +21,6 @@ export function isOperatorApprovalRuntimeToken(value: string | null | undefined) const expected = getOperatorApprovalRuntimeToken(); const tokenBytes = Buffer.from(token); const expectedBytes = Buffer.from(expected); + // timingSafeEqual requires equal lengths; keep length rejection explicit instead of catching. return tokenBytes.length === expectedBytes.length && timingSafeEqual(tokenBytes, expectedBytes); } diff --git a/src/gateway/plugin-activation-runtime-config.ts b/src/gateway/plugin-activation-runtime-config.ts index 725e3dcc1720..e838d521fb2d 100644 --- a/src/gateway/plugin-activation-runtime-config.ts +++ b/src/gateway/plugin-activation-runtime-config.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isRecord } from "../utils.js"; +// Activation config carries only operator-controlled enable/allow surfaces into +// runtime config. Other runtime fields stay canonical to avoid stale activation +// state overriding live config reloads. function hasOwnValue(record: Record, key: string): boolean { return Object.hasOwn(record, key); } @@ -94,6 +97,7 @@ function mergePluginActivationSections(params: { }; } +/** Merges plugin/channel activation enablement into the runtime config shape. */ export function mergeActivationSectionsIntoRuntimeConfig(params: { runtimeConfig: OpenClawConfig; activationConfig: OpenClawConfig; diff --git a/src/gateway/plugin-channel-reload-targets.test.ts b/src/gateway/plugin-channel-reload-targets.test.ts index b6c55c04e42e..8b8122ba26de 100644 --- a/src/gateway/plugin-channel-reload-targets.test.ts +++ b/src/gateway/plugin-channel-reload-targets.test.ts @@ -1,3 +1,6 @@ +/** + * Plugin channel reload target tests. + */ import { describe, expect, it } from "vitest"; import { listChannelPluginConfigTargetIds, diff --git a/src/gateway/plugin-channel-reload-targets.ts b/src/gateway/plugin-channel-reload-targets.ts index dcd63b55b46c..d578a00462d4 100644 --- a/src/gateway/plugin-channel-reload-targets.ts +++ b/src/gateway/plugin-channel-reload-targets.ts @@ -1,6 +1,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { ChannelId } from "../channels/plugins/index.js"; +// Channel plugin reload targeting maps a channel id, plugin id, and aliases to +// config path prefixes so hot reload can decide whether a change affects a +// specific channel runtime. export type ChannelPluginReloadTarget = { channelId: ChannelId; pluginId?: string | null; @@ -14,6 +17,7 @@ function addNormalizedTarget(targets: Set, value: string | null | undefi } } +/** Lists all config ids that should trigger reload for a channel plugin target. */ export function listChannelPluginConfigTargetIds( target: ChannelPluginReloadTarget, ): ReadonlySet { @@ -26,6 +30,7 @@ export function listChannelPluginConfigTargetIds( return targets; } +/** Returns true when changed config paths affect any target plugin/channel id. */ export function pluginConfigTargetsChanged( targetIds: Iterable, changedPaths: readonly string[], diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index e24934d3f2fb..1bb831ad894a 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -9,6 +9,9 @@ import { export { resolveGatewayProbeTarget } from "./probe-target.js"; export type { GatewayProbeTargetResolution } from "./probe-target.js"; +// Probe auth adapts normal gateway credential precedence for reachability +// checks. Local probes must not accidentally consume remote gateway credentials +// from config when they are only checking the embedded/local gateway. function buildGatewayProbeCredentialPolicy(params: { cfg: OpenClawConfig; mode: "local" | "remote"; @@ -40,6 +43,8 @@ function resolveGatewayProbeCredentialConfig(params: { return params.cfg; } + // Strip remote auth only for local probes; otherwise remote credentials can + // mask a missing local token and make the wrong gateway look healthy. const remoteWithoutAuth = { ...remote }; delete remoteWithoutAuth.token; delete remoteWithoutAuth.password; @@ -76,6 +81,7 @@ function resolveGatewayProbeWarning(error: unknown): string | undefined { return buildUnresolvedProbeAuthWarning(error.path); } +/** Resolves synchronous probe auth, throwing when configured secrets cannot be read. */ export function resolveGatewayProbeAuth(params: { cfg: OpenClawConfig; mode: "local" | "remote"; @@ -85,6 +91,7 @@ export function resolveGatewayProbeAuth(params: { return resolveGatewayProbeCredentialsFromConfig(policy); } +/** Resolves probe auth with async SecretRef support. */ export async function resolveGatewayProbeAuthWithSecretInputs(params: { cfg: OpenClawConfig; mode: "local" | "remote"; @@ -101,6 +108,7 @@ export async function resolveGatewayProbeAuthWithSecretInputs(params: { }); } +/** Resolves probe auth without throwing for unavailable SecretRefs, returning a warning. */ export async function resolveGatewayProbeAuthSafeWithSecretInputs(params: { cfg: OpenClawConfig; mode: "local" | "remote"; @@ -128,6 +136,7 @@ export async function resolveGatewayProbeAuthSafeWithSecretInputs(params: { } } +/** Synchronous safe probe auth wrapper for config-only credential paths. */ export function resolveGatewayProbeAuthSafe(params: { cfg: OpenClawConfig; mode: "local" | "remote"; diff --git a/src/gateway/probe-target.ts b/src/gateway/probe-target.ts index b48a133c4348..2f15c570a0e8 100644 --- a/src/gateway/probe-target.ts +++ b/src/gateway/probe-target.ts @@ -1,12 +1,16 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Probe target resolution converts configured gateway mode into the actual +// reachable target. Remote mode falls back to local probing when no remote URL +// exists so startup diagnostics can explain the missing URL. export type GatewayProbeTargetResolution = { gatewayMode: "local" | "remote"; mode: "local" | "remote"; remoteUrlMissing: boolean; }; +/** Resolves whether gateway probe commands should target local or remote gateway. */ export function resolveGatewayProbeTarget(cfg: OpenClawConfig): GatewayProbeTargetResolution { const gatewayMode = cfg.gateway?.mode === "remote" ? "remote" : "local"; const remoteUrlRaw = normalizeOptionalString(cfg.gateway?.remote?.url) ?? ""; diff --git a/src/gateway/rate-limit-attempt-serialization.ts b/src/gateway/rate-limit-attempt-serialization.ts index 7cf79f3e63fb..2ddc832b004e 100644 --- a/src/gateway/rate-limit-attempt-serialization.ts +++ b/src/gateway/rate-limit-attempt-serialization.ts @@ -1,5 +1,7 @@ import { AUTH_RATE_LIMIT_SCOPE_DEFAULT, normalizeRateLimitClientIp } from "./auth-rate-limit.js"; +// Rate-limit attempts for the same IP/scope are serialized so concurrent auth +// failures cannot race the shared limiter state and undercount a burst. const pendingAttempts = new Map>(); function normalizeScope(scope: string | undefined): string { @@ -10,6 +12,7 @@ function buildSerializationKey(ip: string | undefined, scope: string | undefined return `${normalizeScope(scope)}:${normalizeRateLimitClientIp(ip)}`; } +/** Runs one rate-limit attempt after prior attempts for the same IP/scope finish. */ export async function withSerializedRateLimitAttempt(params: { ip: string | undefined; scope: string | undefined; diff --git a/src/gateway/resolve-configured-secret-input-string.test.ts b/src/gateway/resolve-configured-secret-input-string.test.ts index 95fd28de6847..d98913c6507f 100644 --- a/src/gateway/resolve-configured-secret-input-string.test.ts +++ b/src/gateway/resolve-configured-secret-input-string.test.ts @@ -1,3 +1,6 @@ +/** + * Tests configured secret input resolution for gateway method parameters. + */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; import { diff --git a/src/gateway/restart-trace.test.ts b/src/gateway/restart-trace.test.ts index 99798ca4edfc..67a8e96271df 100644 --- a/src/gateway/restart-trace.test.ts +++ b/src/gateway/restart-trace.test.ts @@ -1,3 +1,6 @@ +/** + * Tests restart trace formatting and persisted restart metadata. + */ import { describe, expect, it } from "vitest"; import { collectGatewayProcessMemoryUsageMb, diff --git a/src/gateway/restart-trace.ts b/src/gateway/restart-trace.ts index 2fa8823b7a39..0f855fe027b3 100644 --- a/src/gateway/restart-trace.ts +++ b/src/gateway/restart-trace.ts @@ -7,6 +7,9 @@ const RESTART_TRACE_HANDOFF_STARTED_AT_ENV = "OPENCLAW_GATEWAY_RESTART_TRACE_STA const RESTART_TRACE_HANDOFF_LAST_AT_ENV = "OPENCLAW_GATEWAY_RESTART_TRACE_LAST_AT_MS"; const RESTART_TRACE_HANDOFF_MAX_AGE_MS = 10 * 60_000; +// Restart trace is an opt-in timing logger for gateway restart handoff paths. +// It preserves elapsed time across process replacement through bounded env +// handoff values and ignores stale/future handoffs. type RestartTraceMetricValue = boolean | number | string | null | undefined; type RestartTraceMetrics = | Readonly> @@ -97,6 +100,7 @@ function emitRestartTraceDetail(name: string, metrics: RestartTraceMetrics): voi restartTraceLog.info(`restart trace: ${name} ${formatted}`); } +/** Starts a restart trace sequence when OPENCLAW_GATEWAY_RESTART_TRACE is enabled. */ export function startGatewayRestartTrace(name: string, metrics?: RestartTraceMetrics): void { if (!isRestartTraceEnabled()) { active = false; @@ -113,6 +117,7 @@ function isGatewayRestartTraceActive(): boolean { return isRestartTraceEnabled() && active; } +/** Emits a restart trace mark since the previous mark. */ export function markGatewayRestartTrace(name: string, metrics?: RestartTraceMetrics): void { if (!isGatewayRestartTraceActive()) { return; @@ -122,11 +127,13 @@ export function markGatewayRestartTrace(name: string, metrics?: RestartTraceMetr lastAt = now; } +/** Emits the final restart trace mark and deactivates tracing. */ export function finishGatewayRestartTrace(name: string, metrics?: RestartTraceMetrics): void { markGatewayRestartTrace(name, metrics); active = false; } +/** Measures a restart trace span around async or sync work. */ export async function measureGatewayRestartTrace( name: string, run: () => Promise | T, @@ -150,6 +157,7 @@ export async function measureGatewayRestartTrace( } } +/** Records a measured restart trace duration against the active sequence. */ export function recordGatewayRestartTrace( name: string, durationMs: number, @@ -163,6 +171,7 @@ export function recordGatewayRestartTrace( lastAt = now; } +/** Records an externally measured restart trace span with explicit total time. */ export function recordGatewayRestartTraceSpan( name: string, durationMs: number, @@ -175,6 +184,7 @@ export function recordGatewayRestartTraceSpan( emitRestartTrace(name, Math.max(0, durationMs), Math.max(0, totalMs), metrics); } +/** Records restart trace detail metrics without a duration. */ export function recordGatewayRestartTraceDetail(name: string, metrics: RestartTraceMetrics): void { if (!isGatewayRestartTraceActive()) { return; @@ -182,6 +192,7 @@ export function recordGatewayRestartTraceDetail(name: string, metrics: RestartTr emitRestartTraceDetail(name, metrics); } +/** Collects process memory/resource metrics for restart trace diagnostics. */ export function collectGatewayProcessMemoryUsageMb(): ReadonlyArray { const usage = process.memoryUsage(); const toMb = (bytes: number) => bytes / 1024 / 1024; @@ -275,6 +286,7 @@ function normalizeRestartTraceHandoff(value: unknown): GatewayRestartTraceHandof }; } +/** Captures restart trace handoff state for a child replacement process. */ export function captureGatewayRestartTraceHandoff(): GatewayRestartTraceHandoff | undefined { if (!isGatewayRestartTraceActive()) { return undefined; @@ -282,6 +294,7 @@ export function captureGatewayRestartTraceHandoff(): GatewayRestartTraceHandoff return { startedAt, lastAt }; } +/** Builds env vars that carry restart trace handoff state to a replacement process. */ export function createGatewayRestartTraceHandoffEnv( handoff: GatewayRestartTraceHandoff | undefined = captureGatewayRestartTraceHandoff(), ): NodeJS.ProcessEnv | undefined { @@ -295,6 +308,7 @@ export function createGatewayRestartTraceHandoffEnv( }; } +/** Resumes restart tracing from a validated in-memory handoff object. */ export function resumeGatewayRestartTraceFromHandoff( handoff: unknown, metrics?: RestartTraceMetrics, @@ -313,6 +327,7 @@ export function resumeGatewayRestartTraceFromHandoff( return true; } +/** Resumes restart tracing from env handoff vars and removes them from the env. */ export function resumeGatewayRestartTraceFromEnv( env: NodeJS.ProcessEnv = process.env, metrics?: RestartTraceMetrics, @@ -330,6 +345,7 @@ export function resumeGatewayRestartTraceFromEnv( ); } +/** Resets restart trace globals for tests. */ export function resetGatewayRestartTraceForTest(): void { startedAt = 0; lastAt = 0; diff --git a/src/gateway/role-policy.test.ts b/src/gateway/role-policy.test.ts index 3a1568ea0fdc..35d8a38d3fc5 100644 --- a/src/gateway/role-policy.test.ts +++ b/src/gateway/role-policy.test.ts @@ -1,3 +1,6 @@ +/** + * Tests role-policy helpers that normalize gateway-visible message roles. + */ import { describe, expect, test } from "vitest"; import { isRoleAuthorizedForMethod, diff --git a/src/gateway/runtime-plugin-config.test.ts b/src/gateway/runtime-plugin-config.test.ts index c046d5813d19..84772b414f67 100644 --- a/src/gateway/runtime-plugin-config.test.ts +++ b/src/gateway/runtime-plugin-config.test.ts @@ -1,3 +1,6 @@ +/** + * Runtime plugin config regression tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/runtime-plugin-config.ts b/src/gateway/runtime-plugin-config.ts index 9adecd4ff447..c0ed479d3f82 100644 --- a/src/gateway/runtime-plugin-config.ts +++ b/src/gateway/runtime-plugin-config.ts @@ -3,6 +3,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +// Gateway plugin config applies auto-enable rules against the current manifest +// snapshot. The WeakMap cache is keyed by config object plus snapshot identity, +// which are process-stable until an explicit reload/install flow replaces them. type CachedGatewayPluginConfig = { snapshot: PluginMetadataSnapshot; config: OpenClawConfig; @@ -10,6 +13,7 @@ type CachedGatewayPluginConfig = { const gatewayPluginConfigCache = new WeakMap(); +/** Resolves runtime config with plugin auto-enable applied for gateway startup/reload paths. */ export function resolveGatewayPluginConfig(params: { config: OpenClawConfig }): OpenClawConfig { const currentSnapshot = getCurrentPluginMetadataSnapshot({ config: params.config, diff --git a/src/gateway/server-aux-methods.ts b/src/gateway/server-aux-methods.ts index edf17e7f49db..b57bd75d11fa 100644 --- a/src/gateway/server-aux-methods.ts +++ b/src/gateway/server-aux-methods.ts @@ -1,3 +1,6 @@ +// Auxiliary gateway methods are exposed outside the primary chat/session method +// list for approval and secret-management flows that need their own scopes. +/** Gateway method ids handled by auxiliary approval/secret surfaces. */ export const GATEWAY_AUX_METHODS = [ "exec.approval.get", "exec.approval.list", diff --git a/src/gateway/server-broadcast-types.ts b/src/gateway/server-broadcast-types.ts index 73fef6635c95..782e7e9f3aa4 100644 --- a/src/gateway/server-broadcast-types.ts +++ b/src/gateway/server-broadcast-types.ts @@ -1,19 +1,24 @@ +// Gateway broadcast types are shared by websocket fanout helpers and request +// contexts so event delivery can carry optional state-version hints. type GatewayBroadcastStateVersion = { presence?: number; health?: number; }; +/** Options for gateway websocket broadcasts. */ export type GatewayBroadcastOpts = { dropIfSlow?: boolean; stateVersion?: GatewayBroadcastStateVersion; }; +/** Broadcast function signature for all connected clients. */ export type GatewayBroadcastFn = ( event: string, payload: unknown, opts?: GatewayBroadcastOpts, ) => void; +/** Broadcast function signature for targeted connection ids. */ export type GatewayBroadcastToConnIdsFn = ( event: string, payload: unknown, diff --git a/src/gateway/server-channel-runtime.types.ts b/src/gateway/server-channel-runtime.types.ts index b7d880f8d364..a92fea7c517b 100644 --- a/src/gateway/server-channel-runtime.types.ts +++ b/src/gateway/server-channel-runtime.types.ts @@ -1,5 +1,8 @@ import type { ChannelId, ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; +// Channel runtime snapshots are the read-only Gateway view of channel/account +// state used by status and server-method surfaces. +/** Snapshot of channel runtime state keyed by channel and account id. */ export type ChannelRuntimeSnapshot = { channels: Partial>; channelAccounts: Partial>>; diff --git a/src/gateway/server-channels.approval-bootstrap.test.ts b/src/gateway/server-channels.approval-bootstrap.test.ts index 1fbde58eb2e1..56c10cdf318c 100644 --- a/src/gateway/server-channels.approval-bootstrap.test.ts +++ b/src/gateway/server-channels.approval-bootstrap.test.ts @@ -1,3 +1,6 @@ +/** + * Server channel approval bootstrap tests. + */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import { diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 0433f87e5187..fc689aa0489a 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -1,3 +1,6 @@ +/** + * Server channel lifecycle tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelGatewayContext, ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import { diff --git a/src/gateway/server-chat.load-gateway-session-row.runtime.ts b/src/gateway/server-chat.load-gateway-session-row.runtime.ts index af77a5c9398b..b22979b924c7 100644 --- a/src/gateway/server-chat.load-gateway-session-row.runtime.ts +++ b/src/gateway/server-chat.load-gateway-session-row.runtime.ts @@ -1 +1,3 @@ +// Runtime barrel for loading Gateway session rows from chat paths without +// pulling the rest of session-utils into static startup imports. export { loadGatewaySessionRow } from "./session-utils.js"; diff --git a/src/gateway/server-chat.persist-session-lifecycle.runtime.ts b/src/gateway/server-chat.persist-session-lifecycle.runtime.ts index f81978c332c1..1c044f0b88ab 100644 --- a/src/gateway/server-chat.persist-session-lifecycle.runtime.ts +++ b/src/gateway/server-chat.persist-session-lifecycle.runtime.ts @@ -1 +1,3 @@ +// Runtime barrel for persisting session lifecycle events from chat paths while +// keeping lifecycle-state behind a narrow lazy import boundary. export { persistGatewaySessionLifecycleEvent } from "./session-lifecycle-state.js"; diff --git a/src/gateway/server-chat.stream-text-merge.test.ts b/src/gateway/server-chat.stream-text-merge.test.ts index 0bd2dd33f4b4..5aceaa08fd70 100644 --- a/src/gateway/server-chat.stream-text-merge.test.ts +++ b/src/gateway/server-chat.stream-text-merge.test.ts @@ -1,8 +1,8 @@ +/** + * Tests chat stream text merging before gateway events reach clients. + */ import { describe, expect, it } from "vitest"; -import { - MAX_LIVE_CHAT_BUFFER_CHARS, - resolveMergedAssistantText, -} from "./live-chat-projector.js"; +import { MAX_LIVE_CHAT_BUFFER_CHARS, resolveMergedAssistantText } from "./live-chat-projector.js"; describe("server chat stream text merge", () => { it.each([ diff --git a/src/gateway/server-close.runtime.ts b/src/gateway/server-close.runtime.ts index 2a08c9e66be8..aba96228b41d 100644 --- a/src/gateway/server-close.runtime.ts +++ b/src/gateway/server-close.runtime.ts @@ -1,2 +1,3 @@ +// Runtime close barrel keeps shutdown imports narrow for lazy server paths. export * from "./server-close.js"; export { drainActiveSessionsForShutdown } from "./session-reset-service.js"; diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index d839034827e9..154ae5b3d122 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server close lifecycle tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InternalHookEvent } from "../hooks/internal-hooks.js"; diff --git a/src/gateway/server-control-ui-root.resolve.test.ts b/src/gateway/server-control-ui-root.resolve.test.ts index 2263801c4c41..abac8236f403 100644 --- a/src/gateway/server-control-ui-root.resolve.test.ts +++ b/src/gateway/server-control-ui-root.resolve.test.ts @@ -1,3 +1,6 @@ +/** + * Tests control UI root resolution for gateway static asset serving. + */ import { beforeEach, describe, expect, test, vi } from "vitest"; const controlUiAssetsMocks = vi.hoisted(() => ({ diff --git a/src/gateway/server-control-ui-root.ts b/src/gateway/server-control-ui-root.ts index 93fc25d68b4c..a724406fa499 100644 --- a/src/gateway/server-control-ui-root.ts +++ b/src/gateway/server-control-ui-root.ts @@ -8,6 +8,9 @@ import { import type { RuntimeEnv } from "../runtime.js"; import type { ControlUiRootState } from "./control-ui.js"; +// Control UI root resolution prefers explicit config, then bundled/proven +// assets. Missing bundled assets trigger an async build attempt without blocking +// gateway startup. function startControlUiAssetsBuild(params: { gatewayRuntime: RuntimeEnv; log: { warn: (message: string) => void }; @@ -25,6 +28,7 @@ function startControlUiAssetsBuild(params: { }); } +/** Resolves the Control UI asset root state for gateway startup. */ export async function resolveGatewayControlUiRootState(params: { controlUiRootOverride?: string; controlUiEnabled: boolean; @@ -55,6 +59,8 @@ export async function resolveGatewayControlUiRootState(params: { const resolvedRoot = resolveRoot(); if (!resolvedRoot) { + // Source checkouts may need to build Control UI assets on demand; startup + // continues and the route can become available after the build completes. startControlUiAssetsBuild({ gatewayRuntime: params.gatewayRuntime, log: params.log, diff --git a/src/gateway/server-cron-lazy.test.ts b/src/gateway/server-cron-lazy.test.ts index 2444cd991488..046fe6a84e2e 100644 --- a/src/gateway/server-cron-lazy.test.ts +++ b/src/gateway/server-cron-lazy.test.ts @@ -1,3 +1,6 @@ +/** + * Tests lazy cron startup behavior in the gateway server. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/server-cron-lazy.ts b/src/gateway/server-cron-lazy.ts index 6b277cc46de0..ff16d64ea9f8 100644 --- a/src/gateway/server-cron-lazy.ts +++ b/src/gateway/server-cron-lazy.ts @@ -4,6 +4,8 @@ import type { CronServiceContract } from "../cron/service-contract.js"; import { resolveCronJobsStorePath } from "../cron/store.js"; import type { GatewayCronState } from "./server-cron.js"; +// Gateway cron is loaded lazily so startup/tests that never touch cron do not +// materialize scheduler loops or bundled plugin runtime. type LazyGatewayCronParams = { cfg: OpenClawConfig; deps: CliDeps; @@ -15,6 +17,7 @@ type LoadedGatewayCronState = { started: boolean; }; +/** Creates a cron state proxy that imports the real cron service on first use. */ export function createLazyGatewayCronState(params: LazyGatewayCronParams): GatewayCronState { const storePath = resolveCronJobsStorePath(params.cfg.cron?.store); const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; @@ -48,6 +51,8 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew } resolved.started = true; await resolved.state.cron.start(); + // If stop raced the lazy import/start path, immediately stop the loaded + // scheduler so shutdown does not leave a background loop alive. if (stopped && resolved.started) { resolved.started = false; resolved.state.cron.stop(); @@ -61,6 +66,8 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew return; } if (loading) { + // Stop may happen while the dynamic import is still in flight; attach a + // cleanup continuation instead of forcing cron to load synchronously. void loading .then((resolved) => { if (!stopped) { @@ -113,6 +120,8 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew }, wake(opts) { if (!loaded) { + // A wake should kick off lazy loading but cannot claim success before + // cron exists and knows whether the target job is wakeable. void load(); return { ok: false }; } diff --git a/src/gateway/server-cron-notifications.ts b/src/gateway/server-cron-notifications.ts index 1418cd78c7d0..9e54e932c3e7 100644 --- a/src/gateway/server-cron-notifications.ts +++ b/src/gateway/server-cron-notifications.ts @@ -19,6 +19,12 @@ import { formatErrorMessage } from "../infra/errors.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; +/** + * Gateway cron notification delivery for announce and webhook destinations. + * + * Webhooks are posted through the SSRF guard and logged with redacted URLs + * because cron config is user-authored and can point at arbitrary endpoints. + */ const CRON_WEBHOOK_TIMEOUT_MS = 10_000; type CronLogger = { @@ -49,6 +55,7 @@ function redactOptionalWebhookUrl(url: unknown): string | undefined { return normalized ? redactWebhookUrl(normalized) : undefined; } +/** Resolves direct webhook delivery and completion-destination webhooks. */ function resolveCronWebhookTargets(params: { delivery?: { mode?: string; @@ -88,6 +95,7 @@ function buildCronWebhookHeaders(webhookToken?: string): Record return headers; } +/** Posts a cron webhook without throwing back into scheduler completion flow. */ async function postCronWebhook(params: { webhookUrl: string; webhookToken?: string; @@ -138,6 +146,7 @@ async function postCronWebhook(params: { } } +/** Sends the immediate failure alert for cron jobs that failed before normal completion delivery. */ export async function sendGatewayCronFailureAlert(params: { deps: CliDeps; logger: CronLogger; @@ -206,6 +215,7 @@ export async function sendGatewayCronFailureAlert(params: { }); } +/** Dispatches completion and failure-destination notifications after a cron run finishes. */ export function dispatchGatewayCronFinishedNotifications(params: { evt: CronEvent; job?: CronJob; @@ -255,6 +265,8 @@ export function dispatchGatewayCronFinishedNotifications(params: { if (params.evt.summary) { for (const webhookTarget of webhookTargets) { + // Completion notification fanout is best-effort; the cron service has + // already recorded the run result and must not wait on slow webhooks. void (async () => { await postCronWebhook({ webhookUrl: webhookTarget.url, @@ -312,6 +324,8 @@ function dispatchCronFailureDestinationNotifications(params: { if (failureDest.mode === "webhook" && failureDest.to) { const webhookUrl = normalizeHttpWebhookUrl(failureDest.to); if (webhookUrl) { + // Failure destinations mirror completion webhooks: notify in the + // background and log failures without rewriting the cron event result. void (async () => { await postCronWebhook({ webhookUrl, diff --git a/src/gateway/server-discovery.test.ts b/src/gateway/server-discovery.test.ts index 1b031737f836..c7265002d9c9 100644 --- a/src/gateway/server-discovery.test.ts +++ b/src/gateway/server-discovery.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server discovery tests. + */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const getTailnetHostname = vi.hoisted(() => vi.fn()); diff --git a/src/gateway/server-discovery.ts b/src/gateway/server-discovery.ts index a974889496ed..4aa644aeb3f6 100644 --- a/src/gateway/server-discovery.ts +++ b/src/gateway/server-discovery.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { getTailnetHostname } from "../infra/tailscale.js"; import { runExec } from "../process/exec.js"; +// Server discovery helpers provide Bonjour/Tailscale metadata for gateway +// advertising without making startup depend on optional tailscale tooling. type ResolveBonjourCliPathOptions = { env?: NodeJS.ProcessEnv; argv?: string[]; @@ -11,6 +13,7 @@ type ResolveBonjourCliPathOptions = { statSync?: (path: string) => fs.Stats; }; +/** Formats the Bonjour instance name while preserving user-provided OpenClaw names. */ export function formatBonjourInstanceName(displayName: string) { const trimmed = displayName.trim(); if (!trimmed) { @@ -22,6 +25,7 @@ export function formatBonjourInstanceName(displayName: string) { return `${trimmed} (OpenClaw)`; } +/** Resolves the CLI path advertised to Bonjour clients, preferring explicit env config. */ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}): string | undefined { const env = opts.env ?? process.env; const envPath = env.OPENCLAW_CLI_PATH?.trim(); @@ -64,6 +68,7 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}): return undefined; } +/** Resolves a Tailnet DNS hint from env or the local tailscale CLI when enabled. */ export async function resolveTailnetDnsHint(opts?: { env?: NodeJS.ProcessEnv; exec?: typeof runExec; diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 40de43d21783..fb0d14dd6858 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -1,3 +1,6 @@ +/** + * Tests timeout behavior for gateway HTTP hook request handling. + */ import { beforeEach, describe, expect, test, vi } from "vitest"; import { createHookRequest, diff --git a/src/gateway/server-http.stages.test.ts b/src/gateway/server-http.stages.test.ts index 80445dbc2301..6db4dffc772d 100644 --- a/src/gateway/server-http.stages.test.ts +++ b/src/gateway/server-http.stages.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the staged HTTP request pipeline used by the gateway server. + */ import { describe, expect, it, vi } from "vitest"; import { runGatewayHttpRequestStages } from "./server-http.js"; diff --git a/src/gateway/server-json.ts b/src/gateway/server-json.ts index 44d153a57b75..2eda50c8fed7 100644 --- a/src/gateway/server-json.ts +++ b/src/gateway/server-json.ts @@ -1,5 +1,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +// Gateway JSON parsing accepts optional payload JSON and preserves invalid +// payload text for callers that need to surface or forward parse failures. +/** Safely parses an optional JSON string, returning a payloadJSON wrapper on parse failure. */ export function safeParseJson(value: string | null | undefined): unknown { const trimmed = normalizeOptionalString(value); if (!trimmed) { diff --git a/src/gateway/server-lanes.test.ts b/src/gateway/server-lanes.test.ts index 8c4c8995b440..0f7a3c664307 100644 --- a/src/gateway/server-lanes.test.ts +++ b/src/gateway/server-lanes.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server lane configuration tests. + */ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_CRON_MAX_CONCURRENT_RUNS } from "../config/cron-limits.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/server-live-state.ts b/src/gateway/server-live-state.ts index 3fe4dcde9c3f..b09263ee084d 100644 --- a/src/gateway/server-live-state.ts +++ b/src/gateway/server-live-state.ts @@ -7,6 +7,9 @@ import { } from "./server-runtime-handles.js"; import type { HookClientIpConfig } from "./server/hooks-request-handler.js"; +// Live state combines mutable runtime handles with startup-resolved services +// that server methods need during request handling. +/** Mutable gateway server state shared across request contexts. */ export type GatewayServerLiveState = GatewayServerMutableState & { hooksConfig: HooksConfigResolved | null; hookClientIpConfig: HookClientIpConfig; @@ -15,6 +18,7 @@ export type GatewayServerLiveState = GatewayServerMutableState & { gatewayMethods: string[]; }; +/** Creates gateway live state with fresh mutable runtime handles. */ export function createGatewayServerLiveState(params: { hooksConfig: HooksConfigResolved | null; hookClientIpConfig: HookClientIpConfig; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 46550406d643..3c72f72914a5 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the registered gateway server method list and exported method names. + */ import { describe, expect, it } from "vitest"; import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js"; diff --git a/src/gateway/server-methods.control-plane-rate-limit.test.ts b/src/gateway/server-methods.control-plane-rate-limit.test.ts index 8f7dc4634150..f35caa2782b8 100644 --- a/src/gateway/server-methods.control-plane-rate-limit.test.ts +++ b/src/gateway/server-methods.control-plane-rate-limit.test.ts @@ -1,3 +1,6 @@ +/** + * Tests control-plane rate limiting for gateway method dispatch. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isRetryableGatewayStartupUnavailableError } from "../../packages/gateway-protocol/src/startup-unavailable.js"; import { diff --git a/src/gateway/server-methods/agent-id-shared.ts b/src/gateway/server-methods/agent-id-shared.ts index 49d1fe0ba170..da1928bad48c 100644 --- a/src/gateway/server-methods/agent-id-shared.ts +++ b/src/gateway/server-methods/agent-id-shared.ts @@ -3,6 +3,9 @@ import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RespondFn } from "./types.js"; +/** + * Shared agent-id resolver for request handlers that accept optional agent ids. + */ export function resolveAgentIdOrRespondError(params: { rawAgentId: unknown; respond: RespondFn; diff --git a/src/gateway/server-methods/agent-wait-dedupe.test.ts b/src/gateway/server-methods/agent-wait-dedupe.test.ts index 67854536482c..699628324ea4 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.test.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent wait dedupe behavior for repeated gateway wait requests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AGENT_RUN_ABORTED_ERROR } from "../../agents/run-termination.js"; import type { DedupeEntry } from "../server-shared.js"; diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts index 5b714cf8dca8..a26f6500a878 100644 --- a/src/gateway/server-methods/agent.create-event.test.ts +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent creation event emission from gateway agent methods. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/approval-shared.test.ts b/src/gateway/server-methods/approval-shared.test.ts index 6ea8d1c55197..164fee6d6bde 100644 --- a/src/gateway/server-methods/approval-shared.test.ts +++ b/src/gateway/server-methods/approval-shared.test.ts @@ -1,3 +1,6 @@ +/** + * Tests shared approval helpers used by gateway method handlers. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; diff --git a/src/gateway/server-methods/channels.start.test.ts b/src/gateway/server-methods/channels.start.test.ts index 132877b7965c..919333cd9c4f 100644 --- a/src/gateway/server-methods/channels.start.test.ts +++ b/src/gateway/server-methods/channels.start.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway channels.start method tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 352f23fca287..6cb0b8acc217 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway channels.status method tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { requireRecord } from "../test-helpers.assertions.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; diff --git a/src/gateway/server-methods/chat-reply-media.test.ts b/src/gateway/server-methods/chat-reply-media.test.ts index 8730f0ec926d..6ce1191827c5 100644 --- a/src/gateway/server-methods/chat-reply-media.test.ts +++ b/src/gateway/server-methods/chat-reply-media.test.ts @@ -1,3 +1,6 @@ +/** + * Tests chat reply media handling for gateway message delivery. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/chat.abort-authorization.test.ts b/src/gateway/server-methods/chat.abort-authorization.test.ts index 34e7ffb7fce0..1e8daf126206 100644 --- a/src/gateway/server-methods/chat.abort-authorization.test.ts +++ b/src/gateway/server-methods/chat.abort-authorization.test.ts @@ -1,3 +1,6 @@ +/** + * Tests chat abort authorization checks for gateway clients and session owners. + */ import { describe, expect, it } from "vitest"; import { createActiveRun, diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index ec9961df83b0..ab52305efdda 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -1,3 +1,6 @@ +/** + * Tests persistence effects when chat abort requests complete. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index 2ba02f8c52e9..45aca9f410d8 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Shared helpers for chat abort gateway method tests. + */ import { vi } from "vitest"; import type { Mock } from "vitest"; import type { GatewayRequestHandler, RespondFn } from "./types.js"; diff --git a/src/gateway/server-methods/chat.send-deleted-agent.test.ts b/src/gateway/server-methods/chat.send-deleted-agent.test.ts index c807e20403a3..1f76614cd480 100644 --- a/src/gateway/server-methods/chat.send-deleted-agent.test.ts +++ b/src/gateway/server-methods/chat.send-deleted-agent.test.ts @@ -1,3 +1,6 @@ +/** + * Tests that chat send rejects deleted-agent sessions before dispatch. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { chatHandlers } from "./chat.js"; diff --git a/src/gateway/server-methods/chat.test-helpers.ts b/src/gateway/server-methods/chat.test-helpers.ts index eef7b289d133..582672ad1d33 100644 --- a/src/gateway/server-methods/chat.test-helpers.ts +++ b/src/gateway/server-methods/chat.test-helpers.ts @@ -1,8 +1,12 @@ +/** + * Fixtures for chat method tests that need a real persisted session transcript. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; +/** Writes a minimal current-version transcript file and returns its temp location. */ export function createTranscriptFixtureSync(params: { prefix: string; sessionId: string; diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 775dd52ebf68..2b7a82414d41 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for command gateway methods and command registry responses. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatCommandDefinition } from "../../auto-reply/commands-registry.types.js"; diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 57eb1a487be4..2fb5e454b460 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -1,3 +1,6 @@ +/** + * Tests shared gateway auth behavior across config method updates. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; diff --git a/src/gateway/server-methods/config.test-helpers.ts b/src/gateway/server-methods/config.test-helpers.ts index d3ee96cc6a90..d1dbd7db285f 100644 --- a/src/gateway/server-methods/config.test-helpers.ts +++ b/src/gateway/server-methods/config.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Shared harness builders for gateway config method tests. + */ import { vi, type Mock } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -25,6 +28,7 @@ function createGatewayLog(): GatewayLogMocks { }; } +/** Creates a complete config snapshot shape for tests that exercise config writes. */ export function createConfigWriteSnapshot(config: OpenClawConfig) { return { snapshot: { @@ -46,6 +50,7 @@ export function createConfigWriteSnapshot(config: OpenClawConfig) { }; } +/** Builds request-handler options with captured response and gateway log mocks. */ export function createConfigHandlerHarness(args?: { method?: string; params?: unknown; @@ -76,6 +81,7 @@ export function createConfigHandlerHarness(args?: { }; } +/** Allows fire-and-forget config handler microtasks to settle before assertions. */ export async function flushConfigHandlerMicrotasks() { await new Promise((resolve) => { queueMicrotask(resolve); diff --git a/src/gateway/server-methods/config.test.ts b/src/gateway/server-methods/config.test.ts index 678f1a700c8b..7833c92442fd 100644 --- a/src/gateway/server-methods/config.test.ts +++ b/src/gateway/server-methods/config.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for config gateway methods, writes, validation, and auth transitions. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearConfigSchemaResponseCacheForTests, diff --git a/src/gateway/server-methods/connect.ts b/src/gateway/server-methods/connect.ts index 3a107452f3cc..18cf9b98da40 100644 --- a/src/gateway/server-methods/connect.ts +++ b/src/gateway/server-methods/connect.ts @@ -1,6 +1,9 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** + * Rejects `connect` after the WebSocket handshake already established identity. + */ export const connectHandlers: GatewayRequestHandlers = { connect: ({ respond }) => { respond( diff --git a/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts b/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts index 956a31cdfe17..60e65cdfc564 100644 --- a/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts +++ b/src/gateway/server-methods/deleted-agent-guard.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Module-level session-utils mocks for deleted-agent guard tests. + */ import { vi } from "vitest"; const deletedAgentSessionMocks = vi.hoisted(() => ({ @@ -15,11 +18,13 @@ vi.mock("../session-utils.js", async () => { }; }); +/** Resets mocked deleted-agent session lookups between tests. */ export function resetDeletedAgentSessionMocks(): void { deletedAgentSessionMocks.loadSessionEntry.mockReset(); deletedAgentSessionMocks.resolveDeletedAgentIdFromSessionKey.mockReset(); } +/** Stubs a session that resolves to an agent id no longer present in config. */ export function mockDeletedAgentSession(orphanKey = "agent:deleted-agent:main"): string { deletedAgentSessionMocks.loadSessionEntry.mockReturnValue({ cfg: {}, diff --git a/src/gateway/server-methods/diagnostics.test.ts b/src/gateway/server-methods/diagnostics.test.ts index 78870bec6a60..a4cf19b453fb 100644 --- a/src/gateway/server-methods/diagnostics.test.ts +++ b/src/gateway/server-methods/diagnostics.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for gateway diagnostics methods and their request-handler responses. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emitDiagnosticEvent, diff --git a/src/gateway/server-methods/doctor.memory-core-runtime.ts b/src/gateway/server-methods/doctor.memory-core-runtime.ts index e4d1b8dc82eb..b4cf013ae695 100644 --- a/src/gateway/server-methods/doctor.memory-core-runtime.ts +++ b/src/gateway/server-methods/doctor.memory-core-runtime.ts @@ -1,3 +1,9 @@ +/** + * Lazy boundary for doctor memory-core repair helpers. + * + * Doctor tests mock this file so the gateway method does not import bundled + * memory-core runtime code until a repair action actually needs it. + */ export { dedupeDreamDiaryEntries, previewGroundedRemMarkdown, diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 80ffc1eccb86..1ac65540dc2d 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for doctor gateway methods and repair command dispatch. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index ccd5993b47dc..d6a05bc0de26 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for environment gateway methods and configured environment discovery. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { listDevicePairing } from "../../infra/device-pairing.js"; diff --git a/src/gateway/server-methods/gateway-response.test-helpers.ts b/src/gateway/server-methods/gateway-response.test-helpers.ts index 33accb0e3e7a..fcfcfdbdd80c 100644 --- a/src/gateway/server-methods/gateway-response.test-helpers.ts +++ b/src/gateway/server-methods/gateway-response.test-helpers.ts @@ -1,9 +1,13 @@ +/** + * Assertion helpers for gateway method response envelopes. + */ import { expect } from "vitest"; type MockCallSource = { mock: { calls: ReadonlyArray> }; }; +/** Verifies that a mocked respond callback emitted the expected gateway error. */ export function expectGatewayErrorResponse( respond: MockCallSource, expected: { code: string; message: string }, diff --git a/src/gateway/server-methods/native-hook-relay.test.ts b/src/gateway/server-methods/native-hook-relay.test.ts index e9d4643a2052..62ef0269fa74 100644 --- a/src/gateway/server-methods/native-hook-relay.test.ts +++ b/src/gateway/server-methods/native-hook-relay.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for relaying native hook events through gateway request handlers. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { testing, registerNativeHookRelay } from "../../agents/harness/native-hook-relay.js"; import { nativeHookRelayHandlers } from "./native-hook-relay.js"; diff --git a/src/gateway/server-methods/node-child-process.test-support.ts b/src/gateway/server-methods/node-child-process.test-support.ts index a06b95e93b37..f81157ee94f9 100644 --- a/src/gateway/server-methods/node-child-process.test-support.ts +++ b/src/gateway/server-methods/node-child-process.test-support.ts @@ -1,3 +1,6 @@ +/** + * Test support for gateway methods that spawn node child processes. + */ import { vi } from "vitest"; import { mockNodeBuiltinModule } from "../../plugin-sdk/test-helpers/node-builtin-mocks.js"; diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts index 29054932fac7..228661d06ab7 100644 --- a/src/gateway/server-methods/nodes-pending.test.ts +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -1,3 +1,6 @@ +/** + * Tests pending-node gateway method responses and state filtering. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { nodePendingHandlers } from "./nodes-pending.js"; diff --git a/src/gateway/server-methods/nodes.helpers.ts b/src/gateway/server-methods/nodes.helpers.ts index ade7d0d728fc..a0090b89925f 100644 --- a/src/gateway/server-methods/nodes.helpers.ts +++ b/src/gateway/server-methods/nodes.helpers.ts @@ -9,10 +9,14 @@ export { safeParseJson } from "../server-json.js"; import { formatForLog } from "../ws-log.js"; import type { RespondFn } from "./types.js"; +/** + * Shared response adapters for node-related gateway methods. + */ type ValidatorFn = ((value: unknown) => boolean) & { errors?: ValidationError[] | null; }; +/** Responds with the protocol validation error for invalid method params. */ export function respondInvalidParams(params: { respond: RespondFn; method: string; @@ -28,6 +32,7 @@ export function respondInvalidParams(params: { ); } +/** Converts thrown node-handler failures into `UNAVAILABLE` protocol errors. */ export async function respondUnavailableOnThrow(respond: RespondFn, fn: () => Promise) { try { await fn(); @@ -36,6 +41,7 @@ export async function respondUnavailableOnThrow(respond: RespondFn, fn: () => Pr } } +/** Narrows successful node invoke results or responds with the node error details. */ export function respondUnavailableOnNodeInvokeError( respond: RespondFn, res: T, diff --git a/src/gateway/server-methods/optional-model-catalog.ts b/src/gateway/server-methods/optional-model-catalog.ts index 1f7ad8c77dc9..538a246ad16a 100644 --- a/src/gateway/server-methods/optional-model-catalog.ts +++ b/src/gateway/server-methods/optional-model-catalog.ts @@ -1,10 +1,15 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { GatewayRequestContext } from "./types.js"; +/** + * Optional model-catalog loader for methods where metadata improves the result + * but should never block the primary session response path. + */ const OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 750; const loggedSlowCatalogKeys = new Set(); +/** Loads the gateway model catalog with a short timeout and one-time slow logs. */ export async function loadOptionalServerMethodModelCatalog( context: GatewayRequestContext, surface: string, diff --git a/src/gateway/server-methods/record-shared.ts b/src/gateway/server-methods/record-shared.ts index a948b9e85312..d2dee47ea7a0 100644 --- a/src/gateway/server-methods/record-shared.ts +++ b/src/gateway/server-methods/record-shared.ts @@ -1,5 +1,9 @@ +/** + * Small normalization helpers shared by gateway request handlers. + */ export { asOptionalRecord as asRecord } from "../../../packages/normalization-core/src/record-coerce.js"; +/** Returns a non-empty trimmed string, or `undefined` for non-string input. */ export function normalizeTrimmedString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 37d72f9c1c33..80e0fc9b2057 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for gateway secret resolution and redacted secret method responses. + */ import { describe, expect, it, vi } from "vitest"; import { TALK_TEST_PROVIDER_API_KEY_PATH, diff --git a/src/gateway/server-methods/session-active-runs.ts b/src/gateway/server-methods/session-active-runs.ts index a0dd3ccfff79..ad5092b119ba 100644 --- a/src/gateway/server-methods/session-active-runs.ts +++ b/src/gateway/server-methods/session-active-runs.ts @@ -1,6 +1,12 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayRequestContext } from "./types.js"; +/** + * Active-run matcher used by session list/update methods. + * + * It only reports runs visible to the Control UI so background or hidden runs + * do not make a session look busy to user-facing session operations. + */ type TrackedActiveSessionRun = { sessionKey: string; agentId?: string; @@ -51,6 +57,7 @@ function isTrackedActiveSessionRunForKey( : false; } +/** Returns true when either requested or canonical session key has a visible active run. */ export function hasTrackedActiveSessionRun(params: { context: Partial>; requestedKey: string; diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index fbd18dd53a88..e2d48f98f427 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -1,3 +1,6 @@ +/** + * Tests that session abort requests stay scoped to the targeted agent. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; diff --git a/src/gateway/server-methods/sessions.runtime.ts b/src/gateway/server-methods/sessions.runtime.ts index 97d3ba09f5a6..8b8dcb8925d8 100644 --- a/src/gateway/server-methods/sessions.runtime.ts +++ b/src/gateway/server-methods/sessions.runtime.ts @@ -1,3 +1,6 @@ +/** + * Lazy runtime boundary for session reset/archive helpers used by gateway methods. + */ export { archiveSessionTranscriptsForSessionDetailed, cleanupSessionBeforeMutation, diff --git a/src/gateway/server-methods/sessions.send-deleted-agent.test.ts b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts index 99fa6f50223f..273ec641970f 100644 --- a/src/gateway/server-methods/sessions.send-deleted-agent.test.ts +++ b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts @@ -1,3 +1,6 @@ +/** + * Tests that session send rejects sessions whose configured agent was deleted. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index aee43947e6ee..e3449f1390b6 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -1,3 +1,6 @@ +/** + * Tests follow-up session send status transitions and broadcasts. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { expectSubagentFollowupReactivation } from "./subagent-followup.test-helpers.js"; import type { GatewayRequestContext, RespondFn } from "./types.js"; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 786e67c0f00f..378ce431333a 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -22,8 +22,12 @@ import type { BufferedAgentEvent } from "../server-chat-state.js"; import type { DedupeEntry } from "../server-shared.js"; import type { GatewayEventLoopHealth } from "../server/event-loop-health.js"; +/** + * Shared gateway request types used by every server-method module. + */ type SubsystemLogger = ReturnType; +/** Per-connection client metadata captured after the gateway handshake. */ export type GatewayClient = { connect: ConnectParams; connId?: string; @@ -40,6 +44,7 @@ export type GatewayClient = { }; }; +/** Callback used by method handlers to emit one protocol response frame. */ export type RespondFn = ( ok: boolean, payload?: unknown, @@ -47,6 +52,7 @@ export type RespondFn = ( meta?: Record, ) => void; +/** Runtime services and mutable gateway state available to request handlers. */ export type GatewayRequestContext = { deps: CliDeps; cron: CronServiceContract; @@ -143,6 +149,7 @@ export type GatewayRequestContext = { unavailableGatewayMethods?: ReadonlySet; }; +/** Full dispatch context for raw request frames before params are normalized. */ export type GatewayRequestOptions = { req: RequestFrame; client: GatewayClient | null; @@ -152,6 +159,7 @@ export type GatewayRequestOptions = { methodRegistry?: GatewayMethodRegistryView; }; +/** Normalized method invocation options passed to registered handlers. */ export type GatewayRequestHandlerOptions = { req: RequestFrame; params: Record; @@ -161,6 +169,8 @@ export type GatewayRequestHandlerOptions = { context: GatewayRequestContext; }; +/** Single gateway method implementation. */ export type GatewayRequestHandler = (opts: GatewayRequestHandlerOptions) => Promise | void; +/** Registry fragment keyed by gateway protocol method name. */ export type GatewayRequestHandlers = Record; diff --git a/src/gateway/server-methods/skills-upload.test.ts b/src/gateway/server-methods/skills-upload.test.ts index 390ca22c0ed3..d9ff105e8c63 100644 --- a/src/gateway/server-methods/skills-upload.test.ts +++ b/src/gateway/server-methods/skills-upload.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for skill upload gateway methods and archive validation. + */ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index 115e531d5865..e995b686ac33 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for skill proposal gateway methods and proposal lifecycle responses. + */ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/gateway/server-methods/skills.test-helpers.ts b/src/gateway/server-methods/skills.test-helpers.ts index 17dde0ce5907..f7e54bbe3ec4 100644 --- a/src/gateway/server-methods/skills.test-helpers.ts +++ b/src/gateway/server-methods/skills.test-helpers.ts @@ -1,6 +1,10 @@ +/** + * Small gateway-handler invocation harness for skills method tests. + */ import { vi } from "vitest"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; +/** Captured JSON-RPC response tuple emitted by a gateway request handler. */ export type CapturedGatewayResponse = { ok: boolean | null; response: unknown; @@ -14,6 +18,7 @@ function makeGatewayHandlerTestContext(): GatewayRequestContext { } as unknown as GatewayRequestContext; } +/** Invokes a named gateway handler with a minimal context and captures its response. */ export async function callGatewayHandler( handlers: GatewayRequestHandlers, method: string, diff --git a/src/gateway/server-methods/subagent-followup.test-helpers.ts b/src/gateway/server-methods/subagent-followup.test-helpers.ts index a599f049c43e..c5d7dbcd95d7 100644 --- a/src/gateway/server-methods/subagent-followup.test-helpers.ts +++ b/src/gateway/server-methods/subagent-followup.test-helpers.ts @@ -1,5 +1,9 @@ +/** + * Assertions for the subagent follow-up reactivation broadcast path. + */ import { expect } from "vitest"; +/** Checks both run replacement and the session-change broadcast emitted after steer. */ export function expectSubagentFollowupReactivation(params: { replaceSubagentRunAfterSteerMock: unknown; broadcastToConnIds: unknown; diff --git a/src/gateway/server-methods/talk-client.ts b/src/gateway/server-methods/talk-client.ts index 1a476dd6fc4f..eb8ab714cde4 100644 --- a/src/gateway/server-methods/talk-client.ts +++ b/src/gateway/server-methods/talk-client.ts @@ -27,6 +27,12 @@ import { } from "./talk-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** + * Gateway methods for browser-owned realtime Talk sessions. + * + * These handlers create provider browser sessions and bridge client-owned tool + * calls back into OpenClaw agent consult runs. + */ export const talkClientHandlers: GatewayRequestHandlers = { "talk.client.create": async ({ params, respond, context }) => { if (!validateTalkClientCreateParams(params)) { @@ -251,6 +257,8 @@ function hasOwnedActiveTalkClientRun(params: { clientConnId?: string; sessionKey: string; }): boolean { + // Browser steering is only allowed for the connection that owns the live + // browser session; agent-owned consult runs use the relay steering path. const connId = normalizeOptionalString(params.clientConnId); const sessionKey = params.sessionKey.trim(); if (!connId || !sessionKey) { diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index a63f9ad539a2..c9cdef44a4fd 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -68,6 +68,12 @@ import { import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; +/** + * Gateway-managed Talk session methods for managed rooms and audio relays. + * + * The public `sessionId` is resolved through the unified registry so each RPC + * can enforce the correct connection ownership for its concrete backend. + */ type ManagedRoomTalkSession = Extract; function normalizeTalkSessionMode(params: { mode?: string; transport?: string }): TalkMode { @@ -181,6 +187,7 @@ function respondManagedRoomTurn(params: { respondOk(params.respond, { ok: true, turnId: result.turnId, events: result.events }); } +/** RPC handlers for gateway-managed Talk sessions and room lifecycle. */ export const talkSessionHandlers: GatewayRequestHandlers = { "talk.session.create": async ({ params, respond, context, client }) => { if ( diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index b40372f65331..733fc95e7cfa 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for talk gateway methods that coordinate speech and audio providers. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/config.js"; diff --git a/src/gateway/server-methods/tasks.test.ts b/src/gateway/server-methods/tasks.test.ts index eb2c8dee6514..e9335e7eecc4 100644 --- a/src/gateway/server-methods/tasks.test.ts +++ b/src/gateway/server-methods/tasks.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for task gateway methods and persisted task lifecycle responses. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index eafd8fc12949..afab0a66b42e 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for tool catalog gateway methods and plugin tool visibility. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index 05bb4af96c1f..28ff3633f40d 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -1,3 +1,6 @@ +/** + * Lazy import boundary for effective-tool inventory helpers used by gateway RPCs. + */ export { listAgentIds, resolveAgentDir, diff --git a/src/gateway/server-methods/tools-invoke.ts b/src/gateway/server-methods/tools-invoke.ts index a02614ac0447..35bc0f70666d 100644 --- a/src/gateway/server-methods/tools-invoke.ts +++ b/src/gateway/server-methods/tools-invoke.ts @@ -9,6 +9,9 @@ import { import { invokeGatewayTool } from "../tools-invoke-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** + * RPC adapter for invoking gateway-visible tools from connected clients. + */ function resolveRpcErrorCode(params: { type: "invalid_request" | "not_found" | "tool_call_blocked" | "tool_error"; requiresApproval?: boolean; @@ -29,6 +32,7 @@ function resolveRpcErrorCode(params: { return "internal_error"; } +/** Handles `tools.invoke` with protocol-shaped success and failure payloads. */ export const toolsInvokeHandlers: GatewayRequestHandlers = { "tools.invoke": async ({ params, respond, context }) => { if (!validateToolsInvokeParams(params)) { diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index 4a1743027e6a..8d56b7f0c353 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for text-to-speech gateway methods and provider error envelopes. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { expectGatewayErrorResponse } from "./gateway-response.test-helpers.js"; diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index cbc2821dbc78..4eb16a6457ce 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -1 +1,4 @@ +/** + * Public barrel for server-method request and context types. + */ export type * from "./shared-types.js"; diff --git a/src/gateway/server-methods/update-managed-service-handoff.test.ts b/src/gateway/server-methods/update-managed-service-handoff.test.ts index a1923da3ed69..2ab6432761b0 100644 --- a/src/gateway/server-methods/update-managed-service-handoff.test.ts +++ b/src/gateway/server-methods/update-managed-service-handoff.test.ts @@ -1,3 +1,6 @@ +/** + * Tests managed-service update handoff behavior exposed by gateway methods. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index b50b9d7750e9..3a64a224c6d3 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -1,3 +1,6 @@ +/** + * Tests for usage-report gateway methods and aggregation responses. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; diff --git a/src/gateway/server-methods/web.start.test.ts b/src/gateway/server-methods/web.start.test.ts index 25a2a1462f4b..0941f738540d 100644 --- a/src/gateway/server-methods/web.start.test.ts +++ b/src/gateway/server-methods/web.start.test.ts @@ -1,3 +1,6 @@ +/** + * Tests web.start gateway method behavior and backend launch responses. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index 20fbfb86728c..a5e457808586 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server model catalog tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayModelChoice } from "./server-model-catalog.js"; diff --git a/src/gateway/server-network-runtime.test.ts b/src/gateway/server-network-runtime.test.ts index ff468d6f34bf..6a32c5cbcb23 100644 --- a/src/gateway/server-network-runtime.test.ts +++ b/src/gateway/server-network-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server network runtime tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureGlobalUndiciEnvProxyDispatcherMock = vi.fn(); diff --git a/src/gateway/server-network-runtime.ts b/src/gateway/server-network-runtime.ts index b71a2f457a79..cfbe5b443097 100644 --- a/src/gateway/server-network-runtime.ts +++ b/src/gateway/server-network-runtime.ts @@ -1,5 +1,8 @@ import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; +// Gateway network bootstrap installs the global undici proxy dispatcher before +// server code makes outbound fetch calls. +/** Applies process-wide gateway network runtime setup. */ export function bootstrapGatewayNetworkRuntime(): void { ensureGlobalUndiciEnvProxyDispatcher(); } diff --git a/src/gateway/server-node-events-types.ts b/src/gateway/server-node-events-types.ts index 77f8161dc646..0a848e6ef6c2 100644 --- a/src/gateway/server-node-events-types.ts +++ b/src/gateway/server-node-events-types.ts @@ -5,6 +5,9 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js"; import type { ChatRunEntry } from "./server-chat.js"; import type { DedupeEntry } from "./server-shared.js"; +// Node event handlers receive a narrowed context instead of the full gateway +// request context so node-originated events can mutate only the state they own. +/** Runtime context available to node event handlers. */ export type NodeEventContext = { deps: CliDeps; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; @@ -40,6 +43,7 @@ export type NodeEventContext = { logGateway: { warn: (msg: string) => void }; }; +/** Raw event envelope received from connected node clients. */ export type NodeEvent = { event: string; payloadJSON?: string | null; diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index 1c2f221ef2fd..06326f8e8f94 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -1,3 +1,6 @@ +// Runtime import barrel for node event handlers. Keeping these dependencies in +// one lazy boundary prevents gateway startup paths from loading every node-event +// helper before node traffic is actually handled. export { resolveSessionAgentId } from "../agents/agent-scope.js"; export { sanitizeInboundSystemTags } from "../auto-reply/reply/inbound-text.js"; export { normalizeChannelId } from "../channels/plugins/index.js"; diff --git a/src/gateway/server-node-session-runtime.ts b/src/gateway/server-node-session-runtime.ts index 0f702eb3e684..3c332fb14e3c 100644 --- a/src/gateway/server-node-session-runtime.ts +++ b/src/gateway/server-node-session-runtime.ts @@ -6,6 +6,9 @@ import { import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js"; +// Node session runtime owns connected node registry state, session event +// subscriptions, and voice-wake fanout helpers for the gateway process. +/** Creates node registry/subscription runtime state for a gateway server. */ export function createGatewayNodeSessionRuntime(params: { broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; }) { @@ -21,6 +24,8 @@ export function createGatewayNodeSessionRuntime(params: { }) => { nodeRegistry.sendEventRaw(opts.nodeId, opts.event, opts.payloadJSON ?? null); }; + // Session fanout goes through the subscription manager so node reconnects and + // explicit unsubscribes keep both node->session indexes in sync. const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) => nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent); const nodeSendToAllSubscribed = (event: string, payload: unknown) => diff --git a/src/gateway/server-node-subscriptions.ts b/src/gateway/server-node-subscriptions.ts index 858b4028f050..1db711aa3e7e 100644 --- a/src/gateway/server-node-subscriptions.ts +++ b/src/gateway/server-node-subscriptions.ts @@ -1,5 +1,7 @@ import { serializeEventPayload, type SerializedEventPayload } from "./node-registry.js"; +// Node subscription manager keeps bidirectional node/session indexes so gateway +// events can fan out by session and all node cleanup paths remove reverse links. type NodeSendEventFn = (opts: { nodeId: string; event: string; @@ -32,6 +34,7 @@ type NodeSubscriptionManager = { clear: () => void; }; +/** Manages node subscriptions to gateway session events. */ export function createNodeSubscriptionManager(): NodeSubscriptionManager { const nodeSubscriptions = new Map>(); const sessionSubscribers = new Map>(); @@ -115,6 +118,8 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager { } const payloadJSON = toPayloadJSON(payload); + // Serialize once per event and reuse across all subscribed nodes to keep + // fanout deterministic and avoid repeated JSON conversion. for (const nodeId of subs) { sendEvent({ nodeId, event, payloadJSON }); } diff --git a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts index 0dcec6bfb766..03f0a60f136d 100644 --- a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts +++ b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts @@ -1,3 +1,6 @@ +/** + * Integration tests for browser plugin bootstrap through the gateway server. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBundledBrowserPluginFixture } from "../../test/helpers/browser-bundled-plugin-fixture.js"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 93c339a3594e..c48798ac47f0 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -18,6 +18,9 @@ import { setPluginSubagentOverridePolicies, } from "./server-plugins.js"; +// Gateway plugin bootstrap applies activation/auto-enable config, installs +// plugin runtime bindings, loads plugins, primes channel bindings, and pins the +// active registry for startup/reload paths. type GatewayPluginBootstrapLog = { info: (msg: string) => void; warn: (msg: string) => void; @@ -53,6 +56,8 @@ function installGatewayPluginRuntimeEnvironment(cfg: OpenClawConfig) { setGatewayNodesRuntime(createGatewayNodesRuntime()); } +// Diagnostics are logged after registry priming so startup output contains +// plugin ids/source hints without exposing internal diagnostic objects. function logGatewayPluginDiagnostics(params: { diagnostics: PluginRegistry["diagnostics"]; log: Pick; @@ -75,6 +80,7 @@ function logGatewayPluginDiagnostics(params: { } } +/** Prepares gateway plugin runtime and returns the loaded plugin registry state. */ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { const activationSourceConfig = params.activationSourceConfig ?? params.cfg; const autoEnabled = applyPluginAutoEnable({ @@ -92,6 +98,8 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { runtimeConfig: params.cfg, activationConfig: autoEnabled.config, }); + // Runtime bindings must be installed before loadGatewayPlugins so plugin + // hooks that inspect gateway/node/subagent helpers see current config. installGatewayPluginRuntimeEnvironment(resolvedConfig); const loaded = loadGatewayPlugins({ cfg: resolvedConfig, @@ -126,6 +134,7 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { return loaded; } +/** Loads and pins gateway plugins during normal gateway startup. */ export function loadGatewayStartupPlugins( params: Omit, ) { @@ -135,6 +144,7 @@ export function loadGatewayStartupPlugins( }); } +/** Reloads deferred gateway plugins while preserving startup bootstrap behavior. */ export function reloadDeferredGatewayPlugins( params: Omit< GatewayPluginBootstrapParams, diff --git a/src/gateway/server-plugins.lifecycle.test.ts b/src/gateway/server-plugins.lifecycle.test.ts index 96b2532b066b..3a9e53299e3e 100644 --- a/src/gateway/server-plugins.lifecycle.test.ts +++ b/src/gateway/server-plugins.lifecycle.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway plugin lifecycle loading, startup, and shutdown behavior. + */ import { afterEach, describe, expect, it } from "vitest"; import { clearFallbackGatewayContext, createGatewaySubagentRuntime } from "./server-plugins.js"; import { installGatewayTestHooks, startServer } from "./test-helpers.server.js"; diff --git a/src/gateway/server-plugins.subagent-ended-hook.test.ts b/src/gateway/server-plugins.subagent-ended-hook.test.ts index 2b50f5db630f..21ead72a99b3 100644 --- a/src/gateway/server-plugins.subagent-ended-hook.test.ts +++ b/src/gateway/server-plugins.subagent-ended-hook.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin hook delivery when subagent sessions end. + */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index b3bcab35e344..be7443f3f0e9 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway config reload handler tests. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ConfigWriteNotification } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 0dad1c989b8f..49a2fd2cf108 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway request context construction tests. + */ import { describe, expect, it, vi } from "vitest"; import type { GatewayServerLiveState } from "./server-live-state.js"; import { diff --git a/src/gateway/server-runtime-handles.ts b/src/gateway/server-runtime-handles.ts index 9cc00900f2a1..8d7d4b53efd2 100644 --- a/src/gateway/server-runtime-handles.ts +++ b/src/gateway/server-runtime-handles.ts @@ -3,10 +3,13 @@ import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach.js"; +// Mutable server handles track timers, sidecars, subscriptions, and service +// cleanup hooks that shutdown/reload code must stop exactly once. type GatewayConfigReloaderHandle = { stop: () => Promise; }; +/** Mutable handles owned by a running gateway server process. */ export type GatewayServerMutableState = { bonjourStop: (() => Promise) | null; tickInterval: ReturnType; @@ -31,6 +34,7 @@ export type GatewayServerMutableState = { lifecycleUnsub: (() => void) | null; }; +/** Creates gateway mutable state with inert handles that are safe to stop before startup finishes. */ export function createGatewayServerMutableState(): GatewayServerMutableState { const noopInterval = () => { const timer = setInterval(() => {}, 1 << 30); diff --git a/src/gateway/server-runtime-service-shared.ts b/src/gateway/server-runtime-service-shared.ts index 83d1296d8285..9d908dbda220 100644 --- a/src/gateway/server-runtime-service-shared.ts +++ b/src/gateway/server-runtime-service-shared.ts @@ -1,6 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; +// Shared runtime service helpers avoid pulling full startup services into tests +// and minimal gateway paths that only need stable service handles. export type GatewayRuntimeServiceLogger = { child: (name: string) => { info: (message: string) => void; @@ -10,6 +12,7 @@ export type GatewayRuntimeServiceLogger = { error: (message: string) => void; }; +/** Creates a heartbeat runner placeholder for minimal/test gateway service state. */ export function createNoopHeartbeatRunner(): HeartbeatRunner { return { stop: () => {}, diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index b836702b8ed3..980be6971fef 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway runtime service lifecycle tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => { diff --git a/src/gateway/server-runtime-startup-services.ts b/src/gateway/server-runtime-startup-services.ts index 496b029aedd5..7732d9cfaad7 100644 --- a/src/gateway/server-runtime-startup-services.ts +++ b/src/gateway/server-runtime-startup-services.ts @@ -6,10 +6,14 @@ import { type GatewayRuntimeServiceLogger, } from "./server-runtime-service-shared.js"; +// Runtime startup services start only the background services needed by the +// current gateway mode. Channel health is configurable; heartbeat/model pricing +// currently use inert handles here and are wired by other startup paths. export type GatewayChannelManager = Parameters< typeof startChannelHealthMonitor >[0]["channelManager"]; +/** Starts channel health monitoring when gateway config enables it. */ export function startGatewayChannelHealthMonitor(params: { cfg: OpenClawConfig; channelManager: GatewayChannelManager; @@ -30,6 +34,7 @@ export function startGatewayChannelHealthMonitor(params: { }); } +/** Starts background runtime services and returns their stop/update handles. */ export function startGatewayRuntimeServices(params: { minimalTestGateway: boolean; cfgAtStart: OpenClawConfig; diff --git a/src/gateway/server-runtime-state.test.ts b/src/gateway/server-runtime-state.test.ts index faea7c0210bf..daa598aeb4c8 100644 --- a/src/gateway/server-runtime-state.test.ts +++ b/src/gateway/server-runtime-state.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway runtime state construction tests. + */ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index d3c7268f4f3c..4dc221e16ff0 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -20,10 +20,16 @@ import { type GatewaySessionRow, } from "./session-utils.js"; +// Session event broadcasting bridges transcript/lifecycle stores to live +// Gateway websocket subscribers. Message updates go to session-specific +// subscribers plus broad session listeners; non-display messages still trigger +// sessions.changed so lists refresh. type SessionEventSubscribers = Pick; type SessionMessageSubscribers = Pick; function resolveSessionMessageBroadcastKeys(sessionKey: string, agentId?: string): string[] { + // Global sessions can be subscribed through either the raw global key or the + // default-agent scoped key; non-default agent global sessions stay scoped. const normalizedAgentId = normalizeOptionalString(agentId); if (sessionKey === "global") { const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())); @@ -51,6 +57,8 @@ function buildGatewaySessionSnapshot(params: { return {}; } const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId; + // The unscoped global row hides goal state to avoid presenting one agent's + // scoped goal as the global/default session goal. const session = params.includeSession ? { ...sessionRow } : undefined; if (session && omitUnscopedGlobalGoal) { delete session.goal; @@ -111,6 +119,7 @@ function buildGatewaySessionSnapshot(params: { }; } +/** Creates a serialized transcript-update broadcaster for session websocket clients. */ export function createTranscriptUpdateBroadcastHandler(params: { broadcastToConnIds: GatewayBroadcastToConnIdsFn; sessionEventSubscribers: SessionEventSubscribers; @@ -118,6 +127,8 @@ export function createTranscriptUpdateBroadcastHandler(params: { }) { let broadcastQueue = Promise.resolve(); return (update: SessionTranscriptUpdate): void => { + // Preserve transcript update order even when counting messages requires an + // async read from the session file. broadcastQueue = broadcastQueue .then(() => handleTranscriptUpdateBroadcast(params, update)) .catch(() => undefined); @@ -158,6 +169,8 @@ async function handleTranscriptUpdateBroadcast( } let messageSeq = asPositiveSafeInteger(update.messageSeq); if (messageSeq === undefined) { + // Updates from raw transcript events may not carry seq; fall back to the + // current transcript line count for cursor-compatible live history. const { entry, storePath } = loadSessionEntry(sessionKey, { agentId: visibleAgentId }); messageSeq = entry?.sessionId ? asPositiveSafeInteger( @@ -195,6 +208,8 @@ async function handleTranscriptUpdateBroadcast( return; } + // Messages suppressed from display can still change transcript state, so + // notify broad session listeners even when no session.message is emitted. const sessionEventConnIds = params.sessionEventSubscribers.getAll(); if (sessionEventConnIds.size === 0) { return; @@ -215,6 +230,7 @@ async function handleTranscriptUpdateBroadcast( ); } +/** Creates a lifecycle-event broadcaster for session list refreshes. */ export function createLifecycleEventBroadcastHandler(params: { broadcastToConnIds: GatewayBroadcastToConnIdsFn; sessionEventSubscribers: SessionEventSubscribers; diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts index 66007142b652..09d16f075c77 100644 --- a/src/gateway/server-session-key.test.ts +++ b/src/gateway/server-session-key.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server session-key routing tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index d61d6751dff8..1e56c9121808 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -19,6 +19,9 @@ import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; const RUN_LOOKUP_CACHE_LIMIT = 256; const RUN_LOOKUP_MISS_TTL_MS = 1_000; +// Run-id to session-key lookup bridges live agent events and persisted session +// stores. Positive hits are stable; misses stay short-lived so late transcript +// writes can become visible without polling on every caller. type RunLookupCacheEntry = { sessionKey: string | null; expiresAt: number | null; @@ -62,6 +65,8 @@ function setResolvedSessionKeyCache( }); } +// Agent scoping accepts global sessions only when global scope is configured, +// and rejects malformed agent-prefixed keys before store normalization. function sessionKeyMatchesAgent(sessionKey: string, agentId: string, cfg: OpenClawConfig): boolean { if (cfg.session?.scope === "global" && sessionKey.trim().toLowerCase() === "global") { return true; @@ -79,6 +84,7 @@ function resolveRunSessionKeyForCaller(storeKey: string) { return toAgentRequestSessionKey(storeKey) ?? storeKey; } +/** Resolves the caller-facing session key for an active or recently persisted run id. */ export function resolveSessionKeyForRun(runId: string, opts: { agentId?: string } = {}) { const cfg = getRuntimeConfig(); const explicitAgentId = @@ -124,6 +130,7 @@ export function resolveSessionKeyForRun(runId: string, opts: { agentId?: string return undefined; } +/** Clears the run lookup cache for tests that mutate session stores. */ export function resetResolvedSessionKeyForRunCacheForTest(): void { resolvedSessionKeyByRunId.clear(); } diff --git a/src/gateway/server-shared.ts b/src/gateway/server-shared.ts index 39b0e6f12eae..5d3f38c36598 100644 --- a/src/gateway/server-shared.ts +++ b/src/gateway/server-shared.ts @@ -1,5 +1,7 @@ import type { ErrorShape } from "../../packages/gateway-protocol/src/index.js"; +// Dedupe entries cache recent request results so repeated gateway calls can +// replay the same success/error payload without re-running the method. export type DedupeEntry = { ts: number; ok: boolean; diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts index 7bc03ed43c83..d4821ab1893b 100644 --- a/src/gateway/server-startup-early.test.ts +++ b/src/gateway/server-startup-early.test.ts @@ -1,3 +1,6 @@ +/** + * Early gateway startup helper tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createGatewayMaintenanceStateForTest } from "./test-helpers.maintenance-state.js"; diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index 91878520ab54..86b525d3d541 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway startup memory-service tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemoryQmdUpdateConfig } from "../config/types.memory.js"; diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 45b994bcdebb..ab205acac15d 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway startup plugin bootstrap tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 79cfb12b2e67..4037a3f6fe20 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway post-attach startup task tests. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server-startup-session-migration.test.ts b/src/gateway/server-startup-session-migration.test.ts index ad40ed8caf7c..fda3c3345169 100644 --- a/src/gateway/server-startup-session-migration.test.ts +++ b/src/gateway/server-startup-session-migration.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway startup session migration tests. + */ import { describe, expect, it, vi } from "vitest"; import { runStartupSessionMigration } from "./server-startup-session-migration.js"; diff --git a/src/gateway/server-startup-web-fetch-bind.test.ts b/src/gateway/server-startup-web-fetch-bind.test.ts index dbc09966eafe..1a8346486af6 100644 --- a/src/gateway/server-startup-web-fetch-bind.test.ts +++ b/src/gateway/server-startup-web-fetch-bind.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway startup web fetch bind tests. + */ import http from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index ef739ee60f4f..1bea7ca44fe7 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway startup orchestration tests. + */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/gateway/server-talk-nodes.test.ts b/src/gateway/server-talk-nodes.test.ts index 752eb12b0d5a..ef3fa38add88 100644 --- a/src/gateway/server-talk-nodes.test.ts +++ b/src/gateway/server-talk-nodes.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway talk node state exposed through server events. + */ import { describe, expect, it } from "vitest"; import type { NodeRegistry, NodeSession } from "./node-registry.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js"; diff --git a/src/gateway/server-talk-nodes.ts b/src/gateway/server-talk-nodes.ts index 8e84b0263b03..0233f0e6e7a3 100644 --- a/src/gateway/server-talk-nodes.ts +++ b/src/gateway/server-talk-nodes.ts @@ -1,9 +1,12 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { NodeRegistry, NodeSession } from "./node-registry.js"; +// Talk node detection accepts either the explicit talk capability or talk.* +// commands so older and newer node clients both enable talk routing. const TALK_CAPABILITY = "talk"; const TALK_COMMAND_PREFIX = "talk."; +/** Returns true when any connected node can handle talk routing. */ export function hasConnectedTalkNode(registry: NodeRegistry): boolean { return registry.listConnected().some(isTalkCapableNode); } diff --git a/src/gateway/server-utils.ts b/src/gateway/server-utils.ts index 4c0fcfbee223..712b8ea7a372 100644 --- a/src/gateway/server-utils.ts +++ b/src/gateway/server-utils.ts @@ -1,6 +1,9 @@ import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; +// Generic server helpers for user-facing trigger normalization and compact +// error formatting in gateway logs/responses. +/** Normalizes voice-wake trigger config with bounded count/length and defaults. */ export function normalizeVoiceWakeTriggers(input: unknown): string[] { const cleaned = normalizeTrimmedStringList(input) .slice(0, 32) @@ -8,6 +11,7 @@ export function normalizeVoiceWakeTriggers(input: unknown): string[] { return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); } +/** Formats unknown gateway errors without throwing on unusual status/code shapes. */ export function formatError(err: unknown): string { if (err instanceof Error) { return err.message; diff --git a/src/gateway/server-wizard-sessions.ts b/src/gateway/server-wizard-sessions.ts index 9b2c3de450b6..4513111f0b18 100644 --- a/src/gateway/server-wizard-sessions.ts +++ b/src/gateway/server-wizard-sessions.ts @@ -1,5 +1,8 @@ import type { WizardSession } from "../wizard/session.js"; +// Wizard session tracker keeps onboarding/setup sessions in memory and only +// purges sessions after they have left the running state. +/** Creates the in-memory tracker used for active Gateway wizard sessions. */ export function createWizardSessionTracker() { const wizardSessions = new Map(); diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 724b659a2273..93b746334e1e 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -4,6 +4,9 @@ import { type AttachGatewayWsConnectionHandlerParams, } from "./server/ws-connection.js"; +// Websocket runtime adapter wires the already-built GatewayRequestContext into +// the lower-level connection handler. This keeps startup context construction +// separate from per-connection websocket plumbing. type GatewayWsRuntimeParams = Omit< AttachGatewayWsConnectionHandlerParams, "buildRequestContext" | "refreshHealthSnapshot" @@ -11,6 +14,7 @@ type GatewayWsRuntimeParams = Omit< context: GatewayRequestContext; }; +/** Attaches websocket handlers for an already-created gateway request context. */ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) { attachGatewayWsConnectionHandler({ wss: params.wss, diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index c7adb3daf913..dbd864525746 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server-agent integration tests for agent startup and session dispatch. + */ import fs from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index dbb0ead956f3..d193a5027ce0 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -1,3 +1,6 @@ +/** + * Shared plugin-registry mock used by gateway server-agent tests. + */ import { vi } from "vitest"; import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry as setActivePluginRegistryLocal } from "../plugins/runtime.js"; @@ -7,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = { registry: createEmptyPluginRegistry(), }; +/** Installs the supplied registry into both gateway test and plugin runtime globals. */ export function setRegistry(registry: PluginRegistry) { registryState.registry = registry; setTestPluginRegistry(registry); diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index fedfef386423..41a4e625056b 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway auth compatibility baseline tests. + */ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; diff --git a/src/gateway/server.auth.control-ui.test.ts b/src/gateway/server.auth.control-ui.test.ts index 89060b3223f3..3a24930873d3 100644 --- a/src/gateway/server.auth.control-ui.test.ts +++ b/src/gateway/server.auth.control-ui.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway Control UI auth pairing tests. + */ import { describe } from "vitest"; import { registerControlUiAndPairingSuite } from "./server.auth.control-ui.suite.js"; import { installGatewayTestHooks } from "./server.auth.shared.js"; diff --git a/src/gateway/server.auth.default-token.test.ts b/src/gateway/server.auth.default-token.test.ts index e22cc79502c7..0a19646a6b5e 100644 --- a/src/gateway/server.auth.default-token.test.ts +++ b/src/gateway/server.auth.default-token.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway default auth-token tests. + */ import { describe } from "vitest"; import { registerDefaultAuthTokenSuite } from "./server.auth.default-token.suite.js"; import { installGatewayTestHooks } from "./server.auth.shared.js"; diff --git a/src/gateway/server.auth.modes.test.ts b/src/gateway/server.auth.modes.test.ts index 0b8ca52414d6..3f62acd9f2a1 100644 --- a/src/gateway/server.auth.modes.test.ts +++ b/src/gateway/server.auth.modes.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway auth mode matrix tests. + */ import { describe } from "vitest"; import { registerAuthModesSuite } from "./server.auth.modes.suite.js"; import { installGatewayTestHooks } from "./server.auth.shared.js"; diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts index 38e82095aa90..ebdccafbe419 100644 --- a/src/gateway/server.channels.test.ts +++ b/src/gateway/server.channels.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server channel RPC tests. + */ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js"; diff --git a/src/gateway/server.device-pair-approve-supersede.test.ts b/src/gateway/server.device-pair-approve-supersede.test.ts index fef29daa6ff5..26d5a43d6f90 100644 --- a/src/gateway/server.device-pair-approve-supersede.test.ts +++ b/src/gateway/server.device-pair-approve-supersede.test.ts @@ -1,3 +1,6 @@ +/** + * Tests device-pair approval superseding behavior in the gateway server. + */ import { describe, expect, test } from "vitest"; import { approveDevicePairing, diff --git a/src/gateway/server.e2e-registry-helpers.ts b/src/gateway/server.e2e-registry-helpers.ts index 168b88b2ce50..5c02af4bd514 100644 --- a/src/gateway/server.e2e-registry-helpers.ts +++ b/src/gateway/server.e2e-registry-helpers.ts @@ -1 +1,4 @@ +/** + * Re-exported channel-plugin registry builder for gateway E2E tests. + */ export { createTestRegistry as createRegistry } from "../test-utils/channel-plugins.js"; diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index da0e2fd23534..892a76f727de 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway health endpoint integration tests. + */ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; diff --git a/src/gateway/server.ios-client-id.test.ts b/src/gateway/server.ios-client-id.test.ts index 2505aeb83737..f2074300f1d2 100644 --- a/src/gateway/server.ios-client-id.test.ts +++ b/src/gateway/server.ios-client-id.test.ts @@ -1,3 +1,6 @@ +/** + * iOS gateway client-id classification tests. + */ import { describe, expect, test } from "vitest"; import { GATEWAY_CLIENT_IDS, diff --git a/src/gateway/server.lazy.test.ts b/src/gateway/server.lazy.test.ts index 8395cb0d7975..b36ef0a0dbe5 100644 --- a/src/gateway/server.lazy.test.ts +++ b/src/gateway/server.lazy.test.ts @@ -1,3 +1,6 @@ +/** + * Lazy gateway server entrypoint tests. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const originalTrace = process.env.OPENCLAW_GATEWAY_STARTUP_TRACE; diff --git a/src/gateway/server.minimal-channel-pin.test.ts b/src/gateway/server.minimal-channel-pin.test.ts index 567fab3df368..0b7386b7b8c8 100644 --- a/src/gateway/server.minimal-channel-pin.test.ts +++ b/src/gateway/server.minimal-channel-pin.test.ts @@ -1,3 +1,6 @@ +/** + * Minimal channel pinning regression tests. + */ import { afterEach, expect, test } from "vitest"; import { getChannelPlugin } from "../channels/plugins/index.js"; import { diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index e5dae78762e2..42475098b9f7 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway pre-auth hardening tests. + */ import { writeFile } from "node:fs/promises"; import http from "node:http"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index 6ec824c6a6e6..8cbbe98e94ce 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway session compaction RPC tests. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 8d793714c13e..2203076d984c 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway sessions.list changed-state tests. + */ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test, vi } from "vitest"; diff --git a/src/gateway/server.sessions.preview-resolve.test.ts b/src/gateway/server.sessions.preview-resolve.test.ts index 86adbffe6222..06e5e29372f5 100644 --- a/src/gateway/server.sessions.preview-resolve.test.ts +++ b/src/gateway/server.sessions.preview-resolve.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway session preview resolve tests. + */ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index 66b4e04ba226..b9cd20522fb5 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway session reset model-selection tests. + */ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 863d92c2e654..38d20d5fc01c 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway session store RPC tests. + */ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test, vi } from "vitest"; diff --git a/src/gateway/server.shared-token-hot-reload.test.ts b/src/gateway/server.shared-token-hot-reload.test.ts index 97f144b44bf2..9b87977b0bfe 100644 --- a/src/gateway/server.shared-token-hot-reload.test.ts +++ b/src/gateway/server.shared-token-hot-reload.test.ts @@ -1,3 +1,6 @@ +/** + * Shared gateway-token hot-reload tests. + */ import fs from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { openAuthenticatedGatewayWs, waitForGatewayWsClose } from "./shared-auth.test-helpers.js"; diff --git a/src/gateway/server.shared-token-session-rotation.test.ts b/src/gateway/server.shared-token-session-rotation.test.ts index 60ee6175ef2c..851c6e9535b8 100644 --- a/src/gateway/server.shared-token-session-rotation.test.ts +++ b/src/gateway/server.shared-token-session-rotation.test.ts @@ -1,3 +1,6 @@ +/** + * Shared gateway-token session rotation tests. + */ import fs from "node:fs/promises"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { diff --git a/src/gateway/server.talk-runtime.test.ts b/src/gateway/server.talk-runtime.test.ts index 8a9734c2da73..345e5a91ff4a 100644 --- a/src/gateway/server.talk-runtime.test.ts +++ b/src/gateway/server.talk-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway talk runtime wiring for speech provider execution. + */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { invokeTalkSpeakDirect, diff --git a/src/gateway/server.tools-catalog.test.ts b/src/gateway/server.tools-catalog.test.ts index f7c6fca88305..34da212d168c 100644 --- a/src/gateway/server.tools-catalog.test.ts +++ b/src/gateway/server.tools-catalog.test.ts @@ -1,3 +1,6 @@ +/** + * Tests server-level tool catalog assembly and filtering. + */ import { describe, expect, it } from "vitest"; import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js"; import { withServer } from "./test-with-server.js"; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 316db9bb5604..b8568d5d77cc 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1,3 +1,9 @@ +/** + * Lazy public entrypoint for the gateway server implementation. + * + * Keeping `server.impl` behind dynamic import lets light-weight callers import + * server types and helpers without paying the full startup dependency graph. + */ export { truncateCloseReason } from "./server/close-reason.js"; export type { GatewayServer, GatewayServerOptions } from "./server.impl.js"; @@ -21,6 +27,7 @@ async function loadServerImpl() { } } +/** Starts the gateway server after lazily loading the full server implementation. */ export async function startGatewayServer( ...args: Parameters ): ReturnType { @@ -28,6 +35,7 @@ export async function startGatewayServer( return await mod.startGatewayServer(...args); } +/** Clears the server implementation's model-catalog cache between tests. */ export async function resetModelCatalogCacheForTest(): Promise { const mod = await loadServerImpl(); await mod.resetModelCatalogCacheForTest(); diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index 478d5dda6968..d16ce731228c 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -1,5 +1,8 @@ import { createEmptyPluginRegistry, type PluginRegistry } from "../../../plugins/registry.js"; +/** + * Shared plugin-registry fixtures for gateway server tests. + */ export const createTestRegistry = (overrides: Partial = {}): PluginRegistry => { const merged = { ...createEmptyPluginRegistry(), ...overrides }; return { diff --git a/src/gateway/server/close-reason.ts b/src/gateway/server/close-reason.ts index e530c07aae24..b9148f512842 100644 --- a/src/gateway/server/close-reason.ts +++ b/src/gateway/server/close-reason.ts @@ -1,7 +1,11 @@ import { Buffer } from "node:buffer"; +/** + * WebSocket close reason utilities. + */ const CLOSE_REASON_MAX_BYTES = 120; +/** Truncates close reasons to the RFC-safe byte limit used during handshake failures. */ export function truncateCloseReason(reason: string, maxBytes = CLOSE_REASON_MAX_BYTES): string { if (!reason) { return "invalid handshake"; diff --git a/src/gateway/server/event-loop-health.test.ts b/src/gateway/server/event-loop-health.test.ts index d8e44e2d203f..ed79e106f4e3 100644 --- a/src/gateway/server/event-loop-health.test.ts +++ b/src/gateway/server/event-loop-health.test.ts @@ -5,6 +5,9 @@ import { createGatewayEventLoopHealthMonitor, } from "./event-loop-health.js"; +/** + * Event-loop health regression tests for delay, CPU, and utilization signals. + */ type CpuUsage = ReturnType; type DelayMonitor = ReturnType; type EventLoopUtilization = ReturnType; diff --git a/src/gateway/server/health-state.test.ts b/src/gateway/server/health-state.test.ts index d23980708504..6d635e9cda4b 100644 --- a/src/gateway/server/health-state.test.ts +++ b/src/gateway/server/health-state.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HealthSummary } from "../../commands/health.js"; +/** + * Health-state cache tests covering coalescing, sensitive probes, and broadcasts. + */ const getHealthSnapshotMock = vi.hoisted(() => vi.fn()); vi.mock("../../commands/health.js", () => ({ diff --git a/src/gateway/server/hook-client-ip-config.ts b/src/gateway/server/hook-client-ip-config.ts index 6856caf0da11..5c78be1c9846 100644 --- a/src/gateway/server/hook-client-ip-config.ts +++ b/src/gateway/server/hook-client-ip-config.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { HookClientIpConfig } from "./hooks-request-handler.js"; +/** + * Adapts gateway network trust config to the hooks HTTP request handler. + */ export function resolveHookClientIpConfig(cfg: OpenClawConfig): HookClientIpConfig { return { trustedProxies: cfg.gateway?.trustedProxies, diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index 84be3400d96d..183124bfb4ed 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -1,3 +1,6 @@ +/** + * Hook endpoint trust tests for agent dispatch and gateway network config. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const enqueueSystemEventMock = vi.fn(); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 5b3459ca47b9..943753a23abd 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -21,6 +21,12 @@ import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { HookAgentDispatchPayload, HooksConfigResolved } from "../hooks.js"; import { createHooksRequestHandler, type HookClientIpConfig } from "./hooks-request-handler.js"; +/** + * Gateway hook HTTP handler factory. + * + * Hooks can either enqueue wake events or spawn isolated agent turns; both paths + * sanitize external input before it reaches logs or system-event text. + */ type SubsystemLogger = ReturnType; function resolveHookEventSessionKey(params: { cfg: OpenClawConfig; agentId?: string }): string { @@ -84,6 +90,7 @@ function formatHookRunWarningConsoleMessage(params: { return parts.join(" "); } +/** Creates the HTTP handler used by gateway hook endpoints. */ export function createGatewayHooksRequestHandler(params: { deps: CliDeps; getHooksConfig: () => HooksConfigResolved | null; @@ -143,6 +150,8 @@ export function createGatewayHooksRequestHandler(params: { let hookEventSessionKey: string | undefined; void (async () => { try { + // Agent hooks run after the HTTP response path has returned, so failure + // handling must record a system event instead of throwing to the caller. const cfg = getRuntimeConfig(); hookEventSessionKey = resolveHookEventSessionKey({ cfg, diff --git a/src/gateway/server/http-listen.test.ts b/src/gateway/server/http-listen.test.ts index 6d464f15107b..27f02db8b63d 100644 --- a/src/gateway/server/http-listen.test.ts +++ b/src/gateway/server/http-listen.test.ts @@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import { listenGatewayHttpServer } from "./http-listen.js"; +/** + * Gateway HTTP listener retry tests for lock contention and listen failures. + */ const sleepMock = vi.hoisted(() => vi.fn(async (_ms: number) => {})); vi.mock("../../utils.js", () => ({ diff --git a/src/gateway/server/plugin-node-capability-auth.ts b/src/gateway/server/plugin-node-capability-auth.ts index 7b74d7edd511..ae75fa3b175b 100644 --- a/src/gateway/server/plugin-node-capability-auth.ts +++ b/src/gateway/server/plugin-node-capability-auth.ts @@ -12,6 +12,9 @@ import { } from "../plugin-node-capability.js"; import type { GatewayWsClient } from "./ws-types.js"; +/** + * Authorizes plugin HTTP routes that can be reached by node-issued capabilities. + */ export async function authorizePluginNodeCapabilityRequest(params: { req: IncomingMessage; auth: ResolvedGatewayAuth; @@ -41,6 +44,8 @@ export async function authorizePluginNodeCapabilityRequest(params: { let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); if (token) { + // Bearer gateway auth wins when present; capability auth is only a fallback + // for nodes that already proved ownership over the route surface. const authResult = await authorizeHttpGatewayConnect({ auth: { ...auth, allowTailscale: false }, connectAuth: { token, password: token }, diff --git a/src/gateway/server/plugin-route-runtime-scopes.test.ts b/src/gateway/server/plugin-route-runtime-scopes.test.ts index ba153da0a54c..2e6369d4c4e1 100644 --- a/src/gateway/server/plugin-route-runtime-scopes.test.ts +++ b/src/gateway/server/plugin-route-runtime-scopes.test.ts @@ -1,3 +1,6 @@ +/** + * Plugin route runtime-scope regression tests for trusted-proxy headers. + */ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; import { resolvePluginRouteRuntimeOperatorScopes } from "./plugin-route-runtime-scopes.js"; diff --git a/src/gateway/server/plugin-route-runtime-scopes.ts b/src/gateway/server/plugin-route-runtime-scopes.ts index 89df1646607f..b208a05e3b7e 100644 --- a/src/gateway/server/plugin-route-runtime-scopes.ts +++ b/src/gateway/server/plugin-route-runtime-scopes.ts @@ -6,8 +6,12 @@ import { } from "../http-auth-utils.js"; import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js"; +/** + * Runtime operator-scope resolver for plugin HTTP route requests. + */ export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator"; +/** Resolves the scopes a plugin route receives after gateway HTTP authentication. */ export function resolvePluginRouteRuntimeOperatorScopes( req: IncomingMessage, requestAuth: AuthorizedGatewayHttpRequest, @@ -23,6 +27,8 @@ export function resolvePluginRouteRuntimeOperatorScopes( return [WRITE_SCOPE]; } if (getHeader(req, "x-openclaw-scopes") === undefined) { + // Trusted-proxy callers without an explicit scope header keep the legacy + // write-default surface instead of inheriting every CLI operator scope. return [WRITE_SCOPE]; } return resolveTrustedHttpOperatorScopes(req, requestAuth); diff --git a/src/gateway/server/plugins-http.runtime-scopes.test.ts b/src/gateway/server/plugins-http.runtime-scopes.test.ts index 5921792de43f..e4d0efb40266 100644 --- a/src/gateway/server/plugins-http.runtime-scopes.test.ts +++ b/src/gateway/server/plugins-http.runtime-scopes.test.ts @@ -1,3 +1,6 @@ +/** + * Plugin HTTP runtime-scope integration tests. + */ import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SubsystemLogger } from "../../logging/subsystem.js"; diff --git a/src/gateway/server/plugins-http/path-context.ts b/src/gateway/server/plugins-http/path-context.ts index 2f2271dfef89..69b077915044 100644 --- a/src/gateway/server/plugins-http/path-context.ts +++ b/src/gateway/server/plugins-http/path-context.ts @@ -4,6 +4,9 @@ import { canonicalizePathForSecurity, } from "../../security-path.js"; +/** + * Canonical path context for plugin HTTP route auth and matching. + */ export type PluginRoutePathContext = { pathname: string; canonicalPath: string; @@ -21,6 +24,7 @@ function normalizeProtectedPrefix(prefix: string): string { return collapsed.replace(/\/+$/, ""); } +/** Matches a normalized path against an exact protected prefix boundary. */ export function prefixMatchPath(pathname: string, prefix: string): boolean { return ( pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`) @@ -30,6 +34,7 @@ export function prefixMatchPath(pathname: string, prefix: string): boolean { const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES = PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix); +/** Returns true when any decoded path candidate targets a protected route. */ export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathContext): boolean { if ( context.candidates.some((candidate) => @@ -48,6 +53,7 @@ export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathCo ); } +/** Builds all security-relevant decoded path candidates for a request path. */ export function resolvePluginRoutePathContext(pathname: string): PluginRoutePathContext { const canonical = canonicalizePathForSecurity(pathname); return { diff --git a/src/gateway/server/plugins-http/route-auth.ts b/src/gateway/server/plugins-http/route-auth.ts index 577a0babdfbd..306cbb699dc0 100644 --- a/src/gateway/server/plugins-http/route-auth.ts +++ b/src/gateway/server/plugins-http/route-auth.ts @@ -6,12 +6,16 @@ import { } from "./path-context.js"; import { findMatchingPluginHttpRoutes } from "./route-match.js"; +/** + * Gateway-auth decisions for plugin HTTP routes. + */ export function matchedPluginRoutesRequireGatewayAuth( routes: readonly Pick[number], "auth">[], ): boolean { return routes.some((route) => route.auth === "gateway"); } +/** Returns true when a plugin path must pass gateway auth before routing. */ export function shouldEnforceGatewayAuthForPluginPath( registry: PluginRegistry, pathnameOrContext: string | PluginRoutePathContext, diff --git a/src/gateway/server/plugins-http/route-capability.test.ts b/src/gateway/server/plugins-http/route-capability.test.ts index ba0637ee4fb1..39a245fb80ce 100644 --- a/src/gateway/server/plugins-http/route-capability.test.ts +++ b/src/gateway/server/plugins-http/route-capability.test.ts @@ -1,3 +1,6 @@ +/** + * Plugin node-capability route matching and surface listing tests. + */ import { describe, expect, it } from "vitest"; import type { PluginRegistry } from "../../../plugins/registry.js"; import { resolvePluginRoutePathContext } from "./path-context.js"; diff --git a/src/gateway/server/plugins-http/route-capability.ts b/src/gateway/server/plugins-http/route-capability.ts index 5f7b4b561f81..1e8594c55ab4 100644 --- a/src/gateway/server/plugins-http/route-capability.ts +++ b/src/gateway/server/plugins-http/route-capability.ts @@ -6,8 +6,12 @@ import { import type { PluginRoutePathContext } from "./path-context.js"; import { findMatchingPluginHttpRoutes } from "./route-match.js"; +/** + * Node-capability route discovery for plugin HTTP routes. + */ type PluginHttpRouteEntry = NonNullable[number]; +/** Registered plugin route enriched with its node capability surface. */ export type PluginNodeCapabilityRoute = PluginHttpRouteEntry & { nodeCapability: PluginNodeCapabilitySurface; }; @@ -28,6 +32,7 @@ function resolvePluginNodeCapabilityRouteSurface( }; } +/** Lists all node-capability routes matching the already canonicalized path context. */ export function findMatchingPluginNodeCapabilityRoutes( registry: PluginRegistry, context: PluginRoutePathContext, @@ -41,6 +46,7 @@ export function findMatchingPluginNodeCapabilityRoutes( ); } +/** Returns the highest-priority node-capability route for a plugin HTTP path. */ export function findMatchingPluginNodeCapabilityRoute( registry: PluginRegistry, context: PluginRoutePathContext, @@ -48,10 +54,12 @@ export function findMatchingPluginNodeCapabilityRoute( return findMatchingPluginNodeCapabilityRoutes(registry, context)[0]; } +/** Lists node-capability surface names advertised by the active plugin registry. */ export function listPluginNodeCapabilitySurfaces(registry: PluginRegistry): string[] { return listPluginNodeCapabilities(registry).map((entry) => entry.surface); } +/** Lists unique node-capability surfaces, preferring the shortest TTL per surface. */ export function listPluginNodeCapabilities( registry: PluginRegistry, ): PluginNodeCapabilitySurface[] { diff --git a/src/gateway/server/plugins-http/route-match.ts b/src/gateway/server/plugins-http/route-match.ts index bab082c813e1..32b1723b882a 100644 --- a/src/gateway/server/plugins-http/route-match.ts +++ b/src/gateway/server/plugins-http/route-match.ts @@ -6,8 +6,12 @@ import { type PluginRoutePathContext, } from "./path-context.js"; +/** + * Plugin HTTP route matching against canonicalized request paths. + */ type PluginHttpRouteEntry = NonNullable[number]; +/** Returns true when a registered route matches any canonical request candidate. */ export function doesPluginRouteMatchPath( route: PluginHttpRouteEntry, context: PluginRoutePathContext, @@ -19,6 +23,7 @@ export function doesPluginRouteMatchPath( return context.candidates.some((candidate) => candidate === routeCanonicalPath); } +/** Finds matching plugin routes with exact matches ordered before prefix matches. */ export function findMatchingPluginHttpRoutes( registry: PluginRegistry, context: PluginRoutePathContext, @@ -44,6 +49,7 @@ export function findMatchingPluginHttpRoutes( return [...exactMatches, ...prefixMatches]; } +/** Returns the first registered plugin HTTP route for a raw request path. */ export function findRegisteredPluginHttpRoute( registry: PluginRegistry, pathname: string, @@ -52,6 +58,7 @@ export function findRegisteredPluginHttpRoute( return findMatchingPluginHttpRoutes(registry, pathContext)[0]; } +/** Convenience predicate for checking whether a raw path is a plugin HTTP route. */ export function isRegisteredPluginHttpRoutePath( registry: PluginRegistry, pathname: string, diff --git a/src/gateway/server/preauth-connection-budget.test.ts b/src/gateway/server/preauth-connection-budget.test.ts index 33f5c710b18d..58ea2454e651 100644 --- a/src/gateway/server/preauth-connection-budget.test.ts +++ b/src/gateway/server/preauth-connection-budget.test.ts @@ -1,3 +1,6 @@ +/** + * Pre-auth WebSocket connection-budget regression tests. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { createPreauthConnectionBudget } from "./preauth-connection-budget.js"; diff --git a/src/gateway/server/presence-events.test.ts b/src/gateway/server/presence-events.test.ts index 9c847e940e43..d319d8537670 100644 --- a/src/gateway/server/presence-events.test.ts +++ b/src/gateway/server/presence-events.test.ts @@ -1,3 +1,6 @@ +/** + * Presence broadcast tests for versioned gateway state updates. + */ import { describe, expect, it, vi } from "vitest"; import { broadcastPresenceSnapshot } from "./presence-events.js"; diff --git a/src/gateway/server/presence-events.ts b/src/gateway/server/presence-events.ts index bc0bf711def8..baf7e85cef2c 100644 --- a/src/gateway/server/presence-events.ts +++ b/src/gateway/server/presence-events.ts @@ -1,6 +1,9 @@ import { listSystemPresence } from "../../infra/system-presence.js"; import type { GatewayBroadcastFn } from "../server-broadcast-types.js"; +/** + * Presence snapshot broadcaster for gateway clients. + */ export function broadcastPresenceSnapshot(params: { broadcast: GatewayBroadcastFn; incrementPresenceVersion: () => number; diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts index c88556350cad..0c4099605444 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -4,6 +4,9 @@ import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; import type { ChannelManager, ChannelRuntimeSnapshot } from "../server-channels.js"; import { createReadinessChecker } from "./readiness.js"; +/** + * Readiness checker tests for startup grace, channel health, and stale sockets. + */ const FIVE_MIN_MS = 5 * 60_000; const THIRTY_ONE_MIN_MS = 31 * 60_000; diff --git a/src/gateway/server/tls.ts b/src/gateway/server/tls.ts index b7b5388b5040..f717585b0323 100644 --- a/src/gateway/server/tls.ts +++ b/src/gateway/server/tls.ts @@ -4,8 +4,12 @@ import { loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig, } from "../../infra/tls/gateway.js"; +/** + * Gateway TLS runtime loader boundary. + */ export type { GatewayTlsRuntime } from "../../infra/tls/gateway.js"; +/** Loads certificate/key material for the gateway listener from config. */ export async function loadGatewayTlsRuntime( cfg: GatewayTlsConfig | undefined, log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, diff --git a/src/gateway/server/ws-connection.startup.test.ts b/src/gateway/server/ws-connection.startup.test.ts index 1e23c0d9de9c..89752044cdba 100644 --- a/src/gateway/server/ws-connection.startup.test.ts +++ b/src/gateway/server/ws-connection.startup.test.ts @@ -1,3 +1,6 @@ +/** + * WebSocket connection startup regression tests. + */ import { describe, expect, it, vi } from "vitest"; import { GATEWAY_CLIENT_MODES, diff --git a/src/gateway/server/ws-connection.test-helpers.ts b/src/gateway/server/ws-connection.test-helpers.ts index b7ebb18213cb..b744af591b98 100644 --- a/src/gateway/server/ws-connection.test-helpers.ts +++ b/src/gateway/server/ws-connection.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Shared WebSocket connection fixtures for gateway server tests. + */ import { EventEmitter } from "node:events"; import { expect, vi } from "vitest"; import type { WebSocketServer } from "ws"; diff --git a/src/gateway/server/ws-connection/auth-messages.test.ts b/src/gateway/server/ws-connection/auth-messages.test.ts index a06fd2e76ec6..7df17d369ae1 100644 --- a/src/gateway/server/ws-connection/auth-messages.test.ts +++ b/src/gateway/server/ws-connection/auth-messages.test.ts @@ -1,3 +1,6 @@ +/** + * WebSocket authentication message regression tests. + */ import { describe, expect, it } from "vitest"; import { formatGatewayAuthFailureMessage } from "./auth-messages.js"; diff --git a/src/gateway/server/ws-connection/auth-messages.ts b/src/gateway/server/ws-connection/auth-messages.ts index f18762655854..5f0f29777a5a 100644 --- a/src/gateway/server/ws-connection/auth-messages.ts +++ b/src/gateway/server/ws-connection/auth-messages.ts @@ -5,8 +5,12 @@ import { } from "../../../utils/message-channel.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; +/** + * Human-readable WebSocket auth failure messages for CLI, UI, and webchat clients. + */ export type AuthProvidedKind = "token" | "bootstrap-token" | "device-token" | "password" | "none"; +/** Formats a client-specific auth failure message without exposing secret values. */ export function formatGatewayAuthFailureMessage(params: { authMode: ResolvedGatewayAuth["mode"]; authProvided: AuthProvidedKind; diff --git a/src/gateway/server/ws-connection/handshake-auth-log-limiter.test.ts b/src/gateway/server/ws-connection/handshake-auth-log-limiter.test.ts index b03820843a4f..2b7ac4263611 100644 --- a/src/gateway/server/ws-connection/handshake-auth-log-limiter.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-log-limiter.test.ts @@ -1,3 +1,6 @@ +/** + * WebSocket handshake auth log limiter tests. + */ import { describe, expect, it } from "vitest"; import { buildHandshakeAuthLogKey, diff --git a/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts b/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts index dd2dc563198b..8b8086d37bcc 100644 --- a/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts +++ b/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts @@ -1,3 +1,6 @@ +/** + * Unauthorized-role flood guard tests for logging and socket close decisions. + */ import { describe, expect, it } from "vitest"; import { ErrorCodes, errorShape } from "../../../../packages/gateway-protocol/src/index.js"; import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; diff --git a/src/gateway/server/ws-connection/unauthorized-flood-guard.ts b/src/gateway/server/ws-connection/unauthorized-flood-guard.ts index 1f66f569ad24..66fcb273ab2d 100644 --- a/src/gateway/server/ws-connection/unauthorized-flood-guard.ts +++ b/src/gateway/server/ws-connection/unauthorized-flood-guard.ts @@ -1,11 +1,15 @@ import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { ErrorCodes, type ErrorShape } from "../../../../packages/gateway-protocol/src/index.js"; +/** + * Per-connection guard that suppresses noisy unauthorized-role retries. + */ export type UnauthorizedFloodGuardOptions = { closeAfter?: number; logEvery?: number; }; +/** Decision returned after recording one unauthorized role failure. */ export type UnauthorizedFloodDecision = { shouldClose: boolean; shouldLog: boolean; @@ -16,6 +20,7 @@ export type UnauthorizedFloodDecision = { const DEFAULT_CLOSE_AFTER = 10; const DEFAULT_LOG_EVERY = 100; +/** Counts unauthorized failures and decides when to log or close the socket. */ export class UnauthorizedFloodGuard { private readonly closeAfter: number; private readonly logEvery: number; @@ -58,6 +63,7 @@ export class UnauthorizedFloodGuard { } } +/** Identifies role-auth failures that should feed the flood guard. */ export function isUnauthorizedRoleError(error?: ErrorShape): boolean { if (!error) { return false; diff --git a/src/gateway/server/ws-shared-generation.test.ts b/src/gateway/server/ws-shared-generation.test.ts index 542d6e924f88..e17333a2cc79 100644 --- a/src/gateway/server/ws-shared-generation.test.ts +++ b/src/gateway/server/ws-shared-generation.test.ts @@ -1,3 +1,6 @@ +/** + * Shared gateway-auth generation tests for WebSocket sessions. + */ import { describe, expect, it } from "vitest"; import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js"; diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index 62454a701926..2cd7fa68cabe 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -2,6 +2,9 @@ import type { WebSocket } from "ws"; import type { ConnectParams } from "../../../packages/gateway-protocol/src/index.js"; import type { PluginNodeCapabilityClient } from "../plugin-node-capability.js"; +/** + * Runtime WebSocket client state tracked by the gateway server. + */ export type GatewayWsClient = PluginNodeCapabilityClient & { socket: WebSocket; connect: ConnectParams; diff --git a/src/gateway/session-archive.fs.ts b/src/gateway/session-archive.fs.ts index 5c878b9bda20..0a6e474e91a8 100644 --- a/src/gateway/session-archive.fs.ts +++ b/src/gateway/session-archive.fs.ts @@ -1,3 +1,5 @@ +// Filesystem-backed session archive barrel. Gateway code imports this narrow +// surface instead of the transcript file module directly. export { archiveFileOnDisk, archiveSessionTranscriptsDetailed, diff --git a/src/gateway/session-archive.imports.test.ts b/src/gateway/session-archive.imports.test.ts index d2b15ddd0dba..c11c2e1cc170 100644 --- a/src/gateway/session-archive.imports.test.ts +++ b/src/gateway/session-archive.imports.test.ts @@ -1,3 +1,6 @@ +/** + * Session archive import-boundary tests. + */ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { beforeAll, describe, expect, it, vi } from "vitest"; diff --git a/src/gateway/session-archive.runtime.ts b/src/gateway/session-archive.runtime.ts index feb7059bb66e..2d12a0fa25ae 100644 --- a/src/gateway/session-archive.runtime.ts +++ b/src/gateway/session-archive.runtime.ts @@ -1,3 +1,4 @@ +// Runtime archive barrel used by lazy session archive paths. export { archiveSessionTranscriptsDetailed, archiveSessionTranscripts, diff --git a/src/gateway/session-child-sessions.test.ts b/src/gateway/session-child-sessions.test.ts index 7f8bd984fcfa..d4f1310fffc2 100644 --- a/src/gateway/session-child-sessions.test.ts +++ b/src/gateway/session-child-sessions.test.ts @@ -1,3 +1,6 @@ +/** + * Child-session aggregation tests. + */ import { expect, test, vi } from "vitest"; import { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; import type { SessionEntry } from "../config/sessions/types.js"; diff --git a/src/gateway/session-child-sessions.ts b/src/gateway/session-child-sessions.ts index 5ac3a937c129..a2aed94f634c 100644 --- a/src/gateway/session-child-sessions.ts +++ b/src/gateway/session-child-sessions.ts @@ -3,11 +3,15 @@ import { loadCombinedSessionStoreForGateway } from "../config/sessions/combined- import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Child-session discovery reads the combined gateway session store and matches +// both legacy spawnedBy and newer parentSessionKey relationships. +/** Direct child session entry returned for parent session lookups. */ export type DirectChildSessionEntry = { sessionKey: string; entry: SessionEntry; }; +/** Returns true when a session store row is a direct child of the parent key. */ export function isDirectChildSessionEntry(params: { sessionKey: string; entry: SessionEntry | undefined; @@ -23,6 +27,7 @@ export function isDirectChildSessionEntry(params: { ); } +/** Finds direct child sessions for a parent session across the combined gateway store. */ export function findDirectChildSessionsForParent(params: { cfg: OpenClawConfig; parentKey: string; diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 4bc197634b43..8135badce439 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -1,3 +1,6 @@ +/** + * Session compaction checkpoint persistence tests. + */ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index 044b4f902096..b0659867c091 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -1,3 +1,6 @@ +/** + * Session history state hashing and metadata tests. + */ import { createHash } from "node:crypto"; import { describe, expect, test, vi } from "vitest"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 1450a88f3d9c..3e8f5b4997dc 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -9,6 +9,9 @@ import { readSessionMessagesAsync, } from "./session-utils.js"; +// Session history state owns the SSE-friendly projection of transcript JSONL: +// raw messages are projected for display, paginated by transcript seq, then +// incrementally updated until cursor/window semantics require a full refresh. type SessionHistoryTranscriptMeta = { seq?: number; }; @@ -47,6 +50,7 @@ type SessionHistoryRawSnapshot = { totalRawMessages?: number; }; +/** Computes an oversized raw transcript tail window for projected chat history. */ export function resolveSessionHistoryTailReadOptions(limit: number): { maxMessages: number; maxLines: number; @@ -128,6 +132,7 @@ function paginateSessionMessages( }); } +/** Builds the display history snapshot and raw transcript sequence watermark. */ export function buildSessionHistorySnapshot(params: { rawMessages: unknown[]; maxChars?: number; @@ -164,6 +169,7 @@ export function buildSessionHistorySnapshot(params: { }; } +/** Tracks session-history SSE state and decides when inline appends are still valid. */ export class SessionHistorySseState { private readonly target: SessionHistoryTranscriptTarget; private readonly maxChars: number; @@ -243,6 +249,9 @@ export class SessionHistorySseState { ...(typeof update.messageId === "string" ? { id: update.messageId } : {}), seq: this.rawTranscriptSeq, }); + // Projection can split, drop, or rewrite raw transcript messages. When one + // raw append changes multiple visible rows, callers must refresh instead of + // emitting a misleading single SSE item. const projectedMessages = toSessionHistoryMessages( projectChatDisplayMessages([...this.sentHistory.messages, nextMessage], { maxChars: this.maxChars, diff --git a/src/gateway/session-lifecycle-state.test.ts b/src/gateway/session-lifecycle-state.test.ts index 58cb68f45ec1..41a5163720df 100644 --- a/src/gateway/session-lifecycle-state.test.ts +++ b/src/gateway/session-lifecycle-state.test.ts @@ -1,3 +1,6 @@ +/** + * Session lifecycle state derivation tests. + */ import { describe, expect, it } from "vitest"; import { deriveGatewaySessionLifecycleSnapshot, diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index f0a62725da46..2d15383f12d4 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -1,3 +1,6 @@ +/** + * Session message event indexing and broadcast tests. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/session-patch-hooks.ts b/src/gateway/session-patch-hooks.ts index 37e7872c90a6..22241f72db62 100644 --- a/src/gateway/session-patch-hooks.ts +++ b/src/gateway/session-patch-hooks.ts @@ -8,6 +8,9 @@ import { type SessionPatchHookEvent, } from "../hooks/internal-hooks.js"; +// Session patch hooks are fire-and-forget internal hooks. The context is cloned +// so hook listeners cannot mutate the live session entry or patch object. +/** Triggers internal session patch hooks when listeners are registered. */ export function triggerSessionPatchHook(params: { cfg: OpenClawConfig; sessionEntry: SessionEntry; diff --git a/src/gateway/session-preview.test-helpers.ts b/src/gateway/session-preview.test-helpers.ts index a7461c54f528..f1c6ce0f17a5 100644 --- a/src/gateway/session-preview.test-helpers.ts +++ b/src/gateway/session-preview.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Transcript preview fixtures shared by session preview tests. + */ export function createToolSummaryPreviewTranscriptLines(sessionId: string): string[] { return [ JSON.stringify({ type: "session", version: 1, id: sessionId }), diff --git a/src/gateway/session-subagent-reactivation.runtime.ts b/src/gateway/session-subagent-reactivation.runtime.ts index 474ac1021e6c..e791b8761fd7 100644 --- a/src/gateway/session-subagent-reactivation.runtime.ts +++ b/src/gateway/session-subagent-reactivation.runtime.ts @@ -1 +1,3 @@ +// Runtime barrel for subagent run replacement. The main reactivation helper +// lazy-loads this to avoid importing the mutable registry on cold paths. export { replaceSubagentRunAfterSteer } from "../agents/subagent-registry.js"; diff --git a/src/gateway/session-subagent-reactivation.test.ts b/src/gateway/session-subagent-reactivation.test.ts index ebaf326d3924..4b07fbc68c28 100644 --- a/src/gateway/session-subagent-reactivation.test.ts +++ b/src/gateway/session-subagent-reactivation.test.ts @@ -1,3 +1,6 @@ +/** + * Subagent session reactivation tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); diff --git a/src/gateway/session-subagent-reactivation.ts b/src/gateway/session-subagent-reactivation.ts index 6a6758a40bd7..82b9d532959c 100644 --- a/src/gateway/session-subagent-reactivation.ts +++ b/src/gateway/session-subagent-reactivation.ts @@ -1,9 +1,13 @@ import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry-read.js"; +// Completed subagent sessions can be reactivated after a user steer by replacing +// the previous completed run id with the next run id through a lazy runtime +// import. Active subagent runs are never replaced here. async function loadSessionSubagentReactivationRuntime() { return import("./session-subagent-reactivation.runtime.js"); } +/** Reactivates a completed subagent session by swapping in the new run id. */ export async function reactivateCompletedSubagentSession(params: { sessionKey: string; runId?: string; diff --git a/src/gateway/session-transcript-index.fs.ts b/src/gateway/session-transcript-index.fs.ts index e2067a5427c4..6542b2c673be 100644 --- a/src/gateway/session-transcript-index.fs.ts +++ b/src/gateway/session-transcript-index.fs.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import { StringDecoder } from "node:string_decoder"; +/** + * Streaming JSONL transcript index used by gateway history reads. + * + * The index keeps byte offsets for visible entries so callers can page large + * transcripts without decoding every message body into memory. + */ const TRANSCRIPT_INDEX_READ_CHUNK_BYTES = 64 * 1024; const MAX_TRANSCRIPT_INDEX_CACHE_ENTRIES = 256; const MAX_TRANSCRIPT_INDEX_PARSE_LINE_BYTES = 256 * 1024; @@ -9,6 +15,7 @@ const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message type ParsedTranscriptRecord = Record; +/** Visible transcript entry plus its byte range in the JSONL file. */ export type IndexedTranscriptEntry = { seq: number; id?: string; @@ -119,6 +126,7 @@ function setCachedIndex(filePath: string, entry: CacheEntry): void { } } +/** Clears transcript index caches and in-flight builds between tests. */ export function clearSessionTranscriptIndexCache(): void { transcriptIndexCache.clear(); transcriptIndexBuilds.clear(); @@ -141,6 +149,8 @@ function buildOversizedIndexedRawEntry(params: { offset: number; byteLength: number; }): IndexedRawEntry | null { + // Oversized lines may contain huge message arrays, so recover only metadata + // from a bounded prefix and synthesize a visible placeholder record. const prefix = params.line.slice(0, OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS); const messageMatch = /"message"\s*:/.exec(prefix); const recordPrefix = messageMatch ? prefix.slice(0, messageMatch.index) : prefix; @@ -201,6 +211,8 @@ async function visitTranscriptJsonLines( } nextOffset += bytesRead; carryOffset = nextOffset - Buffer.byteLength(carry, "utf8"); + // Yield between chunks so a large transcript scan does not monopolize the + // gateway event loop while chat/session traffic is still flowing. await yieldTranscriptIndexScan(); } @@ -323,6 +335,7 @@ async function buildSessionTranscriptIndex( }; } +/** Reads or builds the visible transcript index for a JSONL session file. */ export async function readSessionTranscriptIndex( filePath: string, opts: ReadSessionTranscriptIndexOptions = {}, diff --git a/src/gateway/session-transcript-path.ts b/src/gateway/session-transcript-path.ts index e909a9fb44db..5d7fbbd44e4f 100644 --- a/src/gateway/session-transcript-path.ts +++ b/src/gateway/session-transcript-path.ts @@ -2,6 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** + * Resolves transcript file paths into a stable comparison key for history and update matching. + */ export function resolveTranscriptPathForComparison(value: string | undefined): string | undefined { const trimmed = normalizeOptionalString(value); if (!trimmed) { @@ -11,6 +14,7 @@ export function resolveTranscriptPathForComparison(value: string | undefined): s try { return fs.realpathSync(resolved); } catch { + // Some session references point at files that may not exist yet; still compare absolute paths. return resolved; } } diff --git a/src/gateway/session-utils.plugin-runtime.test.ts b/src/gateway/session-utils.plugin-runtime.test.ts index f678635042ee..2c858a5fbab5 100644 --- a/src/gateway/session-utils.plugin-runtime.test.ts +++ b/src/gateway/session-utils.plugin-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests session utility interactions with plugin runtime state. + */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; diff --git a/src/gateway/session-utils.single-row-cache.test.ts b/src/gateway/session-utils.single-row-cache.test.ts index 0a60189c375c..461c9157effa 100644 --- a/src/gateway/session-utils.single-row-cache.test.ts +++ b/src/gateway/session-utils.single-row-cache.test.ts @@ -1,3 +1,6 @@ +/** + * Tests single-row session cache behavior in gateway session utilities. + */ import { afterEach, describe, expect, test, vi } from "vitest"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 6fc103e3892f..4c6d344eef19 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -1,3 +1,6 @@ +/** + * Tests subagent session utility behavior and persisted session lookups. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; diff --git a/src/gateway/session-utils.telegram-recreate.test.ts b/src/gateway/session-utils.telegram-recreate.test.ts index 7aee8b7343c9..99e1306ef386 100644 --- a/src/gateway/session-utils.telegram-recreate.test.ts +++ b/src/gateway/session-utils.telegram-recreate.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Telegram session recreation helpers and persisted session mapping. + */ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 58752e51cf5c..dc5e1c018071 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -14,6 +14,8 @@ import type { } from "../shared/session-types.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; +// Shared Gateway session response contracts. Server methods, UI adapters, and +// tests import these types so list/patch/preview payloads evolve together. export type GatewaySessionsDefaults = { modelProvider: string | null; model: string | null; @@ -23,6 +25,7 @@ export type GatewaySessionsDefaults = { thinkingDefault?: string; }; +/** Runtime status surfaced for the latest session run. */ export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout"; type SubagentRunState = "active" | "interrupted" | "historical"; diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index 3b938b0cf72f..8eeb0f0f5a78 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -1,3 +1,6 @@ +/** + * HTTP session history revocation tests. + */ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index c16d92c949d2..52bb2c523236 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -1,3 +1,6 @@ +/** + * Session resolve store tests. + */ import path from "node:path"; import { describe, expect, it } from "vitest"; import { ErrorCodes } from "../../packages/gateway-protocol/src/index.js"; diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts index d4dfe9b65e8f..1ffc03aa6384 100644 --- a/src/gateway/startup-control-ui-origins.ts +++ b/src/gateway/startup-control-ui-origins.ts @@ -5,6 +5,10 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isContainerEnvironment } from "./net.js"; +/** + * Seeds runtime-only Control UI origins when a non-loopback gateway bind would + * otherwise reject the browser that just opened the local UI. + */ export async function maybeSeedControlUiAllowedOriginsAtStartup(params: { config: OpenClawConfig; log: { info: (msg: string) => void; warn: (msg: string) => void }; diff --git a/src/gateway/startup-tasks.test.ts b/src/gateway/startup-tasks.test.ts index 9c51182b6248..6224d9dda05d 100644 --- a/src/gateway/startup-tasks.test.ts +++ b/src/gateway/startup-tasks.test.ts @@ -1,3 +1,6 @@ +/** + * Tests startup task registration and gateway startup side effects. + */ import { describe, expect, it, vi } from "vitest"; import { runStartupTasks, type StartupTask } from "./startup-tasks.js"; diff --git a/src/gateway/startup-tasks.ts b/src/gateway/startup-tasks.ts index 2ac7d31fa21a..d7afdf813d78 100644 --- a/src/gateway/startup-tasks.ts +++ b/src/gateway/startup-tasks.ts @@ -1,10 +1,14 @@ import { formatErrorMessage } from "../infra/errors.js"; +// Startup tasks run sequentially so logs and side effects stay ordered during +// gateway startup. Failures are collected and logged without aborting later +// tasks. type StartupTaskResult = | { status: "skipped"; reason: string } | { status: "ran" } | { status: "failed"; reason: string }; +/** Startup task descriptor used by gateway startup side-effect runners. */ export type StartupTask = { source: string; agentId?: string; @@ -30,6 +34,7 @@ function taskMeta(task: StartupTask, result?: StartupTaskResult): Record & { room: { activeClientId?: string; @@ -103,6 +112,7 @@ type TalkHandoffRoomState = { const handoffs = new Map(); +/** Creates a short-lived Talk room and returns the only plaintext join token. */ export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffCreateResult { pruneExpiredTalkHandoffs(); const rawCreatedAt = Date.now(); @@ -146,11 +156,13 @@ export function createTalkHandoff(params: TalkHandoffCreateParams): TalkHandoffC return { ...toPublicTalkHandoffRecord(record), token }; } +/** Returns a non-expired handoff record for gateway-internal callers. */ export function getTalkHandoff(id: string): TalkHandoffRecord | undefined { pruneExpiredTalkHandoffs(); return handoffs.get(id); } +/** Joins a managed room, replacing any previous active client for that room. */ export function joinTalkHandoff( id: string, token: string, @@ -181,6 +193,7 @@ export function joinTalkHandoff( }; } +/** Starts a client turn in a joined managed room. */ export function startTalkHandoffTurn( id: string, token: string, @@ -207,6 +220,7 @@ export function startTalkHandoffTurn( }; } +/** Ends the active managed-room turn and returns the emitted Talk event. */ export function endTalkHandoffTurn( id: string, token: string, @@ -232,6 +246,7 @@ export function endTalkHandoffTurn( }; } +/** Cancels the active managed-room turn with a client-visible reason. */ export function cancelTalkHandoffTurn( id: string, token: string, @@ -257,6 +272,7 @@ export function cancelTalkHandoffTurn( }; } +/** Revokes a handoff and emits the final room-close event if it existed. */ export function revokeTalkHandoff(id: string): TalkHandoffRevokeResult { pruneExpiredTalkHandoffs(); const record = handoffs.get(id); @@ -277,10 +293,12 @@ export function revokeTalkHandoff(id: string): TalkHandoffRevokeResult { }; } +/** Verifies the caller token without exposing the stored token hash. */ export function verifyTalkHandoffToken(record: TalkHandoffRecord, token: string): boolean { return record.tokenHash === hashTalkHandoffToken(token); } +/** Clears process-local handoffs between tests. */ export function clearTalkHandoffsForTest(): void { handoffs.clear(); } @@ -357,6 +375,8 @@ function resolveTalkHandoffAccess( return { ok: false, reason: "not_found" }; } if (!isFutureDateTimestampMs(record.expiresAt)) { + // Expiry emits the same close event as explicit revocation so room clients + // can reconcile state without knowing which cleanup path won the race. appendTalkHandoffRoomEvent(record, { type: "session.closed", payload: { reason: "expired", handoffId: id, roomId: record.roomId }, diff --git a/src/gateway/talk-realtime-relay.test.ts b/src/gateway/talk-realtime-relay.test.ts index 9065dfe570f2..9186077dfc79 100644 --- a/src/gateway/talk-realtime-relay.test.ts +++ b/src/gateway/talk-realtime-relay.test.ts @@ -1,3 +1,6 @@ +/** + * Tests talk realtime relay event forwarding and connection cleanup. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { setActiveEmbeddedRun, diff --git a/src/gateway/talk-realtime-relay.ts b/src/gateway/talk-realtime-relay.ts index 6858889fc563..7c895badb3fb 100644 --- a/src/gateway/talk-realtime-relay.ts +++ b/src/gateway/talk-realtime-relay.ts @@ -48,6 +48,13 @@ import { } from "./talk-relay-session-lifecycle.js"; import { forgetUnifiedTalkSession } from "./talk-session-registry.js"; +/** + * Gateway-owned relay between browser Talk audio and realtime voice providers. + * + * Each relay is scoped to the owning WebSocket connection; events are broadcast + * back only to that connection so audio, transcript, and tool-call state cannot + * leak across clients that know another relay id. + */ const RELAY_SESSION_TTL_MS = 30 * 60 * 1000; const MAX_AUDIO_BASE64_BYTES = 512 * 1024; const MAX_RELAY_SESSIONS_PER_CONN = 2; @@ -259,6 +266,8 @@ function submitRelayAgentControlProviderResults( for (const callId of activeCallIds) { const forcedConsult = session.forcedConsults.handles().find((handle) => handle.id === callId); if (forcedConsult) { + // Forced consults may have both synthetic and provider-native call ids; + // cancelling must satisfy every native id or the provider keeps waiting. session.forcedConsults.markCancelled(forcedConsult); for (const nativeCallId of session.forcedConsults.nativeCallIds(forcedConsult)) { session.bridge.submitToolResult(nativeCallId, result.providerResult, { @@ -327,6 +336,7 @@ function enforceRelaySessionLimits(connId: string): void { } } +/** Creates a realtime voice relay session and returns the browser audio contract. */ export function createTalkRealtimeRelaySession( params: CreateTalkRealtimeRelaySessionParams, ): TalkRealtimeRelaySessionResult { @@ -477,6 +487,8 @@ export function createTalkRealtimeRelaySession( pruneInactiveRelayAgentRuns(relay) > 0 && shouldAutoControlRealtimeVoiceAgentText(question) ) { + // While an agent consult is active, short user utterances like "stop" + // steer the chat run instead of becoming a new consult. void steerTalkRealtimeRelayAgentRun({ relaySessionId, connId: params.connId, @@ -727,6 +739,7 @@ function getRelaySession(relaySessionId: string, connId: string): RelaySession { }); } +/** Streams one base64-encoded browser audio frame into the owning relay. */ export function sendTalkRealtimeRelayAudio(params: { relaySessionId: string; connId: string; @@ -755,6 +768,7 @@ export function sendTalkRealtimeRelayAudio(params: { } } +/** Delivers a tool result from the browser/client side back to the provider. */ export function submitTalkRealtimeRelayToolResult(params: { relaySessionId: string; connId: string; @@ -821,6 +835,7 @@ export function submitTalkRealtimeRelayToolResult(params: { }); } +/** Tracks the chat run started for a realtime agent-consult tool call. */ export function registerTalkRealtimeRelayAgentRun(params: { relaySessionId: string; connId: string; @@ -838,6 +853,7 @@ export function registerTalkRealtimeRelayAgentRun(params: { } } +/** Applies realtime voice-control text to the active agent-consult chat run. */ export async function steerTalkRealtimeRelayAgentRun(params: { relaySessionId: string; connId: string; @@ -880,6 +896,7 @@ export async function steerTalkRealtimeRelayAgentRun(params: { return result; } +/** Cancels the active relay turn, aborts agent work, and clears provider audio. */ export function cancelTalkRealtimeRelayTurn(params: { relaySessionId: string; connId: string; @@ -902,6 +919,7 @@ export function cancelTalkRealtimeRelayTurn(params: { }); } +/** Closes a realtime relay session owned by the current connection. */ export function stopTalkRealtimeRelaySession(params: { relaySessionId: string; connId: string; @@ -910,6 +928,7 @@ export function stopTalkRealtimeRelaySession(params: { closeRelaySession(session, "completed"); } +/** Clears process-local realtime relays between tests. */ export function clearTalkRealtimeRelaySessionsForTest(): void { for (const session of relaySessions.values()) { session.forcedConsults.clear(); diff --git a/src/gateway/talk-relay-session-lifecycle.ts b/src/gateway/talk-relay-session-lifecycle.ts index a92d6fa988e2..a344b7569991 100644 --- a/src/gateway/talk-relay-session-lifecycle.ts +++ b/src/gateway/talk-relay-session-lifecycle.ts @@ -1,5 +1,8 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; +/** + * Shared TTL and connection-ownership checks for Talk relay session maps. + */ type TalkRelayLifecycleSession = { connId: string; expiresAtMs: number; @@ -17,6 +20,7 @@ function isExpiredTalkRelaySession( return expiresAtMs === undefined || validNowMs > expiresAtMs; } +/** Closes every expired relay session in the provided process-local map. */ export function closeExpiredTalkRelaySessions(params: { sessions: Iterable; closeSession: CloseTalkRelaySession; @@ -33,6 +37,7 @@ export function closeExpiredTalkRelaySessions(params: { sessions: ReadonlyMap; sessionId: string; diff --git a/src/gateway/talk-session-registry.ts b/src/gateway/talk-session-registry.ts index 5ce626387255..964ab55bc0c3 100644 --- a/src/gateway/talk-session-registry.ts +++ b/src/gateway/talk-session-registry.ts @@ -1,3 +1,7 @@ +/** + * Process-local registry that lets Talk protocol methods resolve opaque + * `sessionId` values to the concrete relay or managed-room backend. + */ export type UnifiedTalkSessionRecord = | { kind: "realtime-relay"; @@ -18,6 +22,7 @@ export type UnifiedTalkSessionRecord = const unifiedTalkSessions = new Map(); +/** Associates a public Talk session id with its concrete gateway backend. */ export function rememberUnifiedTalkSession( sessionId: string, session: UnifiedTalkSessionRecord, @@ -25,6 +30,7 @@ export function rememberUnifiedTalkSession( unifiedTalkSessions.set(sessionId, session); } +/** Resolves a Talk session id or throws the protocol-facing unknown-session error. */ export function getUnifiedTalkSession(sessionId: string): UnifiedTalkSessionRecord { const session = unifiedTalkSessions.get(sessionId); if (!session) { @@ -33,10 +39,12 @@ export function getUnifiedTalkSession(sessionId: string): UnifiedTalkSessionReco return session; } +/** Removes a Talk session id after the concrete backend closes. */ export function forgetUnifiedTalkSession(sessionId: string): void { unifiedTalkSessions.delete(sessionId); } +/** Enforces that a relay-backed Talk session is controlled by its owner socket. */ export function requireUnifiedTalkSessionConn( session: Extract, connId: string | undefined, @@ -47,6 +55,7 @@ export function requireUnifiedTalkSessionConn( return connId; } +/** Clears process-local Talk session mappings between tests. */ export function clearUnifiedTalkSessionsForTest(): void { unifiedTalkSessions.clear(); } diff --git a/src/gateway/talk-transcription-relay.test.ts b/src/gateway/talk-transcription-relay.test.ts index 4ebd5e7f5f53..d129b610aeba 100644 --- a/src/gateway/talk-transcription-relay.test.ts +++ b/src/gateway/talk-transcription-relay.test.ts @@ -1,3 +1,6 @@ +/** + * Tests talk transcription relay behavior between realtime events and clients. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RealtimeTranscriptionProviderPlugin } from "../plugins/types.js"; import type { RealtimeTranscriptionSessionCreateRequest } from "../realtime-transcription/provider-types.js"; @@ -110,9 +113,13 @@ describe("talk transcription gateway relay", () => { sttRequest?.onPartial?.("hel"); sttRequest?.onTranscript?.("hello world"); }); - const { events, session } = await createStartedRelaySession(sttSession, { model: "stt-model" }, (req) => { - sttRequest = req; - }); + const { events, session } = await createStartedRelaySession( + sttSession, + { model: "stt-model" }, + (req) => { + sttRequest = req; + }, + ); expectRecordFields(session, "session", { provider: "stt-test", diff --git a/src/gateway/talk-transcription-relay.ts b/src/gateway/talk-transcription-relay.ts index fb8b54a05a07..44d796fdcca8 100644 --- a/src/gateway/talk-transcription-relay.ts +++ b/src/gateway/talk-transcription-relay.ts @@ -18,6 +18,13 @@ import { requireActiveTalkRelaySession, } from "./talk-relay-session-lifecycle.js"; +/** + * Gateway-owned relay for streaming speech-to-text providers used by Talk. + * + * The relay accepts browser audio on one WebSocket connection, forwards it to a + * realtime transcription provider, and mirrors provider callbacks into Talk + * events for the same connection. + */ const TRANSCRIPTION_SESSION_TTL_MS = 30 * 60 * 1000; const MAX_AUDIO_BASE64_BYTES = 512 * 1024; const MAX_TRANSCRIPTION_SESSIONS_PER_CONN = 2; @@ -72,6 +79,7 @@ type TalkTranscriptionRelaySessionResult = { const transcriptionSessions = new Map(); +/** Normalizes common provider audio-format aliases into the relay contract. */ function normalizeRelayInputEncoding( value: unknown, ): "g711_ulaw" | "g711_alaw" | "pcm16" | undefined { @@ -120,6 +128,7 @@ function inferSampleRateFromAudioFormat(value: unknown): number | undefined { return match ? readFiniteNumber(match[1]) : undefined; } +/** Verifies provider config matches the audio format the browser relay emits. */ function assertRelayInputAudioConfig(providerConfig: RealtimeTranscriptionProviderConfig): void { const encodingValue = providerConfig.encoding ?? providerConfig.audioFormat ?? providerConfig.audio_format; @@ -211,6 +220,7 @@ function enforceTranscriptionSessionLimits(connId: string): void { } } +/** Creates a transcription relay session and returns its browser audio contract. */ export function createTalkTranscriptionRelaySession( params: CreateTalkTranscriptionRelaySessionParams, ): TalkTranscriptionRelaySessionResult { @@ -368,6 +378,7 @@ function getTranscriptionSession( }); } +/** Streams one base64-encoded audio frame into the owning transcription relay. */ export function sendTalkTranscriptionRelayAudio(params: { transcriptionSessionId: string; connId: string; @@ -392,6 +403,7 @@ export function sendTalkTranscriptionRelayAudio(params: { }); } +/** Commits the current transcription turn and closes the relay. */ export function stopTalkTranscriptionRelaySession(params: { transcriptionSessionId: string; connId: string; @@ -414,6 +426,7 @@ export function stopTalkTranscriptionRelaySession(params: { closeTranscriptionSession(session, "completed"); } +/** Cancels the active transcription turn and closes the relay. */ export function cancelTalkTranscriptionRelayTurn(params: { transcriptionSessionId: string; connId: string; @@ -435,6 +448,7 @@ export function cancelTalkTranscriptionRelayTurn(params: { closeTranscriptionSession(session, "completed"); } +/** Clears process-local transcription relays between tests. */ export function clearTalkTranscriptionRelaySessionsForTest(): void { for (const session of transcriptionSessions.values()) { clearTimeout(session.cleanupTimer); diff --git a/src/gateway/talk.test-helpers.ts b/src/gateway/talk.test-helpers.ts index af74724f2d79..0b11d081f5b1 100644 --- a/src/gateway/talk.test-helpers.ts +++ b/src/gateway/talk.test-helpers.ts @@ -1,6 +1,10 @@ +/** + * Direct talk method invocation helpers for gateway speech tests. + */ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; +/** Minimal successful speech-provider response shape used by talk.speak tests. */ export type TalkSpeakTestPayload = { audioBase64?: string; provider?: string; @@ -9,6 +13,7 @@ export type TalkSpeakTestPayload = { fileExtension?: string; }; +/** Calls talk.speak without a WebSocket server and captures the handler response. */ export async function invokeTalkSpeakDirect(params: Record) { const { talkHandlers } = await import("./server-methods/talk.js"); const { getRuntimeConfig } = await import("../config/config.js"); @@ -32,6 +37,7 @@ export async function invokeTalkSpeakDirect(params: Record) { return response; } +/** Temporarily replaces the active speech providers for one async test body. */ export async function withSpeechProviders( speechProviders: NonNullable["speechProviders"]>, run: () => Promise, diff --git a/src/gateway/test-helpers.agent-results.test.ts b/src/gateway/test-helpers.agent-results.test.ts index 92225cc7fb19..32db34fad3b6 100644 --- a/src/gateway/test-helpers.agent-results.test.ts +++ b/src/gateway/test-helpers.agent-results.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway agent-result test helper coverage. + */ import { describe, expect, it } from "vitest"; import { extractPayloadText } from "./test-helpers.agent-results.js"; diff --git a/src/gateway/test-helpers.agent-results.ts b/src/gateway/test-helpers.agent-results.ts index 94188594aff1..453f1f5aaa1c 100644 --- a/src/gateway/test-helpers.agent-results.ts +++ b/src/gateway/test-helpers.agent-results.ts @@ -1,3 +1,6 @@ +/** + * Agent result builders and extractors shared by gateway tests. + */ type AgentDeltaEvent = { runId: string; stream: "assistant"; @@ -51,6 +54,7 @@ function extractCliStreamJsonText(text: string): string | null { return resultText ?? assistantText; } +/** Extracts normalized assistant text from gateway agent result payloads. */ export function extractPayloadText(result: unknown): string { const record = result as Record; const payloads = Array.isArray(record.payloads) ? record.payloads : []; @@ -64,6 +68,7 @@ export function extractPayloadText(result: unknown): string { return extractCliStreamJsonText(joined) ?? joined; } +/** Builds a minimal assistant delta result payload. */ export function buildAssistantDeltaResult(params: { opts: unknown; emit: (event: AgentDeltaEvent) => void; diff --git a/src/gateway/test-helpers.assertions.ts b/src/gateway/test-helpers.assertions.ts index d56bbee0f096..b3016f98a7fa 100644 --- a/src/gateway/test-helpers.assertions.ts +++ b/src/gateway/test-helpers.assertions.ts @@ -1,14 +1,19 @@ import { expect } from "vitest"; +/** + * Record-shape assertion helpers for gateway tests. + */ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Requires an unknown value to be a record and throws with a test label. */ export function requireRecord(value: unknown, label: string): Record { expect(isRecord(value), `${label} must be an object`).toBe(true); return value as Record; } +/** Asserts selected record fields without losing type narrowing at call sites. */ export function expectRecordFields( value: unknown, label: string, diff --git a/src/gateway/test-helpers.channels.ts b/src/gateway/test-helpers.channels.ts index 4892f108556e..ae511a3b81d7 100644 --- a/src/gateway/test-helpers.channels.ts +++ b/src/gateway/test-helpers.channels.ts @@ -1,3 +1,6 @@ +/** + * Channel test fixtures shared by gateway tests. + */ import type { ChannelOutboundAdapter } from "../channels/plugins/types.adapters.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts index f6d81fbbe821..d560b98ff00d 100644 --- a/src/gateway/test-helpers.config-runtime.ts +++ b/src/gateway/test-helpers.config-runtime.ts @@ -13,8 +13,12 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js"; import { testConfigRoot, testIsNixMode, testState } from "./test-helpers.runtime-state.js"; +/** + * Config module mock factory used by gateway integration tests. + */ type GatewayConfigModule = typeof import("../config/config.js"); +/** Wraps the real config module with gateway-test runtime overrides. */ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): GatewayConfigModule { const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json"); diff --git a/src/gateway/test-helpers.config-snapshots.ts b/src/gateway/test-helpers.config-snapshots.ts index 65b1d00e9c4c..1c5138686bbd 100644 --- a/src/gateway/test-helpers.config-snapshots.ts +++ b/src/gateway/test-helpers.config-snapshots.ts @@ -1,6 +1,9 @@ import crypto from "node:crypto"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; +/** + * Config snapshot builders shared by gateway tests. + */ function hashConfigRaw(raw: string | null): string { return crypto .createHash("sha256") @@ -8,6 +11,7 @@ function hashConfigRaw(raw: string | null): string { .digest("hex"); } +/** Builds a fully-populated config snapshot for config-module mocks. */ export function buildTestConfigSnapshot(params: { path: string; exists: boolean; diff --git a/src/gateway/test-helpers.deferred.ts b/src/gateway/test-helpers.deferred.ts index 87550941b69b..845abb73268e 100644 --- a/src/gateway/test-helpers.deferred.ts +++ b/src/gateway/test-helpers.deferred.ts @@ -1,3 +1,6 @@ +/** + * Deferred promise helper for gateway async tests. + */ export function createDeferred() { let resolve: ((value: T | PromiseLike) => void) | undefined; let reject: ((reason?: unknown) => void) | undefined; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 1b265f751b76..5521343213b3 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -26,6 +26,9 @@ import { type RunBtwSideQuestionFn, } from "./test-helpers.runtime-state.js"; +/** + * Central Vitest module mock setup for gateway integration tests. + */ export { getTestPluginRegistry, resetTestPluginRegistry, setTestPluginRegistry }; export { agentCommand, diff --git a/src/gateway/test-helpers.node-invoke.ts b/src/gateway/test-helpers.node-invoke.ts index d5d38af195a0..73ab0d6307b2 100644 --- a/src/gateway/test-helpers.node-invoke.ts +++ b/src/gateway/test-helpers.node-invoke.ts @@ -3,6 +3,9 @@ import type { WebSocket } from "ws"; import type { GatewayClient } from "./client.js"; import { rpcReq } from "./test-helpers.js"; +/** + * Node invoke acknowledgement helper for gateway tests. + */ export function acknowledgeNodeInvokeRequestForTest(params: { client: GatewayClient; event: { event?: string; payload?: unknown }; diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index d2926f402306..91a9979bb350 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -4,6 +4,9 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { createDefaultGatewayTestChannels } from "./test-helpers.channels.js"; import { createDefaultGatewayTestSpeechProviders } from "./test-helpers.speech.js"; +/** + * Process-wide plugin registry fixture for gateway tests. + */ function createStubPluginRegistry(): PluginRegistry { return { plugins: [], @@ -60,16 +63,19 @@ const pluginRegistryState = resolveGlobalSingleton(GATEWAY_TEST_PLUGIN_REGISTRY_ setActivePluginRegistry(pluginRegistryState.registry); +/** Installs a plugin registry fixture as the active runtime registry. */ export function setTestPluginRegistry(registry: PluginRegistry): void { pluginRegistryState.registry = registry; setActivePluginRegistry(registry); } +/** Restores the default empty gateway test plugin registry. */ export function resetTestPluginRegistry(): void { pluginRegistryState.registry = createStubPluginRegistry(); setActivePluginRegistry(pluginRegistryState.registry); } +/** Returns the currently active gateway test plugin registry. */ export function getTestPluginRegistry(): PluginRegistry { return pluginRegistryState.registry; } diff --git a/src/gateway/test-helpers.runtime-state.ts b/src/gateway/test-helpers.runtime-state.ts index 35fd9b054edd..13382ce5104d 100644 --- a/src/gateway/test-helpers.runtime-state.ts +++ b/src/gateway/test-helpers.runtime-state.ts @@ -13,6 +13,9 @@ import type { RunCronAgentTurnResult } from "../cron/isolated-agent/run.types.js import type { TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +/** + * Hoisted mutable state shared by gateway Vitest module mocks. + */ export type GetReplyFromConfigFn = ( ctx: MsgContext, opts?: GetReplyOptions, @@ -140,6 +143,7 @@ const gatewayTestHoisted = vi.hoisted(() => { return created; }); +/** Returns the singleton state object used by gateway test module mocks. */ export function getGatewayTestHoistedState(): GatewayTestHoistedState { return gatewayTestHoisted; } @@ -165,6 +169,7 @@ export const testConfigRoot = resolveGlobalSingleton(GATEWAY_TEST_CONFIG_ROOT_KE value: path.join(os.tmpdir(), `openclaw-gateway-test-${process.pid}-${crypto.randomUUID()}`), })); +/** Updates the config root used by gateway config-module mocks. */ export function setTestConfigRoot(root: string): void { testConfigRoot.value = root; process.env.OPENCLAW_CONFIG_PATH = path.join(root, "openclaw.json"); diff --git a/src/gateway/test-helpers.server-runtime-state.ts b/src/gateway/test-helpers.server-runtime-state.ts index dccec9498c43..98af924bb62c 100644 --- a/src/gateway/test-helpers.server-runtime-state.ts +++ b/src/gateway/test-helpers.server-runtime-state.ts @@ -1,8 +1,12 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; +/** + * Runtime-state fixture factory for gateway server tests. + */ type GatewayRuntimeStateParams = Parameters[0]; +/** Creates a minimal gateway runtime state with optional plugin registry fixture. */ export async function createGatewayRuntimeStateForTest( pluginRegistry: GatewayRuntimeStateParams["pluginRegistry"] = createEmptyPluginRegistry(), ) { diff --git a/src/gateway/test-helpers.server.test.ts b/src/gateway/test-helpers.server.test.ts index b05b9ad5da09..b58e1242927e 100644 --- a/src/gateway/test-helpers.server.test.ts +++ b/src/gateway/test-helpers.server.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway server test-helper coverage. + */ import { describe, expect, it } from "vitest"; import { testOnlyResolveAuthTokenForSignature } from "./test-helpers.server.js"; diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 7cc0d3ac0227..d85bff30894e 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Public barrel for gateway integration test helpers. + */ export { agentCommand, cronIsolatedRun, diff --git a/src/gateway/test-http-response.ts b/src/gateway/test-http-response.ts index 1fc7fb200c65..1aca0a058514 100644 --- a/src/gateway/test-http-response.ts +++ b/src/gateway/test-http-response.ts @@ -3,6 +3,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { PassThrough } from "node:stream"; import { vi } from "vitest"; +/** + * Minimal HTTP response mock used by gateway handler tests. + */ export function makeMockHttpResponse(): { res: ServerResponse; setHeader: ReturnType; diff --git a/src/gateway/test-openai-responses-model.ts b/src/gateway/test-openai-responses-model.ts index 694824682937..83d5ef722dbe 100644 --- a/src/gateway/test-openai-responses-model.ts +++ b/src/gateway/test-openai-responses-model.ts @@ -1,3 +1,6 @@ +/** + * Mock OpenAI Responses provider used by gateway compatibility tests. + */ const MOCK_OPENAI_RESPONSES_PROVIDER_ID = "mock-openai"; function buildOpenAiResponsesTestModel(id = "gpt-5.4") { @@ -22,6 +25,7 @@ function buildOpenAiResponsesProviderConfig(baseUrl: string, modelId = "gpt-5.4" } as const; } +/** Builds provider config and model refs for local OpenAI-compatible HTTP tests. */ export function buildMockOpenAiResponsesProvider(baseUrl: string, modelId = "gpt-5.4") { return { providerId: MOCK_OPENAI_RESPONSES_PROVIDER_ID, diff --git a/src/gateway/test-temp-config.ts b/src/gateway/test-temp-config.ts index dfeae6a699ae..e93b109c35b4 100644 --- a/src/gateway/test-temp-config.ts +++ b/src/gateway/test-temp-config.ts @@ -9,6 +9,9 @@ import { import type { OpenClawConfig } from "../config/config.js"; import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; +/** + * Temporary gateway config helper for tests that need isolated config files. + */ function withStableOwnerDisplaySecretForTest(cfg: unknown): unknown { if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { return cfg; @@ -30,6 +33,7 @@ function withStableOwnerDisplaySecretForTest(cfg: unknown): unknown { }; } +/** Writes a temp OpenClaw config, installs it as runtime state, then restores globals. */ export async function withTempConfig(params: { cfg: unknown; run: () => Promise; diff --git a/src/gateway/test-with-server.ts b/src/gateway/test-with-server.ts index 0712874d5784..a0c6fb8b1364 100644 --- a/src/gateway/test-with-server.ts +++ b/src/gateway/test-with-server.ts @@ -1,10 +1,14 @@ import { afterAll, beforeAll, beforeEach } from "vitest"; import { connectOk, startServerWithClient, testState } from "./test-helpers.js"; +/** + * Test helpers for running code against a connected gateway WebSocket server. + */ type StartServerWithClient = typeof startServerWithClient; type GatewayWs = Awaited>["ws"]; type GatewayServer = Awaited>["server"]; +/** Starts a gateway, connects a client, runs the callback, and closes resources. */ export async function withServer(run: (ws: GatewayWs) => Promise): Promise { const { server, ws, envSnapshot } = await startServerWithClient("secret"); try { @@ -16,6 +20,7 @@ export async function withServer(run: (ws: GatewayWs) => Promise): Promise } } +/** Installs a reusable connected Control UI server suite for gateway tests. */ export function installConnectedControlUiServerSuite( onReady: (started: { server: GatewayServer; ws: GatewayWs; port: number }) => void, ): void { diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index ce334349821c..144caab792e3 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * HTTP server session fixtures shared by gateway session tests. + */ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 6199f1d29bcd..b196fb87ca9e 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway tool-resolution exclusion tests. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index e664def6d05a..041d538cb59f 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway tool-resolution tests. + */ import { beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index 54cdd1357f55..e9ea505e7cae 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -16,8 +16,12 @@ import { getPluginToolMeta } from "../plugins/tools.js"; import { canonicalizeSessionKeyForAgent } from "./session-store-key.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; +/** + * Shared gateway tool invocation engine used by HTTP and RPC adapters. + */ const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]); +/** Protocol input shape accepted by gateway tool invocation surfaces. */ export type ToolsInvokeInput = { tool?: unknown; name?: unknown; @@ -143,6 +147,7 @@ function resolveToolSource(tool: AnyAgentTool): "core" | "plugin" | "channel" { return "core"; } +/** Resolves, authorizes, and invokes one gateway-visible core/plugin/channel tool. */ export async function invokeGatewayTool(params: { cfg: OpenClawConfig; input: ToolsInvokeInput; diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index 8fe7d7203de4..02f7dfab6a6d 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -1,3 +1,6 @@ +/** + * Gateway WebSocket log formatting tests. + */ import { describe, expect, test } from "vitest"; import { formatForLog, shortId, summarizeAgentEventForWsLog } from "./ws-log.js"; diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts index 0b01dd242291..c047a663ae7b 100644 --- a/src/gateway/ws-log.ts +++ b/src/gateway/ws-log.ts @@ -9,6 +9,9 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; +/** + * WebSocket logging helpers for gateway request, response, and event traffic. + */ const LOG_VALUE_LIMIT = 240; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const WS_LOG_REDACT_OPTIONS = { @@ -87,10 +90,12 @@ function logWsInfoLine(params: { wsLog.info(tokens.join(" ")); } +/** Returns true when gateway WebSocket logging is enabled for the current console. */ export function shouldLogWs(): boolean { return shouldLogSubsystemToConsole("gateway/ws"); } +/** Compacts long ids while keeping enough entropy for log correlation. */ export function shortId(value: string): string { const s = value.trim(); if (UUID_RE.test(s)) { @@ -102,6 +107,7 @@ export function shortId(value: string): string { return `${s.slice(0, 12)}…${s.slice(-4)}`; } +/** Formats and redacts arbitrary values before they are written to gateway logs. */ export function formatForLog(value: unknown): string { try { if (value instanceof Error) { @@ -187,6 +193,7 @@ function compactPreview(input: string, maxLen = 160): string { return `${oneLine.slice(0, Math.max(0, maxLen - 1))}…`; } +/** Extracts small, non-sensitive fields from agent event payloads for WS logs. */ export function summarizeAgentEventForWsLog(payload: unknown): Record { if (!payload || typeof payload !== "object") { return {}; diff --git a/src/gateway/ws-logging.ts b/src/gateway/ws-logging.ts index 95568a7b7831..f35c81b93454 100644 --- a/src/gateway/ws-logging.ts +++ b/src/gateway/ws-logging.ts @@ -1,11 +1,16 @@ +/** + * Runtime control for gateway WebSocket logging verbosity. + */ export type GatewayWsLogStyle = "auto" | "full" | "compact"; let gatewayWsLogStyle: GatewayWsLogStyle = "auto"; +/** Overrides gateway WebSocket log formatting for tests or explicit runtime config. */ export function setGatewayWsLogStyle(style: GatewayWsLogStyle): void { gatewayWsLogStyle = style; } +/** Returns the active gateway WebSocket log style. */ export function getGatewayWsLogStyle(): GatewayWsLogStyle { return gatewayWsLogStyle; } diff --git a/src/hooks/bundled/boot-md/handler.ts b/src/hooks/bundled/boot-md/handler.ts index de46b6777fb2..63b7c7de8861 100644 --- a/src/hooks/bundled/boot-md/handler.ts +++ b/src/hooks/bundled/boot-md/handler.ts @@ -8,6 +8,7 @@ import { isGatewayStartupEvent } from "../../internal-hooks.js"; const log = createSubsystemLogger("hooks/boot-md"); +/** Gateway-startup hook that runs BOOT.md checks once per unique agent workspace. */ const runBootChecklist: HookHandler = async (event) => { if (!isGatewayStartupEvent(event)) { return; @@ -20,6 +21,8 @@ const runBootChecklist: HookHandler = async (event) => { const cfg = event.context.cfg; const deps = event.context.deps ?? createDefaultDeps(); const seenWorkspaces = new Set(); + // Multiple agents may share a workspace. Startup tasks are keyed by workspace + // so BOOT.md is not executed repeatedly for the same files. const tasks: StartupTask[] = listAgentIds(cfg) .map((agentId) => { const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.ts b/src/hooks/bundled/bootstrap-extra-files/handler.ts index f603745d4550..fe2bffa65f69 100644 --- a/src/hooks/bundled/bootstrap-extra-files/handler.ts +++ b/src/hooks/bundled/bootstrap-extra-files/handler.ts @@ -10,6 +10,7 @@ import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js"; const HOOK_KEY = "bootstrap-extra-files"; const log = createSubsystemLogger("bootstrap-extra-files"); +/** Resolve legacy and current config keys for extra bootstrap file patterns. */ function resolveExtraBootstrapPatterns(hookConfig: Record): string[] { const fromPaths = normalizeTrimmedStringList(hookConfig.paths); if (fromPaths.length > 0) { @@ -22,6 +23,7 @@ function resolveExtraBootstrapPatterns(hookConfig: Record): str return normalizeTrimmedStringList(hookConfig.files); } +/** Agent-bootstrap hook that appends configured extra files to the session bootstrap set. */ const bootstrapExtraFilesHook: HookHandler = async (event) => { if (!isAgentBootstrapEvent(event)) { return; @@ -55,6 +57,8 @@ const bootstrapExtraFilesHook: HookHandler = async (event) => { if (extras.length === 0) { return; } + // Re-run session filtering after append so extra files obey the same + // per-session include rules as the original bootstrap files. context.bootstrapFiles = filterBootstrapFilesForSession( [...context.bootstrapFiles, ...extras], context.sessionKey, diff --git a/src/hooks/bundled/compaction-notifier/handler.ts b/src/hooks/bundled/compaction-notifier/handler.ts index ffb9f40f9f3a..0c5adae60a1b 100644 --- a/src/hooks/bundled/compaction-notifier/handler.ts +++ b/src/hooks/bundled/compaction-notifier/handler.ts @@ -1,11 +1,13 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { HookHandler } from "../../hooks.js"; +/** Read optional numeric compaction metadata without trusting hook context shape. */ function readOptionalNumber(context: Record, key: string): number | undefined { const value = context[key]; return asFiniteNumber(value); } +/** Session compaction hook that emits short user-visible progress messages. */ const handler: HookHandler = async (event) => { try { const context = event.context; diff --git a/src/hooks/config.ts b/src/hooks/config.ts index fa9a36e02264..0e26d9fd6ebb 100644 --- a/src/hooks/config.ts +++ b/src/hooks/config.ts @@ -15,6 +15,7 @@ const DEFAULT_CONFIG_VALUES: Record = { export { hasBinary }; +/** Evaluate a config path with hook-specific defaults for legacy runtime requirements. */ export function isConfigPathTruthy(config: OpenClawConfig | undefined, pathStr: string): boolean { return isConfigPathTruthyWithDefaults(config, pathStr, DEFAULT_CONFIG_VALUES); } @@ -29,6 +30,8 @@ function evaluateHookRuntimeEligibility(params: { }): boolean { const { entry, config, hookConfig, eligibility } = params; const remote = eligibility?.remote; + // Hook metadata uses the same requirement language as plugins, but hook env + // can also come from the per-hook config block. const base = { os: entry.metadata?.os, remotePlatforms: remote?.platforms, @@ -45,6 +48,7 @@ function evaluateHookRuntimeEligibility(params: { }); } +/** Return true when a hook passes enable policy and runtime requirements. */ export function shouldIncludeHook(params: { entry: HookEntry; config?: OpenClawConfig; diff --git a/src/hooks/configured.ts b/src/hooks/configured.ts index fcac07ac59f8..5c03bfffe0ef 100644 --- a/src/hooks/configured.ts +++ b/src/hooks/configured.ts @@ -17,6 +17,7 @@ function hasConfiguredInstalls(installs: Record | und return installs ? Object.keys(installs).length > 0 : false; } +/** Return whether config can load any internal hooks, including legacy handlers. */ export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { const internal = config.hooks?.internal; if (!internal || internal.enabled === false) { @@ -37,6 +38,7 @@ export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { return getLegacyInternalHookHandlers(config).length > 0; } +/** Resolve explicitly configured internal hook names; null means all/discovered hooks may load. */ export function resolveConfiguredInternalHookNames(config: OpenClawConfig): Set | null { const internal = config.hooks?.internal; if (!internal || internal.enabled === false) { @@ -56,6 +58,8 @@ export function resolveConfiguredInternalHookNames(config: OpenClawConfig): Set< for (const [installId, install] of Object.entries(internal.installs ?? {})) { const hookNames = install.hooks ?? []; if (hookNames.length === 0 && installId.trim()) { + // An install without an explicit hook list can add hooks dynamically, so + // callers must treat the allowlist as open-ended. return null; } for (const hookName of hookNames) { diff --git a/src/hooks/fire-and-forget.ts b/src/hooks/fire-and-forget.ts index 498172dd3ec8..afc87b59a5d5 100644 --- a/src/hooks/fire-and-forget.ts +++ b/src/hooks/fire-and-forget.ts @@ -20,6 +20,7 @@ type FireAndForgetHookState = { queue: FireAndForgetHookJob[]; }; +/** Queue limits for bounded fire-and-forget hook execution. */ export type FireAndForgetBoundedHookOptions = { maxConcurrency?: number; maxQueue?: number; @@ -65,6 +66,7 @@ function replaceLogControlCharacters(value: string): string { return result; } +/** Format hook errors as bounded single-line log messages with secrets redacted upstream. */ export function formatHookErrorForLog(err: unknown): string { const formatted = replaceLogControlCharacters(formatErrorMessage(err)) .replace(/\s+/g, " ") @@ -72,6 +74,7 @@ export function formatHookErrorForLog(err: unknown): string { return (formatted || "unknown error").slice(0, MAX_HOOK_LOG_MESSAGE_LENGTH); } +/** Run a hook promise without awaiting it, logging rejection safely. */ export function fireAndForgetHook( task: Promise, label: string, @@ -92,6 +95,8 @@ function runFireAndForgetHookJob( const timeout = job.timeoutMs > 0 ? setTimeout(() => { + // Timeout is informational only; the hook promise may still settle + // later, but the log should not double-report an eventual rejection. didLogTimeout = true; job.logger(`${job.label}: timed out after ${job.timeoutMs}ms`); }, job.timeoutMs) @@ -126,6 +131,7 @@ function drainFireAndForgetHookQueue( } } +/** Queue a fire-and-forget hook with bounded concurrency, queue depth, and timeout logs. */ export function fireAndForgetBoundedHook( task: () => Promise, label: string, diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts index ff9032e3fd54..80f523a1c1ce 100644 --- a/src/hooks/frontmatter.ts +++ b/src/hooks/frontmatter.ts @@ -19,6 +19,7 @@ import type { ParsedHookFrontmatter, } from "./types.js"; +/** Parse HOOK.md frontmatter into the generic hook frontmatter record. */ export function parseFrontmatter(content: string): ParsedHookFrontmatter { return parseFrontmatterBlock(content); } @@ -45,6 +46,7 @@ function parseInstallSpec(input: unknown): HookInstallSpec | undefined { return spec; } +/** Resolve OpenClaw hook metadata from the manifest block in HOOK.md frontmatter. */ export function resolveOpenClawMetadata( frontmatter: ParsedHookFrontmatter, ): OpenClawHookMetadata | undefined { @@ -69,6 +71,7 @@ export function resolveOpenClawMetadata( }; } +/** Resolve invocation policy from top-level hook frontmatter flags. */ export function resolveHookInvocationPolicy( frontmatter: ParsedHookFrontmatter, ): HookInvocationPolicy { @@ -77,6 +80,7 @@ export function resolveHookInvocationPolicy( }; } +/** Resolve the config key for a hook, honoring metadata hookKey overrides. */ export function resolveHookKey(hookName: string, entry?: HookEntry): string { return entry?.metadata?.hookKey ?? hookName; } diff --git a/src/hooks/gmail-watcher-errors.ts b/src/hooks/gmail-watcher-errors.ts index f61debbf999e..d085aa8db4b4 100644 --- a/src/hooks/gmail-watcher-errors.ts +++ b/src/hooks/gmail-watcher-errors.ts @@ -1,5 +1,6 @@ const ADDRESS_IN_USE_RE = /address already in use|EADDRINUSE/i; +/** Detect watcher startup failures caused by an occupied bind port. */ export function isAddressInUseError(line: string): boolean { return ADDRESS_IN_USE_RE.test(line); } diff --git a/src/hooks/gmail-watcher-lifecycle.ts b/src/hooks/gmail-watcher-lifecycle.ts index 988485177538..66464918019e 100644 --- a/src/hooks/gmail-watcher-lifecycle.ts +++ b/src/hooks/gmail-watcher-lifecycle.ts @@ -2,12 +2,14 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { startGmailWatcher } from "./gmail-watcher.js"; +/** Logging surface used while starting the Gmail watcher during gateway startup. */ export type GMailWatcherLog = { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; }; +/** Start the Gmail watcher with startup logs and env-based skip handling. */ export async function startGmailWatcherWithLogs(params: { cfg: OpenClawConfig; log: GMailWatcherLog; @@ -16,6 +18,8 @@ export async function startGmailWatcherWithLogs(params: { signal?: AbortSignal; }) { if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) { + // Test and local recovery paths use the env skip to avoid starting a long + // lived watcher while still exercising gateway startup. params.onSkipped?.(); return; } diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts index 7996fc322663..706af3daa6ad 100644 --- a/src/hooks/hooks.ts +++ b/src/hooks/hooks.ts @@ -1,5 +1,7 @@ +/** Public hook handler alias exposed to bundled/workspace hook modules. */ export type HookHandler = import("./internal-hook-types.js").InternalHookHandler; +/** Public hook API facade for hook modules that should not import internals directly. */ export type { AgentBootstrapHookContext } from "./internal-hooks.js"; export { createInternalHookEvent as createHookEvent, diff --git a/src/hooks/install.runtime.ts b/src/hooks/install.runtime.ts index 38ab18a921c6..a83d13bdd8e3 100644 --- a/src/hooks/install.runtime.ts +++ b/src/hooks/install.runtime.ts @@ -22,8 +22,10 @@ import { import { readJson } from "../infra/json-files.js"; import { isPathInside, isPathInsideWithRealpath } from "../security/scan-paths.js"; +/** Runtime-only install dependencies for hook install/update paths. */ export type { NpmIntegrityDrift, NpmSpecResolution }; +/** Lazy facade kept separate so hook metadata paths do not eagerly load install tooling. */ export { ensureInstallTargetAvailable, pathExists as fileExists, diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 0d11b863c548..1911a989c610 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -15,6 +15,7 @@ async function loadHookInstallRuntime() { return hookInstallRuntimePromise; } +/** Logger contract used by hook install and update operations. */ export type HookInstallLogger = { info?: (message: string) => void; warn?: (message: string) => void; @@ -38,6 +39,7 @@ export type InstallHooksResult = } | { ok: false; error: string }; +/** Integrity drift payload surfaced when npm metadata no longer matches an install record. */ export type HookNpmIntegrityDriftParams = { spec: string; expectedIntegrity: string; @@ -85,6 +87,7 @@ function validateHookId(hookId: string): string | null { return null; } +/** Resolve the canonical local install directory for one hook pack id. */ export function resolveHookInstallDir(hookId: string, hooksDir?: string): string { const hooksBase = hooksDir ? resolveUserPath(hooksDir) : path.join(CONFIG_DIR, "hooks"); const hookIdError = validateHookId(hookId); @@ -157,6 +160,8 @@ async function installFromResolvedHookDir( ): Promise { const runtime = await loadHookInstallRuntime(); const manifestPath = path.join(resolvedDir, "package.json"); + // A directory with package.json is a hook pack. A bare hook directory must + // contain HOOK.md plus a handler file and installs as a single hook. if (await runtime.fileExists(manifestPath)) { return await installHookPackageFromDir({ packageDir: resolvedDir, @@ -261,6 +266,8 @@ async function installHookPackageFromDir( const resolvedHooks = [] as string[]; for (const entry of hookEntries) { const hookDir = path.resolve(params.packageDir, entry); + // Validate both lexical containment and realpath containment so archive + // symlinks cannot make package hook entries escape after extraction. if (!runtime.isPathInside(params.packageDir, hookDir)) { return { ok: false, @@ -372,6 +379,7 @@ async function installHookFromDir(params: { return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir }; } +/** Install hooks from an archive after extracting and validating the archive root. */ export async function installHooksFromArchive( params: HookArchiveInstallParams, ): Promise { @@ -404,6 +412,7 @@ export async function installHooksFromArchive( }); } +/** Download, verify, and install an npm hook pack tarball. */ export async function installHooksFromNpmSpec(params: { spec: string; dangerouslyForceUnsafeInstall?: boolean; @@ -446,6 +455,7 @@ export async function installHooksFromNpmSpec(params: { }); } +/** Install a hook pack or single hook from a local directory/archive path. */ export async function installHooksFromPath( params: HookPathInstallParams, ): Promise { diff --git a/src/hooks/installs.ts b/src/hooks/installs.ts index 3822efe37f03..d5142d21ba06 100644 --- a/src/hooks/installs.ts +++ b/src/hooks/installs.ts @@ -1,8 +1,10 @@ import type { HookInstallRecord } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Install record plus the hook pack id being updated in config. */ export type HookInstallUpdate = HookInstallRecord & { hookId: string }; +/** Return config with one hook install record merged into hooks.internal.installs. */ export function recordHookInstall(cfg: OpenClawConfig, update: HookInstallUpdate): OpenClawConfig { const { hookId, ...record } = update; const installs = { diff --git a/src/hooks/legacy-config.ts b/src/hooks/legacy-config.ts index 25ea8aaa1bf3..946de980a987 100644 --- a/src/hooks/legacy-config.ts +++ b/src/hooks/legacy-config.ts @@ -12,6 +12,7 @@ type LegacyInternalHooksCarrier = { }; }; +/** Read legacy hooks.internal.handlers entries for backward-compatible config detection. */ export function getLegacyInternalHookHandlers(config: unknown): LegacyInternalHookHandler[] { const handlers = (config as LegacyInternalHooksCarrier)?.hooks?.internal?.handlers; return Array.isArray(handlers) ? handlers : []; diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts index 523f1f8e974f..f504801be770 100644 --- a/src/hooks/plugin-hooks.ts +++ b/src/hooks/plugin-hooks.ts @@ -18,6 +18,7 @@ type PluginHookDirEntry = { pluginId: string; }; +/** Resolve hook directories declared by active plugin manifests. */ export function resolvePluginHookDirs(params: { workspaceDir: string | undefined; config?: OpenClawConfig; @@ -68,6 +69,8 @@ export function resolvePluginHookDirs(params: { if (!memoryDecision.enabled) { continue; } + // Memory plugin hooks follow the same slot winner as runtime memory + // providers so disabled memory implementations cannot register hooks. if (memoryDecision.selected && hasKind(record.kind, "memory")) { selectedMemoryPluginId = record.id; } @@ -82,6 +85,8 @@ export function resolvePluginHookDirs(params: { log.warn(`plugin hook path not found (${record.id}): ${candidate}`); continue; } + // Manifest hook paths are plugin-owned code. Require realpath containment + // so symlinks cannot register hook handlers outside the plugin root. if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) { log.warn(`plugin hook path escapes plugin root (${record.id}): ${candidate}`); continue; diff --git a/src/hooks/policy.ts b/src/hooks/policy.ts index d7343379e267..a9d2748f025e 100644 --- a/src/hooks/policy.ts +++ b/src/hooks/policy.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig, HookConfig } from "../config/config.js"; import { resolveHookKey } from "./frontmatter.js"; import type { HookEntry, HookSource } from "./types.js"; +/** Human-readable reason for disabling a hook at policy resolution time. */ export type HookEnableStateReason = "disabled in config" | "workspace hook (disabled by default)"; type HookEnableState = { @@ -54,10 +55,12 @@ const HOOK_SOURCE_POLICIES: Record = { }, }; +/** Resolve source trust, precedence, default enablement, and override rules. */ function getHookSourcePolicy(source: HookSource): HookSourcePolicy { return HOOK_SOURCE_POLICIES[source]; } +/** Resolve explicit per-hook config by hook key. */ export function resolveHookConfig( config: OpenClawConfig | undefined, hookKey: string, @@ -73,6 +76,7 @@ export function resolveHookConfig( return entry; } +/** Resolve whether a hook is enabled before runtime requirement checks. */ export function resolveHookEnableState(params: { entry: HookEntry; config?: OpenClawConfig; @@ -106,6 +110,7 @@ function canOverrideHook(candidate: HookEntry, existing: HookEntry): boolean { ); } +/** Merge hook entries by name using source precedence and override policy. */ export function resolveHookEntries( entries: HookEntry[], opts?: { @@ -128,6 +133,8 @@ export function resolveHookEntries( merged.set(entry.hook.name, entry); continue; } + // Source policy is asymmetric: higher precedence alone is not enough unless + // both source policies agree the candidate may replace the existing hook. if (canOverrideHook(entry, existing)) { merged.set(entry.hook.name, entry); continue; diff --git a/src/hooks/update.ts b/src/hooks/update.ts index faa1c60228f3..356a89340842 100644 --- a/src/hooks/update.ts +++ b/src/hooks/update.ts @@ -11,13 +11,16 @@ import { } from "./install.js"; import { recordHookInstall } from "./installs.js"; +/** Logger contract for hook pack update operations. */ export type HookPackUpdateLogger = { info?: (message: string) => void; warn?: (message: string) => void; }; +/** Per-pack update status emitted by updateNpmInstalledHookPacks. */ export type HookPackUpdateStatus = "updated" | "unchanged" | "skipped" | "error"; +/** Outcome for one hook pack update attempt. */ export type HookPackUpdateOutcome = { hookId: string; status: HookPackUpdateStatus; @@ -26,12 +29,14 @@ export type HookPackUpdateOutcome = { nextVersion?: string; }; +/** Aggregate update result with the possibly updated config. */ export type HookPackUpdateSummary = { config: OpenClawConfig; changed: boolean; outcomes: HookPackUpdateOutcome[]; }; +/** Integrity drift payload enriched with hook pack identity and dry-run state. */ export type HookPackUpdateIntegrityDriftParams = HookNpmIntegrityDriftParams & { hookId: string; resolvedSpec?: string; @@ -66,6 +71,7 @@ function createHookPackUpdateIntegrityDriftHandler(params: { }; } +/** Update npm-installed hook packs and return config changes plus per-pack outcomes. */ export async function updateNpmInstalledHookPacks(params: { config: OpenClawConfig; logger?: HookPackUpdateLogger; @@ -101,6 +107,8 @@ export async function updateNpmInstalledHookPacks(params: { } const effectiveSpec = params.specOverrides?.[hookId] ?? record.spec; + // Only enforce the stored integrity when the update uses the same spec. + // Spec overrides intentionally resolve a new tarball identity. const expectedIntegrity = effectiveSpec === record.spec ? expectedIntegrityForUpdate(record.spec, record.integrity) diff --git a/src/image-generation/image-assets.ts b/src/image-generation/image-assets.ts index 19c7437521bb..a0774b5f17a1 100644 --- a/src/image-generation/image-assets.ts +++ b/src/image-generation/image-assets.ts @@ -9,6 +9,8 @@ import type { GeneratedImageAsset, ImageGenerationSourceImage } from "./types.js const DEFAULT_IMAGE_MIME_TYPE = "image/png"; const DEFAULT_IMAGE_FILE_PREFIX = "image"; +// Image asset helpers for provider responses and source uploads. They normalize +// base64/data-url inputs into in-memory assets with predictable filenames. export type ImageMimeTypeDetection = { mimeType: string; extension: string; @@ -49,6 +51,8 @@ export function imageFileExtensionForMimeType( return slashIndex >= 0 ? normalized.slice(slashIndex + 1) || fallback : fallback; } +// Lightweight magic-byte sniffing for providers that omit mime_type. Keep this +// conservative so unknown formats still use the configured default mime type. export function sniffImageMimeType( buffer: Buffer, fallbackMimeType = DEFAULT_IMAGE_MIME_TYPE, @@ -109,6 +113,8 @@ export function parseImageDataUrl( return { mimeType, base64: canonicalBase64 }; } +// Public conversion path for OpenAI-compatible base64 payloads. Invalid or +// empty base64 returns undefined so callers can choose strict/lenient handling. export function generatedImageAssetFromBase64(params: { base64: string | undefined; index: number; @@ -220,6 +226,8 @@ export function parseOpenAiCompatibleImageResponse( return images; } +// Upload filename contract for edit/reference images. User-provided filenames +// win; generated names follow the same prefix/index/mime logic as outputs. export function imageSourceUploadFileName(params: { image: ImageGenerationSourceImage; index: number; diff --git a/src/image-generation/live-test-helpers.ts b/src/image-generation/live-test-helpers.ts index 7dbaa55c5d1c..99bc479d67bc 100644 --- a/src/image-generation/live-test-helpers.ts +++ b/src/image-generation/live-test-helpers.ts @@ -10,6 +10,8 @@ import { export { parseProviderModelMap, redactLiveApiKey }; +// Default provider/model matrix for image live tests. Provider env filters can +// override these without changing test source. export const DEFAULT_LIVE_IMAGE_MODELS: Record = { deepinfra: "deepinfra/black-forest-labs/FLUX-1-schnell", fal: "fal/fal-ai/flux/dev", @@ -21,6 +23,8 @@ export const DEFAULT_LIVE_IMAGE_MODELS: Record = { xai: "xai/grok-imagine-image", }; +// Case filters are intentionally lowercased because test case names are local +// labels, unlike provider ids/models that may be case-sensitive. export function parseCaseFilter(raw?: string): Set | null { const trimmed = raw?.trim(); if (!trimmed || trimmed === "all") { diff --git a/src/image-generation/model-ref.ts b/src/image-generation/model-ref.ts index dbfb5a003339..374406475fa1 100644 --- a/src/image-generation/model-ref.ts +++ b/src/image-generation/model-ref.ts @@ -1,5 +1,7 @@ import { parseGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; +// Image model refs share the generic media-generation provider/model grammar: +// "provider/model" when explicit, otherwise null for default resolution. export function parseImageGenerationModelRef( raw: string | undefined, ): { provider: string; model: string } | null { diff --git a/src/image-generation/normalization.ts b/src/image-generation/normalization.ts index abade496490b..be3db6e0a3d9 100644 --- a/src/image-generation/normalization.ts +++ b/src/image-generation/normalization.ts @@ -49,6 +49,8 @@ export function resolveImageGenerationOverrides(params: { inputImages?: ImageGenerationSourceImage[]; }): ResolvedImageGenerationOverrides { const hasInputImages = (params.inputImages?.length ?? 0) > 0; + // Edit and generate modes can expose different knobs for the same provider, + // so normalize requested overrides against the active mode only. const modeCaps = hasInputImages ? params.provider.capabilities.edit : params.provider.capabilities.generate; @@ -90,6 +92,8 @@ export function resolveImageGenerationOverrides(params: { if (!modeCaps.supportsSize && size) { let translated = false; if (modeCaps.supportsAspectRatio) { + // Prefer translating size into aspect ratio when the provider supports + // shape but not exact dimensions; otherwise report the size as ignored. const normalizedAspectRatio = resolveClosestAspectRatio({ requestedAspectRatio: aspectRatio, requestedSize: size, @@ -138,6 +142,8 @@ export function resolveImageGenerationOverrides(params: { : undefined; let translated = false; if (derivedSize) { + // Reverse translation lets size-only providers still honor common + // landscape/portrait requests when a supported size is close enough. size = derivedSize; normalization.size = { applied: derivedSize, @@ -206,6 +212,8 @@ export function resolveImageGenerationOverrides(params: { aspectRatio && ((!params.aspectRatio && params.size) || params.aspectRatio !== aspectRatio) ) { + // Record derived aspect ratios even when the applied value is already in + // place, otherwise callers cannot explain why a size became a shape. const entry: MediaNormalizationEntry = { applied: aspectRatio, ...(params.aspectRatio ? { requested: params.aspectRatio } : {}), diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 6199e850d15f..879c00cb67df 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -20,6 +20,8 @@ import type { ImageGenerationSourceImage, } from "./types.js"; +// Factory for providers that expose OpenAI-style /images/generations and +// /images/edits endpoints while still allowing provider-specific bodies. type ModelProviderConfig = NonNullable["providers"]>[string]; export type OpenAiCompatibleImageRequestMode = "generate" | "edit"; @@ -150,6 +152,8 @@ export function createOpenAiCompatibleImageGenerationProvider( capabilities: options.capabilities, async generateImage(req): Promise { const inputImages = req.inputImages ?? []; + // Reference images switch the request to edit mode; providers can still + // disable edits or cap reference count through capabilities. const mode: OpenAiCompatibleImageRequestMode = inputImages.length > 0 ? "edit" : "generate"; const maxInputImages = options.capabilities.edit.maxInputImages; if (mode === "edit" && !options.capabilities.edit.enabled) { @@ -221,6 +225,8 @@ export function createOpenAiCompatibleImageGenerationProvider( ? options.buildEditRequest({ ...requestParams, mode }) : options.buildGenerateRequest({ ...requestParams, mode }); const timeoutMs = resolveRequestTimeoutMs({ options, req, mode }); + // Multipart requests must let FormData set its own boundary header, while + // JSON requests need an explicit content type after configured headers. const request = requestBody.kind === "multipart" ? postMultipartRequest({ diff --git a/src/image-generation/provider-registry.ts b/src/image-generation/provider-registry.ts index 2dbc0c3c2cfd..ab33ee6553af 100644 --- a/src/image-generation/provider-registry.ts +++ b/src/image-generation/provider-registry.ts @@ -4,6 +4,8 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import * as capabilityProviderRuntime from "../plugins/capability-provider-runtime.js"; import type { ImageGenerationProviderPlugin } from "../plugins/types.js"; +// Image-generation providers come from plugin capability registration. The +// registry keeps aliases separate from canonical ids for user config lookups. const BUILTIN_IMAGE_GENERATION_PROVIDERS: readonly ImageGenerationProviderPlugin[] = []; const UNSAFE_PROVIDER_IDS = new Set(["__proto__", "constructor", "prototype"]); @@ -39,6 +41,8 @@ function buildProviderMaps(cfg?: OpenClawConfig): { if (!isSafeImageGenerationProviderId(id)) { return; } + // Canonical list output is one entry per provider; aliases only affect + // lookup so duplicate aliases cannot duplicate providers in UI/config. canonical.set(id, provider); aliases.set(id, provider); for (const alias of provider.aliases ?? []) { diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 14fb2857d61f..4af35ae62dbf 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -20,6 +20,8 @@ import type { ImageGenerationResult } from "./types.js"; const log = createSubsystemLogger("image-generation"); +// Runtime dependency seam for tests and plugin-host callers. Production uses +// the plugin registry and provider-env helpers by default. export type ImageGenerationRuntimeDeps = { getProvider?: typeof getImageGenerationProvider; listProviders?: typeof listImageGenerationProviders; @@ -75,6 +77,8 @@ export async function generateImage( const attempts: FallbackAttempt[] = []; let lastError: unknown; + // Try configured/fallback models in order and return the first provider that + // yields at least one image; failed attempts are preserved for diagnostics. for (const candidate of candidates) { const provider = getProvider(candidate.provider, params.cfg); if (!provider) { @@ -107,6 +111,8 @@ export async function generateImage( background: params.background, inputImages: params.inputImages, }); + // Providers receive only supported overrides. Ignored/normalized values + // are returned to callers so user-facing replies can explain adjustments. const result: ImageGenerationResult = await provider.generateImage({ provider: candidate.provider, model: candidate.model, diff --git a/src/infra/abort-signal.ts b/src/infra/abort-signal.ts index 77922784eda4..f3f809d9c942 100644 --- a/src/infra/abort-signal.ts +++ b/src/infra/abort-signal.ts @@ -1,9 +1,12 @@ +/** Resolves when the signal aborts, or immediately when no wait is needed. */ export async function waitForAbortSignal(signal?: AbortSignal): Promise { if (!signal || signal.aborted) { return; } await new Promise((resolve) => { const onAbort = () => { + // Remove explicitly even with `{ once: true }`; tests use foreign + // AbortSignal-like objects, and cleanup must stay deterministic there. signal.removeEventListener("abort", onAbort); resolve(); }; diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 57323ad0ba8a..79b387e7fc8d 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -2,6 +2,7 @@ import type { VerboseLevel } from "../auto-reply/thinking.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { notifyListeners, registerListener } from "../shared/listeners.js"; +/** Stream name for agent events delivered to gateway listeners and plugin host hooks. */ export type AgentEventStream = | "lifecycle" | "tool" @@ -16,8 +17,11 @@ export type AgentEventStream = | "thinking" | (string & {}); +/** Lifecycle phase for a visible item in the agent activity feed. */ export type AgentItemEventPhase = "start" | "update" | "end"; +/** Status rendered for an item-level agent activity event. */ export type AgentItemEventStatus = "running" | "completed" | "failed" | "blocked"; +/** Item category used by channels and Control UI to choose progress presentation. */ export type AgentItemEventKind = | "tool" | "command" @@ -26,6 +30,7 @@ export type AgentItemEventKind = | "analysis" | (string & {}); +/** Payload for a single item shown in the agent activity stream. */ export type AgentItemEventData = { itemId: string; phase: AgentItemEventPhase; @@ -46,6 +51,7 @@ export type AgentItemEventData = { approvalSlug?: string; }; +/** Plan update payload emitted when an agent publishes or revises its task list. */ export type AgentPlanEventData = { phase: "update"; title: string; @@ -54,10 +60,14 @@ export type AgentPlanEventData = { source?: string; }; +/** Approval event phase for request/resolution transitions. */ export type AgentApprovalEventPhase = "requested" | "resolved"; +/** Approval status after routing, user action, or delivery failure. */ export type AgentApprovalEventStatus = "pending" | "unavailable" | "approved" | "denied" | "failed"; +/** Approval family used by renderers and host hooks. */ export type AgentApprovalEventKind = "exec" | "plugin" | "unknown"; +/** Payload for approval requests and their later resolution events. */ export type AgentApprovalEventData = { phase: AgentApprovalEventPhase; kind: AgentApprovalEventKind; @@ -74,6 +84,7 @@ export type AgentApprovalEventData = { message?: string; }; +/** Incremental command output payload associated with an item/tool call. */ export type AgentCommandOutputEventData = { itemId: string; phase: "delta" | "end"; @@ -87,6 +98,7 @@ export type AgentCommandOutputEventData = { cwd?: string; }; +/** Patch summary payload emitted after an agent applies file changes. */ export type AgentPatchSummaryEventData = { itemId: string; phase: "end"; @@ -99,6 +111,7 @@ export type AgentPatchSummaryEventData = { summary: string; }; +/** Enriched event delivered to subscribers after sequencing and context stamping. */ export type AgentEventPayload = { runId: string; seq: number; @@ -115,6 +128,7 @@ export type AgentEventPayload = { agentId?: string; }; +/** Per-run metadata used to stamp events and gate Control UI visibility. */ export type AgentRunContext = { sessionKey?: string; /** Owning run's sessionId; stamped onto lifecycle events (see AgentEventPayload.sessionId). */ @@ -145,6 +159,7 @@ function getAgentEventState(): AgentEventState { })); } +/** Registers or merges per-run context used by later agent event emissions. */ export function registerAgentRunContext(runId: string, context: AgentRunContext) { if (!runId) { return; @@ -181,10 +196,12 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext) } } +/** Returns the currently registered context for a run, if it has not been cleared or swept. */ export function getAgentRunContext(runId: string) { return getAgentEventState().runContextById.get(runId); } +/** Clears context and sequence state for a run that has ended or been discarded. */ export function clearAgentRunContext(runId: string) { const state = getAgentEventState(); state.runContextById.delete(runId); @@ -213,11 +230,13 @@ export function sweepStaleRunContexts(maxAgeMs = 30 * 60 * 1000): number { return swept; } +/** Clears run context state without removing event listeners; test-only helper. */ export function resetAgentRunContextForTest() { getAgentEventState().runContextById.clear(); getAgentEventState().seqByRun.clear(); } +/** Emits an agent event after assigning per-run sequence, timestamp, and context metadata. */ export function emitAgentEvent(event: Omit) { const state = getAgentEventState(); const nextSeq = (state.seqByRun.get(event.runId) ?? 0) + 1; @@ -251,6 +270,7 @@ export function emitAgentEvent(event: Omit) { notifyListeners(state.listeners, enriched); } +/** Emits an item activity event on the shared agent event bus. */ export function emitAgentItemEvent(params: { runId: string; data: AgentItemEventData; @@ -264,6 +284,7 @@ export function emitAgentItemEvent(params: { }); } +/** Emits a plan update event on the shared agent event bus. */ export function emitAgentPlanEvent(params: { runId: string; data: AgentPlanEventData; @@ -277,6 +298,7 @@ export function emitAgentPlanEvent(params: { }); } +/** Emits an approval event on the shared agent event bus. */ export function emitAgentApprovalEvent(params: { runId: string; data: AgentApprovalEventData; @@ -290,6 +312,7 @@ export function emitAgentApprovalEvent(params: { }); } +/** Emits command output for a running or completed item/tool call. */ export function emitAgentCommandOutputEvent(params: { runId: string; data: AgentCommandOutputEventData; @@ -303,6 +326,7 @@ export function emitAgentCommandOutputEvent(params: { }); } +/** Emits a patch summary for a completed file-editing item/tool call. */ export function emitAgentPatchSummaryEvent(params: { runId: string; data: AgentPatchSummaryEventData; @@ -316,11 +340,13 @@ export function emitAgentPatchSummaryEvent(params: { }); } +/** Subscribes to sequenced agent events; returns an unsubscribe callback. */ export function onAgentEvent(listener: (evt: AgentEventPayload) => void) { const state = getAgentEventState(); return registerListener(state.listeners, listener); } +/** Clears all agent event state, including listeners; test-only helper. */ export function resetAgentEventsForTest() { const state = getAgentEventState(); state.seqByRun.clear(); diff --git a/src/infra/approval-display-paths.ts b/src/infra/approval-display-paths.ts index a2c2d5696ec9..283089efebdf 100644 --- a/src/infra/approval-display-paths.ts +++ b/src/infra/approval-display-paths.ts @@ -1,3 +1,4 @@ +/** Formats user-home paths compactly for approval prompts without normalizing unsafe paths. */ export function formatApprovalDisplayPath(value: string): string { const normalized = value.trim(); if (!normalized || hasRelativePathSegment(normalized)) { @@ -6,11 +7,14 @@ export function formatApprovalDisplayPath(value: string): string { const unixHomeMatch = normalized.match(/^\/(?:home|Users)\/([^/]+)(.*)$/); if (unixHomeMatch && isSafeHomeSegment(unixHomeMatch[1])) { + // Use display-only home compaction for both Linux and macOS paths; approval matching still uses + // the original path value. return compactHomeSuffix(unixHomeMatch[2] ?? ""); } const windowsHomeMatch = normalized.match(/^[A-Za-z]:[\\/]Users[\\/]([^\\/]+)(.*)$/i); if (windowsHomeMatch && isSafeHomeSegment(windowsHomeMatch[1])) { + // Normalize slashes only after proving this is a plain Windows user-home path. return compactHomeSuffix(windowsHomeMatch[2] ?? ""); } @@ -26,5 +30,7 @@ function isSafeHomeSegment(segment: string | undefined): boolean { } function hasRelativePathSegment(value: string): boolean { + // Do not compact paths containing `.` or `..`; hiding those segments would make approval prompts + // less precise than the path that will actually be evaluated. return /(^|[\\/])\.{1,2}(?=[\\/]|$)/.test(value); } diff --git a/src/infra/approval-errors.ts b/src/infra/approval-errors.ts index 899f31357f4d..76f2bd71a2e1 100644 --- a/src/infra/approval-errors.ts +++ b/src/infra/approval-errors.ts @@ -15,6 +15,10 @@ function readApprovalNotFoundDetailsReason(value: unknown): string | null { return typeof reason === "string" ? (normalizeOptionalString(reason) ?? null) : null; } +/** + * Detects approval-not-found failures across gateway error shapes. + * Kept broad enough for legacy message-only errors emitted before structured codes. + */ export function isApprovalNotFoundError(err: unknown): boolean { if (!(err instanceof Error)) { return false; diff --git a/src/infra/approval-gateway-resolver.ts b/src/infra/approval-gateway-resolver.ts index 7076b79f0a37..89124804ce3d 100644 --- a/src/infra/approval-gateway-resolver.ts +++ b/src/infra/approval-gateway-resolver.ts @@ -14,6 +14,7 @@ type ResolveApprovalOverGatewayParams = { clientDisplayName?: string; }; +/** Resolves an exec or plugin approval id through the operator approvals gateway. */ export async function resolveApprovalOverGateway( params: ResolveApprovalOverGatewayParams, ): Promise { @@ -43,6 +44,7 @@ export async function resolveApprovalOverGateway( if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) { throw err; } + // Slash commands can omit the plugin prefix; only retry when exec lookup proves no match. await requestResolve("plugin.approval.resolve"); } }, diff --git a/src/infra/approval-handler-adapter-runtime.ts b/src/infra/approval-handler-adapter-runtime.ts index aa5d2d8ebf28..99422d2c0d1d 100644 --- a/src/infra/approval-handler-adapter-runtime.ts +++ b/src/infra/approval-handler-adapter-runtime.ts @@ -5,8 +5,10 @@ import type { } from "./approval-handler-runtime-types.js"; import type { ExecApprovalChannelRuntimeEventKind } from "./exec-approval-channel-runtime.types.js"; +/** Runtime-context capability key used by channels to register native approval resources. */ export const CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY = "approval.native"; +/** Creates an approval runtime adapter that loads heavy channel code only when delivery hooks run. */ export function createLazyChannelApprovalNativeRuntimeAdapter< TPendingPayload = unknown, TPreparedTarget = unknown, diff --git a/src/infra/approval-handler-bootstrap.ts b/src/infra/approval-handler-bootstrap.ts index d5ae6ff7986b..3e56389c1a3f 100644 --- a/src/infra/approval-handler-bootstrap.ts +++ b/src/infra/approval-handler-bootstrap.ts @@ -39,6 +39,7 @@ function formatRetryableApprovalBootstrapStartError(error: unknown): string { return message; } +/** Starts the native approval handler for a channel runtime context and returns its cleanup hook. */ export async function startChannelApprovalHandlerBootstrap(params: { plugin: Pick; cfg: OpenClawConfig; @@ -98,6 +99,7 @@ export async function startChannelApprovalHandlerBootstrap(params: { return; } if (generation !== activeGeneration) { + // Runtime contexts can unregister while the handler factory awaits; stop stale handlers. await handler.stop().catch(() => {}); return; } diff --git a/src/infra/approval-handler-runtime-types.ts b/src/infra/approval-handler-runtime-types.ts index eb9990e10915..599b3e8b7ec7 100644 --- a/src/infra/approval-handler-runtime-types.ts +++ b/src/infra/approval-handler-runtime-types.ts @@ -13,9 +13,12 @@ import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-app export type { ChannelApprovalKind } from "./approval-types.js"; +/** Union of approval request events a native approval handler can receive. */ export type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +/** Union of approval resolution events a native approval handler can finalize. */ export type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; +/** Shared context passed to channel-native approval hooks. */ export type ChannelApprovalCapabilityHandlerContext = { cfg: OpenClawConfig; accountId?: string | null; @@ -23,12 +26,14 @@ export type ChannelApprovalCapabilityHandlerContext = { context?: unknown; }; +/** Result instruction for updating, deleting, clearing, or leaving a delivered approval entry. */ export type ChannelApprovalNativeFinalAction = | { kind: "update"; payload: TPayload } | { kind: "delete" } | { kind: "clear-actions" } | { kind: "leave" }; +/** Availability gate for deciding whether a channel-native approval runtime can handle work. */ export type ChannelApprovalNativeAvailabilityAdapter = { isConfigured: (params: ChannelApprovalCapabilityHandlerContext) => boolean; shouldHandle: ( @@ -36,6 +41,7 @@ export type ChannelApprovalNativeAvailabilityAdapter = { ) => boolean; }; +/** Builds channel-native payloads for pending, resolved, and expired approval views. */ export type ChannelApprovalNativePresentationAdapter< TPendingPayload = unknown, TFinalPayload = unknown, @@ -113,6 +119,7 @@ type ChannelApprovalNativeTransportAdapterForView< ) => Promise; }; +/** Transport hooks for preparing, delivering, updating, and deleting native approval entries. */ export type ChannelApprovalNativeTransportAdapter< TPreparedTarget = unknown, TPendingEntry = unknown, @@ -163,6 +170,7 @@ type ChannelApprovalNativeInteractionAdapterForView< ) => Promise | void; }; +/** Optional hooks for binding and clearing interactive approval controls. */ export type ChannelApprovalNativeInteractionAdapter< TPendingEntry = unknown, TBinding = unknown, @@ -207,12 +215,14 @@ type ChannelApprovalNativeObserveAdapterForView< ) => void; }; +/** Optional observer hooks for delivery errors, duplicates, and successful deliveries. */ export type ChannelApprovalNativeObserveAdapter< TPreparedTarget = unknown, TPendingPayload = unknown, TPendingEntry = unknown, > = ChannelApprovalNativeObserveAdapterForView; +/** Runtime adapter consumed by core after a plugin's strongly typed spec has been erased. */ export type ChannelApprovalNativeRuntimeAdapter< TPendingPayload = unknown, TPreparedTarget = unknown, @@ -234,6 +244,7 @@ export type ChannelApprovalNativeRuntimeAdapter< observe?: ChannelApprovalNativeObserveAdapter; }; +/** Strongly typed plugin spec used to build a channel-native approval runtime adapter. */ export type ChannelApprovalNativeRuntimeSpec< TPendingPayload, TPreparedTarget, diff --git a/src/infra/approval-handler-runtime.ts b/src/infra/approval-handler-runtime.ts index e130342b40ba..72c6e490d294 100644 --- a/src/infra/approval-handler-runtime.ts +++ b/src/infra/approval-handler-runtime.ts @@ -177,10 +177,12 @@ async function applyApprovalFinalAction(params: { phase: params.phase, }); + // `clear-actions` updates interaction controls but leaves the delivered content in place. case "leave": } } +/** Adapts a strongly typed channel native approval spec into the erased runtime contract. */ export function createChannelApprovalNativeRuntimeAdapter< TPendingPayload, TPreparedTarget, @@ -358,6 +360,7 @@ type ChannelApprovalHandlerLifecycleSpec< onStopped?: () => Promise | void; }; +/** Adapter contract used by core to run a channel's native approval delivery lifecycle. */ export type ChannelApprovalHandlerAdapter< TPendingEntry, TPreparedTarget, @@ -382,6 +385,7 @@ export type ChannelApprovalHandlerAdapter< >; }; +/** Creates the shared approval handler runtime from channel-specific content and transport hooks. */ export function createChannelApprovalHandler< TPendingEntry, TPreparedTarget, @@ -429,6 +433,7 @@ export function createChannelApprovalHandler< }); } +/** Builds a shared approval handler from a plugin approval capability, or null when unsupported. */ export async function createChannelApprovalHandlerFromCapability(params: { capability?: Pick | null; label: string; diff --git a/src/infra/approval-handler.test-helpers.ts b/src/infra/approval-handler.test-helpers.ts index c416d2943327..0af194a957fd 100644 --- a/src/infra/approval-handler.test-helpers.ts +++ b/src/infra/approval-handler.test-helpers.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import type { ChannelApprovalNativeRuntimeAdapter } from "./approval-handler-runtime.js"; +// Shared approval-runtime stubs keep channel approval tests focused on route +// behavior instead of rebuilding the native adapter shape. export type ApprovalNativeRuntimeAdapterStubParams = { resolveApprovalKind?: ChannelApprovalNativeRuntimeAdapter["resolveApprovalKind"]; buildResolvedResult?: ChannelApprovalNativeRuntimeAdapter["presentation"]["buildResolvedResult"]; @@ -13,6 +15,7 @@ export type ApprovalNativeRuntimeAdapterStubParams = { bindPending?: NonNullable["bindPending"]; }; +/** Build a complete native approval adapter stub with per-test overrides. */ export function createApprovalNativeRuntimeAdapterStubs( params: ApprovalNativeRuntimeAdapterStubParams = {}, ): ChannelApprovalNativeRuntimeAdapter { diff --git a/src/infra/approval-native-delivery.ts b/src/infra/approval-native-delivery.ts index 5abd6d447963..1f5edd32b2ea 100644 --- a/src/infra/approval-native-delivery.ts +++ b/src/infra/approval-native-delivery.ts @@ -11,12 +11,14 @@ import type { PluginApprovalRequest } from "./plugin-approvals.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +/** One native approval delivery target selected by the channel adapter plan. */ export type ChannelApprovalNativePlannedTarget = { surface: ChannelApprovalNativeSurface; target: ChannelApprovalNativeTarget; reason: "preferred" | "fallback"; }; +/** Complete native approval routing plan, including optional origin-chat notice state. */ export type ChannelApprovalNativeDeliveryPlan = { targets: ChannelApprovalNativePlannedTarget[]; originTarget: ChannelApprovalNativeTarget | null; @@ -34,11 +36,13 @@ function dedupeTargets( continue; } seen.add(key); + // Keep the first surface/reason so origin-preferred plans stay stable when DM targets overlap. deduped.push(target); } return deduped; } +/** Resolves the origin and approver-DM targets a channel should use for native approvals. */ export async function resolveChannelNativeApprovalDeliveryPlan(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/infra/approval-native-route-coordinator.ts b/src/infra/approval-native-route-coordinator.ts index d7d954ca741f..49fc16fd306f 100644 --- a/src/infra/approval-native-route-coordinator.ts +++ b/src/infra/approval-native-route-coordinator.ts @@ -266,6 +266,7 @@ function resolveApprovalRouteNotice(params: { }; } +/** Returns whether a native approval runtime is active for the requested channel/account scope. */ export function hasActiveApprovalNativeRouteRuntime(params: { approvalKind: ChannelApprovalKind; channel?: string | null; @@ -299,6 +300,7 @@ async function maybeFinalizeApprovalRouteNotice(approvalId: string): Promise; channel?: string; @@ -437,6 +440,7 @@ export function createApprovalNativeRouteReporter(params: { }; } +/** Clears in-memory native approval route coordination state between tests. */ export function clearApprovalNativeRouteStateForTest(): void { for (const approvalId of Array.from(pendingApprovalRouteNotices.keys())) { clearPendingApprovalRouteNotice(approvalId); diff --git a/src/infra/approval-native-route-notice.ts b/src/infra/approval-native-route-notice.ts index 717fd938cec5..8656dcf766b4 100644 --- a/src/infra/approval-native-route-notice.ts +++ b/src/infra/approval-native-route-notice.ts @@ -2,6 +2,7 @@ import { sortUniqueStrings } from "@openclaw/normalization-core/string-normaliza import { formatHumanList } from "../shared/human-list.js"; import type { ChannelApprovalNativePlannedTarget } from "./approval-native-delivery.js"; +/** Formats the human destination label for where native approval prompts were delivered. */ export function describeApprovalDeliveryDestination(params: { channelLabel: string; deliveredTargets: readonly ChannelApprovalNativePlannedTarget[]; @@ -12,6 +13,7 @@ export function describeApprovalDeliveryDestination(params: { : params.channelLabel; } +/** Builds the notice shown in the current chat when approval was routed elsewhere. */ export function resolveApprovalRoutedElsewhereNoticeText( destinations: readonly string[], ): string | null { @@ -26,6 +28,7 @@ export function resolveApprovalRoutedElsewhereNoticeText( )}, not this chat.`; } +/** Builds the fallback slash-command notice when native approval delivery fails. */ export function resolveApprovalDeliveryFailedNoticeText(params: { approvalId: string; approvalKind: "exec" | "plugin"; @@ -35,6 +38,8 @@ export function resolveApprovalDeliveryFailedNoticeText(params: { params.approvalKind === "exec" && params.approvalId.length > 8 ? params.approvalId.slice(0, 8) : params.approvalId; + // Exec approval ids are long command ids in chat UX; plugin ids can be short + // semantic ids, so only shorten exec ids and keep the full-id fallback visible. const decisions = ( params.allowedDecisions?.length ? params.allowedDecisions diff --git a/src/infra/approval-native-runtime-types.ts b/src/infra/approval-native-runtime-types.ts index bce0b21572ff..54c9d8b71705 100644 --- a/src/infra/approval-native-runtime-types.ts +++ b/src/infra/approval-native-runtime-types.ts @@ -1,11 +1,13 @@ import type { ChannelApprovalNativePlannedTarget } from "./approval-native-delivery.js"; import type { ChannelApprovalKind } from "./approval-types.js"; +/** Prepared delivery target plus the stable key used to avoid duplicate native messages. */ export type PreparedChannelNativeApprovalTarget = { dedupeKey: string; target: TPreparedTarget; }; +/** Channel hooks that prepare adapter targets and deliver pending approval content. */ export type ChannelNativeApprovalTransportSpec< TPendingEntry, TPreparedTarget, @@ -30,6 +32,7 @@ export type ChannelNativeApprovalTransportSpec< }) => TPendingEntry | null | Promise; }; +/** Optional observer hooks for per-target native approval delivery outcomes. */ export type ChannelNativeApprovalDeliveryCallbacks< TPendingEntry, TPreparedTarget, diff --git a/src/infra/approval-native-runtime.ts b/src/infra/approval-native-runtime.ts index cefff8771838..62fd1eb0cb1f 100644 --- a/src/infra/approval-native-runtime.ts +++ b/src/infra/approval-native-runtime.ts @@ -34,6 +34,7 @@ type ChannelNativeApprovalPlanDeliveryResult = { deliveredTargets: ChannelApprovalNativePlannedTarget[]; }; +/** Delivers an approval request to the adapter-planned native targets and returns pending entries. */ export async function deliverApprovalRequestViaChannelNativePlan< TPreparedTarget, TPendingEntry, @@ -93,6 +94,7 @@ export async function deliverApprovalRequestViaChannelNativePlan< if (!preparedTarget) { continue; } + // Dedupe after preparation because different surfaces can converge on the same message target. if (deliveredKeys.has(preparedTarget.dedupeKey)) { params.onDuplicateSkipped?.({ plannedTarget, @@ -170,6 +172,7 @@ type ChannelNativeApprovalRuntimeAdapter< onStopped?: () => Promise | void; }; +/** Creates the shared gateway approval runtime backed by channel-native delivery hooks. */ export function createChannelNativeApprovalRuntime< TPendingEntry, TPreparedTarget, diff --git a/src/infra/approval-native-target-key.ts b/src/infra/approval-native-target-key.ts index c8d0c5980b56..4063d6bea1c5 100644 --- a/src/infra/approval-native-target-key.ts +++ b/src/infra/approval-native-target-key.ts @@ -1,6 +1,7 @@ import type { ChannelApprovalNativeTarget } from "../channels/plugins/approval-native.types.js"; import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; +/** Builds the stable dedupe key used to compare channel-native approval targets. */ export function buildChannelApprovalNativeTargetKey(target: ChannelApprovalNativeTarget): string { return channelRouteDedupeKey({ to: target.to, diff --git a/src/infra/approval-request-account-binding.ts b/src/infra/approval-request-account-binding.ts index e3ab21471fe8..cf1283d4fb58 100644 --- a/src/infra/approval-request-account-binding.ts +++ b/src/infra/approval-request-account-binding.ts @@ -26,6 +26,7 @@ function normalizeOptionalChannel(value?: string | null): string | undefined { return normalizeMessageChannel(value); } +/** Loads the persisted session entry referenced by an approval request, if still present. */ export function resolvePersistedApprovalRequestSessionEntry(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; @@ -61,6 +62,7 @@ function resolvePersistedApprovalRequestSessionBinding(params: { return channel || accountId ? { channel, accountId } : null; } +/** Resolves the account id an approval request belongs to for an optional channel filter. */ export function resolveApprovalRequestAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; @@ -88,6 +90,7 @@ export function resolveApprovalRequestAccountId(params: { return sessionBinding?.accountId ?? null; } +/** Resolves an approval request account only when the request can be routed to a channel. */ export function resolveApprovalRequestChannelAccountId(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; @@ -102,10 +105,13 @@ export function resolveApprovalRequestChannelAccountId(params: { return resolveApprovalRequestAccountId(params); } + // A conflicting turn-source channel is authoritative for live routing; only + // fall back to the persisted session when that stored binding names this channel. const sessionBinding = resolvePersistedApprovalRequestSessionBinding(params); return sessionBinding?.channel === expectedChannel ? (sessionBinding.accountId ?? null) : null; } +/** Checks whether a channel/account pair is eligible to handle an approval request. */ export function doesApprovalRequestMatchChannelAccount(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; diff --git a/src/infra/approval-request-filters.ts b/src/infra/approval-request-filters.ts index 62c1a5018342..4e70b367092a 100644 --- a/src/infra/approval-request-filters.ts +++ b/src/infra/approval-request-filters.ts @@ -2,11 +2,13 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +/** Minimal approval request identity used by agent/session filter checks. */ export type ApprovalRequestFilterInput = { agentId?: string | null; sessionKey?: string | null; }; +/** Matches session filters as literal substrings first, then bounded safe regexes. */ export function matchesApprovalRequestSessionFilter( sessionKey: string, patterns: string[], @@ -20,6 +22,10 @@ export function matchesApprovalRequestSessionFilter( }); } +/** + * Applies optional approval request filters for agent ids and session keys. + * Agent id can be parsed from the session key only when the caller opts in. + */ export function matchesApprovalRequestFilters(params: { request: ApprovalRequestFilterInput; agentFilter?: string[]; diff --git a/src/infra/approval-turn-source.ts b/src/infra/approval-turn-source.ts index 1e8d8ac4a41a..4e6975231491 100644 --- a/src/infra/approval-turn-source.ts +++ b/src/infra/approval-turn-source.ts @@ -1,6 +1,7 @@ import { getRuntimeConfig } from "../config/config.js"; import { resolveApprovalInitiatingSurfaceState } from "./exec-approval-surface.js"; +/** Returns whether approval replies can route back to the turn's initiating surface. */ export function hasApprovalTurnSourceRoute(params: { turnSourceChannel?: string | null; turnSourceAccountId?: string | null; diff --git a/src/infra/approval-types.ts b/src/infra/approval-types.ts index 67e1372e06c3..73feffa5773b 100644 --- a/src/infra/approval-types.ts +++ b/src/infra/approval-types.ts @@ -1 +1,2 @@ +// Approval kind is shared by exec and plugin approval routing surfaces. export type ChannelApprovalKind = "exec" | "plugin"; diff --git a/src/infra/approval-view-model.ts b/src/infra/approval-view-model.ts index cd17fd268389..617c1b69e927 100644 --- a/src/infra/approval-view-model.ts +++ b/src/infra/approval-view-model.ts @@ -101,6 +101,7 @@ function buildPluginViewBase( }; } +/** Builds the presentation model for an unresolved exec or plugin approval. */ export function buildPendingApprovalView(request: ApprovalRequest): PendingApprovalView { if (request.id.startsWith("plugin:")) { const pluginRequest = request as PluginApprovalRequest; @@ -125,6 +126,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro }; } +/** Builds the presentation model for an approval after a decision was recorded. */ export function buildResolvedApprovalView( request: ApprovalRequest, resolved: ApprovalResolved, @@ -145,6 +147,7 @@ export function buildResolvedApprovalView( }; } +/** Builds the presentation model shown when an approval can no longer be acted on. */ export function buildExpiredApprovalView(request: ApprovalRequest): ExpiredApprovalView { if (request.id.startsWith("plugin:")) { return buildPluginViewBase(request as PluginApprovalRequest, "expired"); diff --git a/src/infra/approval-view-model.types.ts b/src/infra/approval-view-model.types.ts index ffcce2068677..a153852d9439 100644 --- a/src/infra/approval-view-model.types.ts +++ b/src/infra/approval-view-model.types.ts @@ -10,6 +10,7 @@ import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-app type ApprovalPhase = "pending" | "resolved" | "expired"; +/** Button or command action shown with a pending approval prompt. */ export type ApprovalActionView = { kind?: "command" | "decision"; decision: ExecApprovalDecision; @@ -18,6 +19,7 @@ export type ApprovalActionView = { command: string; }; +/** Label/value metadata row rendered with an approval prompt. */ export type ApprovalMetadataView = { label: string; value: string; @@ -32,6 +34,7 @@ type ApprovalViewBase = { metadata: ApprovalMetadataView[]; }; +/** Shared presentation fields for exec approval views across all phases. */ export type ExecApprovalViewBase = ApprovalViewBase & { approvalKind: "exec"; ask?: string | null; @@ -47,22 +50,26 @@ export type ExecApprovalViewBase = ApprovalViewBase & { sessionKey?: string | null; }; +/** Pending exec approval view, including executable reply actions. */ export type ExecApprovalPendingView = ExecApprovalViewBase & { phase: "pending"; actions: ApprovalActionView[]; expiresAtMs: number; }; +/** Resolved exec approval view with the recorded decision. */ export type ExecApprovalResolvedView = ExecApprovalViewBase & { phase: "resolved"; decision: ExecApprovalDecision; resolvedBy?: string | null; }; +/** Expired exec approval view without reply actions. */ export type ExecApprovalExpiredView = ExecApprovalViewBase & { phase: "expired"; }; +/** Shared presentation fields for plugin approval views across all phases. */ export type PluginApprovalViewBase = ApprovalViewBase & { approvalKind: "plugin"; agentId?: string | null; @@ -71,26 +78,35 @@ export type PluginApprovalViewBase = ApprovalViewBase & { severity: "info" | "warning" | "critical"; }; +/** Pending plugin approval view, including executable reply actions. */ export type PluginApprovalPendingView = PluginApprovalViewBase & { phase: "pending"; actions: ApprovalActionView[]; expiresAtMs: number; }; +/** Resolved plugin approval view with the recorded decision. */ export type PluginApprovalResolvedView = PluginApprovalViewBase & { phase: "resolved"; decision: ExecApprovalDecision; resolvedBy?: string | null; }; +/** Expired plugin approval view without reply actions. */ export type PluginApprovalExpiredView = PluginApprovalViewBase & { phase: "expired"; }; +/** Any pending approval view that still accepts a user decision. */ export type PendingApprovalView = ExecApprovalPendingView | PluginApprovalPendingView; +/** Any approval view after a decision was recorded. */ export type ResolvedApprovalView = ExecApprovalResolvedView | PluginApprovalResolvedView; +/** Any approval view after it can no longer be acted on. */ export type ExpiredApprovalView = ExecApprovalExpiredView | PluginApprovalExpiredView; +/** Discriminated approval presentation model consumed by channel/UI renderers. */ export type ApprovalViewModel = PendingApprovalView | ResolvedApprovalView | ExpiredApprovalView; +/** Stored approval request variants accepted by the view-model builders. */ export type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +/** Stored approval resolution variants accepted by resolved view builders. */ export type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; diff --git a/src/infra/archive-path.ts b/src/infra/archive-path.ts index 5fb5de9aa633..b22f6fe83176 100644 --- a/src/infra/archive-path.ts +++ b/src/infra/archive-path.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; + +// Archive path facade kept in infra so callers share one traversal policy. export { isWindowsDrivePath, normalizeArchiveEntryPath, diff --git a/src/infra/archive.ts b/src/infra/archive.ts index edd80d1bcda5..26d981a804bd 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; + +// Archive extraction facade for size limits, staged writes, and traversal checks. export { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, diff --git a/src/infra/backoff.ts b/src/infra/backoff.ts index 9b3d55b4286d..893ad2b5d684 100644 --- a/src/infra/backoff.ts +++ b/src/infra/backoff.ts @@ -1,18 +1,25 @@ import { clampPositiveTimerTimeoutMs } from "../shared/number-coercion.js"; +/** Exponential backoff settings for retry loops that need bounded jitter. */ export type BackoffPolicy = { + /** Delay in milliseconds for attempt 1 and any lower attempt value. */ initialMs: number; + /** Hard upper bound in milliseconds after exponential growth and jitter. */ maxMs: number; + /** Multiplier applied once per retry attempt after the first. */ factor: number; + /** Fraction of the current base delay used as additive random jitter. */ jitter: number; }; +/** Computes a bounded exponential delay for a 1-based retry attempt. */ export function computeBackoff(policy: BackoffPolicy, attempt: number) { const base = policy.initialMs * policy.factor ** Math.max(attempt - 1, 0); const jitter = base * policy.jitter * Math.random(); return Math.min(policy.maxMs, Math.round(base + jitter)); } +/** Sleeps for a clamped timer duration and rejects with a stable aborted error on abort. */ export async function sleepWithAbort(ms: number, abortSignal?: AbortSignal) { const delayMs = clampPositiveTimerTimeoutMs(ms); if (delayMs === undefined) { diff --git a/src/infra/boundary-file-read.ts b/src/infra/boundary-file-read.ts index 036ab82b1686..1460ee5b4ac0 100644 --- a/src/infra/boundary-file-read.ts +++ b/src/infra/boundary-file-read.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Root-scoped file open helpers. Use these for user paths that must stay under +// an already trusted boundary. export { canUseRootFileOpen, matchRootFileOpenFailure, diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index 3bf8520704ad..e4bcc18f02df 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Boundary path resolution keeps alias expansion and realpath checks in one +// shared contract before file IO happens. export { ROOT_PATH_ALIAS_POLICIES, resolvePathViaExistingAncestorSync, diff --git a/src/infra/brew.ts b/src/infra/brew.ts index 8f87c927947a..e72baaca9717 100644 --- a/src/infra/brew.ts +++ b/src/infra/brew.ts @@ -34,6 +34,7 @@ function resolveBrewFromPath(pathEnv = process.env.PATH): string | undefined { return undefined; } +/** Returns standard Homebrew bin directories suitable for PATH augmentation. */ export function resolveBrewPathDirs(opts?: BrewResolutionOptions): string[] { const homeDir = opts?.homeDir ?? os.homedir(); @@ -50,9 +51,12 @@ export function resolveBrewPathDirs(opts?: BrewResolutionOptions): string[] { return dirs; } +/** Resolves an executable `brew` path from trusted PATH entries or standard install roots. */ export function resolveBrewExecutable(opts?: BrewResolutionOptions): string | undefined { const homeDir = opts?.homeDir ?? os.homedir(); + // Use the real process PATH, not opts.env, because callers may pass workspace + // env loaded from untrusted project state. const pathBrew = resolveBrewFromPath(); if (pathBrew) { return pathBrew; diff --git a/src/infra/browser-open.ts b/src/infra/browser-open.ts index c4e015329471..d353cf2d5189 100644 --- a/src/infra/browser-open.ts +++ b/src/infra/browser-open.ts @@ -4,6 +4,8 @@ import { detectBinary } from "./detect-binary.js"; import { getWindowsInstallRoots } from "./windows-install-roots.js"; import { isWSL } from "./wsl.js"; +// Browser opening is best-effort and platform-specific; callers get a resolved +// command first so UI can explain why open-in-browser is unavailable. type BrowserOpenCommand = { argv: string[] | null; reason?: string; @@ -40,6 +42,7 @@ function normalizeBrowserOpenUrl(raw: string): string | null { } } +/** Resolve the platform command used to open an HTTP(S) URL in a browser. */ export async function resolveBrowserOpenCommand(): Promise { const platform = process.platform; const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); @@ -88,6 +91,7 @@ export async function resolveBrowserOpenCommand(): Promise { return { argv: null, reason: "unsupported-platform" }; } +/** Report whether browser opening is currently available. */ export async function detectBrowserOpenSupport(): Promise { const resolved = await resolveBrowserOpenCommand(); if (!resolved.argv) { @@ -96,6 +100,7 @@ export async function detectBrowserOpenSupport(): Promise { return { ok: true, command: resolved.command }; } +/** Open a safe HTTP(S) URL in the user's browser when the platform supports it. */ export async function openUrl(url: string): Promise { if (shouldSkipBrowserOpenInTests()) { return false; diff --git a/src/infra/channel-activity.ts b/src/infra/channel-activity.ts index 54a5de8a78e3..b239515c66fd 100644 --- a/src/infra/channel-activity.ts +++ b/src/infra/channel-activity.ts @@ -1,4 +1,6 @@ import type { ChannelId } from "../channels/plugins/channel-id.types.js"; + +/** Direction of the last observed activity for a channel/account pair. */ export type ChannelDirection = "inbound" | "outbound"; type ActivityEntry = { @@ -9,6 +11,7 @@ type ActivityEntry = { const activity = new Map(); function keyFor(channel: ChannelId, accountId: string) { + // Account ids are normalized before keying so omitted/blank ids share the default account slot. return `${channel}:${accountId || "default"}`; } @@ -23,6 +26,7 @@ function ensureEntry(channel: ChannelId, accountId: string): ActivityEntry { return created; } +/** Records the latest inbound or outbound activity timestamp for a channel/account. */ export function recordChannelActivity(params: { channel: ChannelId; accountId?: string | null; @@ -40,6 +44,7 @@ export function recordChannelActivity(params: { } } +/** Returns the latest known inbound/outbound activity timestamps for a channel/account. */ export function getChannelActivity(params: { channel: ChannelId; accountId?: string | null; @@ -53,6 +58,7 @@ export function getChannelActivity(params: { ); } +/** Clears all tracked channel activity; test-only helper. */ export function resetChannelActivityForTest() { activity.clear(); } diff --git a/src/infra/channel-approval-auth.ts b/src/infra/channel-approval-auth.ts index 1e19ae9a52d9..7a48118debb7 100644 --- a/src/infra/channel-approval-auth.ts +++ b/src/infra/channel-approval-auth.ts @@ -9,6 +9,7 @@ type ApprovalCommandAuthorization = { explicit: boolean; }; +/** Resolves whether a chat `/approve` command is authorized by channel-specific approval policy. */ export function resolveApprovalCommandAuthorization(params: { cfg: OpenClawConfig; channel?: string | null; @@ -18,6 +19,7 @@ export function resolveApprovalCommandAuthorization(params: { }): ApprovalCommandAuthorization { const channel = normalizeMessageChannel(params.channel); if (!channel) { + // Non-channel command paths keep legacy behavior: allow, but do not count as explicit chat auth. return { authorized: true, explicit: false }; } const approvalCapability = resolveChannelApprovalCapability(getChannelPlugin(channel)); diff --git a/src/infra/channel-runtime-context.ts b/src/infra/channel-runtime-context.ts index 0d1c63645d5a..f3e9e1b45b48 100644 --- a/src/infra/channel-runtime-context.ts +++ b/src/infra/channel-runtime-context.ts @@ -28,6 +28,7 @@ function resolveRuntimeContextRegistry(params: { return params.channelRuntime?.runtimeContexts ?? null; } +/** Registers a channel-scoped runtime context, returning null when no runtime registry exists. */ export function registerChannelRuntimeContext( params: ChannelRuntimeContextKey & { channelRuntime?: ChannelRuntimeSurface; @@ -49,6 +50,7 @@ export function registerChannelRuntimeContext( } // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key. +/** Reads a channel-scoped runtime context from the current runtime registry. */ export function getChannelRuntimeContext( params: ChannelRuntimeContextKey & { channelRuntime?: ChannelRuntimeSurface; @@ -65,6 +67,7 @@ export function getChannelRuntimeContext( }); } +/** Watches context registration changes for one channel/account/capability key. */ export function watchChannelRuntimeContexts( params: ChannelRuntimeContextKey & { channelRuntime?: ChannelRuntimeSurface; @@ -83,6 +86,7 @@ export function watchChannelRuntimeContexts( }); } +/** Wraps a channel runtime so contexts registered during a task are disposed together. */ export function createTaskScopedChannelRuntime(params: { channelRuntime?: T; }): { @@ -108,6 +112,7 @@ export function createTaskScopedChannelRuntime( return; } disposed = true; + // Lease disposal is idempotent so task cleanup and explicit caller cleanup can race. trackedLeases.delete(lease); lease.dispose(); }, diff --git a/src/infra/channels-status-issues.ts b/src/infra/channels-status-issues.ts index 41b2c117b0b4..63e6b6bf84c7 100644 --- a/src/infra/channels-status-issues.ts +++ b/src/infra/channels-status-issues.ts @@ -37,6 +37,8 @@ function collectGenericRuntimeStatusIssues( }); continue; } + // Generic health issues are derived before plugin-specific checks so every + // channel gets the same stale/disconnected runtime warnings. const health = evaluateChannelHealth(account, { channelId: channel, now, @@ -80,6 +82,7 @@ function collectGenericRuntimeStatusIssues( return issues; } +/** Collects generic and plugin-specific issues from a channels status payload. */ export function collectChannelStatusIssues(payload: Record): ChannelStatusIssue[] { const issues: ChannelStatusIssue[] = []; const accountsByChannel = payload.channelAccounts as Record | undefined; diff --git a/src/infra/clawhub-spec.ts b/src/infra/clawhub-spec.ts index cdd038a1225e..27f9ebb6673c 100644 --- a/src/infra/clawhub-spec.ts +++ b/src/infra/clawhub-spec.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Parses explicit `clawhub:[@version]` package specs for ClawHub installs. */ export function parseClawHubPluginSpec(raw: string): { name: string; version?: string; diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 2e5f01e073ca..688287b24f0c 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -765,6 +765,7 @@ async function readClawHubResponseBytes(params: { }); } +/** Resolves the configured ClawHub base URL, falling back to the default public host. */ export function resolveClawHubBaseUrl(baseUrl?: string): string { return normalizeBaseUrl(baseUrl); } @@ -816,6 +817,7 @@ function safePackageTarballName(name: string, version: string): string { return `${base || "package"}-${version}.tgz`; } +/** Normalizes ClawHub SHA-256 metadata into Subresource Integrity format. */ export function normalizeClawHubSha256Integrity(value: string): string | null { const trimmed = value.trim(); if (!trimmed) { @@ -843,6 +845,7 @@ export function normalizeClawHubSha256Integrity(value: string): string | null { return null; } +/** Normalizes ClawHub SHA-256 metadata into lowercase hex form. */ export function normalizeClawHubSha256Hex(value: string): string | null { const trimmed = value.trim(); if (!/^[A-Fa-f0-9]{64}$/.test(trimmed)) { @@ -1274,10 +1277,12 @@ export async function downloadClawHubSkillArchive(params: { }; } +/** Resolves the preferred latest package version from detail metadata. */ export function resolveLatestVersionFromPackage(detail: ClawHubPackageDetail): string | null { return detail.package?.latestVersion ?? detail.package?.tags?.latest ?? null; } +/** Detects package or skill detail payloads that represent skill-family packages. */ export function isClawHubFamilySkill(detail: ClawHubPackageDetail | ClawHubSkillDetail): boolean { if ("package" in detail) { return detail.package?.family === "skill"; @@ -1285,6 +1290,7 @@ export function isClawHubFamilySkill(detail: ClawHubPackageDetail | ClawHubSkill return Boolean(detail.skill); } +/** Checks whether a host plugin API version satisfies a ClawHub plugin API range. */ export function satisfiesPluginApiRange( pluginApiVersion: string, pluginApiRange?: string | null, @@ -1295,6 +1301,7 @@ export function satisfiesPluginApiRange( return satisfiesSemverRange(pluginApiVersion, pluginApiRange); } +/** Checks whether the current gateway version satisfies a package minimum gateway version. */ export function satisfiesGatewayMinimum( currentVersion: string, minGatewayVersion?: string | null, diff --git a/src/infra/cli-root-options.ts b/src/infra/cli-root-options.ts index f59efefb2780..2131dd16fd22 100644 --- a/src/infra/cli-root-options.ts +++ b/src/infra/cli-root-options.ts @@ -1,8 +1,10 @@ +/** CLI token that stops root option scanning and leaves following args positional. */ export const FLAG_TERMINATOR = "--"; const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]); +/** Returns whether a token can be consumed as a root option value. */ export function isValueToken(arg: string | undefined): boolean { if (!arg || arg === FLAG_TERMINATOR) { return false; @@ -13,6 +15,7 @@ export function isValueToken(arg: string | undefined): boolean { return /^-\d+(?:\.\d+)?$/.test(arg); } +/** Returns how many argv tokens a supported root option consumes at the given index. */ export function consumeRootOptionToken(args: ReadonlyArray, index: number): number { const arg = args[index]; if (!arg) { diff --git a/src/infra/command-analysis/explain.ts b/src/infra/command-analysis/explain.ts index ab689d13280e..863d1df1bc8c 100644 --- a/src/infra/command-analysis/explain.ts +++ b/src/infra/command-analysis/explain.ts @@ -4,6 +4,7 @@ import type { ExecCommandSegment } from "../exec-approvals-analysis.js"; import { analyzeCommandForPolicy } from "./policy.js"; import { detectCommandCarrierArgv, detectInlineEvalInSegments } from "./risks.js"; +/** Compact command explanation summary shown in approval UI. */ export type CommandExplanationSummary = { commandCount: number; nestedCommandCount: number; @@ -11,6 +12,7 @@ export type CommandExplanationSummary = { warningLines: string[]; }; +// Risk labels keep warnings readable without exposing full command payloads. function riskLabel(risk: CommandRisk): string { switch (risk.kind) { case "inline-eval": @@ -30,6 +32,7 @@ function riskLabel(risk: CommandRisk): string { } } +/** Summarizes parsed shell-command explanation data for display. */ export function summarizeCommandExplanation( explanation: CommandExplanation, ): CommandExplanationSummary { diff --git a/src/infra/command-analysis/policy.ts b/src/infra/command-analysis/policy.ts index f102a2203a75..8537a3391d3a 100644 --- a/src/infra/command-analysis/policy.ts +++ b/src/infra/command-analysis/policy.ts @@ -6,6 +6,7 @@ import { } from "../exec-approvals-analysis.js"; import { detectInlineEvalInSegments } from "./risks.js"; +/** Normalized policy analysis result for argv and shell commands. */ export type CommandPolicyAnalysis = | { ok: true; @@ -21,6 +22,7 @@ export type CommandPolicyAnalysis = segments: []; }; +/** Parses a shell or argv command into command segments for approval policy checks. */ export function analyzeCommandForPolicy( params: | { diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 6ec205bbf4c0..217955f09f29 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -18,8 +18,10 @@ import { } from "../shell-wrapper-resolution.js"; import { detectInterpreterInlineEvalArgv, type InterpreterInlineEvalHit } from "./inline-eval.js"; +/** Shared command carrier constants used by approval policy and command explanation. */ export { COMMAND_CARRIER_EXECUTABLES, resolveCarrierCommandArgv, SOURCE_EXECUTABLES }; +/** Command and flag pair that can carry nested command text. */ export type CommandCarrierHit = { command: string; flag?: string; @@ -27,6 +29,7 @@ export type CommandCarrierHit = { export type CarriedShellBuiltinHit = { kind: "eval" } | { kind: "source"; command: string }; +// Recurse through env, carriers, and shell wrappers while guarding argv cycles. function commandArgvKey(argv: readonly string[]): string { return argv.join("\0"); } @@ -38,6 +41,7 @@ function isCommandCarrierExecutable(executable: string, options?: { includeExec? ); } +/** Builds candidate command payload strings from nested carriers and shell wrappers. */ export function buildCommandPayloadCandidates( argv: string[], seenArgv = new Set(), diff --git a/src/infra/command-carriers.ts b/src/infra/command-carriers.ts index fc501332120c..f3f29747f938 100644 --- a/src/infra/command-carriers.ts +++ b/src/infra/command-carriers.ts @@ -2,6 +2,8 @@ import { splitShellArgs } from "../utils/shell-argv.js"; import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; import { parseInlineOptionToken } from "./inline-option-token.js"; +// Command carriers are executables that can hide the real command behind +// wrapper-specific options, environment assignments, or shell-style splitting. export const COMMAND_CARRIER_EXECUTABLES = new Set(["sudo", "doas", "env", "command", "builtin"]); export const SOURCE_EXECUTABLES = new Set([".", "source"]); @@ -205,6 +207,8 @@ function resolveEnvSplitPayload( return null; } const carriedArgv = [...innerArgv, ...trailingArgv]; + // env -S can recursively introduce another env wrapper; keep a bounded depth + // so malicious argv cannot create unbounded parser work. return resolveEnvCarriedArgv(["env", ...carriedArgv], depth + 1) ?? carriedArgv; } @@ -215,6 +219,7 @@ export type ParsedEnvInvocationPrelude = { usesModifiers: boolean; }; +/** Parse the option and assignment prelude of an `env` invocation. */ export function parseEnvInvocationPrelude( argv: string[], depth = 0, @@ -282,11 +287,13 @@ export function envInvocationUsesModifiers(argv: string[]): boolean { return parsed?.usesModifiers ?? normalizeExecutableToken(argv[0] ?? "") === "env"; } +/** Return the argv carried by `env`, including argv reconstructed from `env -S`. */ export function unwrapEnvInvocation(argv: string[]): string[] | null { const parsed = parseEnvInvocationPrelude(argv); return parsed ? (parsed.splitArgv ?? argv.slice(parsed.commandIndex)) : null; } +/** Resolve the command argv behind an `env` carrier, honoring bounded `env -S` recursion. */ export function resolveEnvCarriedArgv(argv: string[], depth = 0): string[] | null { const parsed = parseEnvInvocationPrelude(argv, depth); return parsed ? (parsed.splitArgv ?? argv.slice(parsed.commandIndex)) : null; diff --git a/src/infra/command-explainer/extract.ts b/src/infra/command-explainer/extract.ts index 153534b81839..7ec960142d27 100644 --- a/src/infra/command-explainer/extract.ts +++ b/src/infra/command-explainer/extract.ts @@ -63,6 +63,7 @@ const MAX_WRAPPER_PAYLOAD_DEPTH = 2; const PARSEABLE_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPERS); +// Span bases map nested wrapper payload offsets back to source command offsets. type SpanBase = { startIndex: number; startPosition: SourceSpan["startPosition"]; @@ -74,6 +75,7 @@ const ROOT_SPAN_BASE: SpanBase = { startPosition: { row: 0, column: 0 }, }; +// Tree-sitter exposes nullable children; normalize once for the walkers below. function children(node: TreeSitterNode): TreeSitterNode[] { return Array.from({ length: node.childCount }, (_, index) => node.child(index)).filter( (child): child is TreeSitterNode => child !== null, @@ -1121,6 +1123,7 @@ async function walk( } } +/** Parses a shell command into command steps, shapes, risks, and source spans. */ export async function explainShellCommand(source: string): Promise { const tree = await parseBashForCommandExplanation(source); try { diff --git a/src/infra/command-explainer/format.ts b/src/infra/command-explainer/format.ts index 2026159f2eda..b866b027a3f0 100644 --- a/src/infra/command-explainer/format.ts +++ b/src/infra/command-explainer/format.ts @@ -9,6 +9,7 @@ import type { CommandExplanation } from "./types.js"; const POSIX_COMMAND_HIGHLIGHT_SHELLS: ReadonlySet = POSIX_SHELL_WRAPPERS; +// Approval spans must be strict positive source ranges to avoid broken highlighting. function spanToCommandSpan(span: { startIndex: number; endIndex: number; @@ -41,6 +42,7 @@ function hasUnsupportedShellWrapper(explanation: CommandExplanation): boolean { ); } +/** Converts a parsed command explanation into source spans suitable for approval UI. */ export function formatCommandSpans(explanation: CommandExplanation): ExecApprovalCommandSpan[] { if (hasUnsupportedShellWrapper(explanation)) { return []; diff --git a/src/infra/command-explainer/index.ts b/src/infra/command-explainer/index.ts index 07757feb433a..cf6ae9e7984f 100644 --- a/src/infra/command-explainer/index.ts +++ b/src/infra/command-explainer/index.ts @@ -1,3 +1,4 @@ +// Public command explainer facade for parsing shell commands and formatting approval spans. export { explainShellCommand } from "./extract.js"; export { formatCommandSpans } from "./format.js"; export type { diff --git a/src/infra/command-explainer/types.ts b/src/infra/command-explainer/types.ts index bfc620b78bfe..6e372dd1293e 100644 --- a/src/infra/command-explainer/types.ts +++ b/src/infra/command-explainer/types.ts @@ -1,3 +1,4 @@ +/** Where a parsed command step appeared in the shell source. */ export type CommandContext = | "top-level" | "command-substitution" diff --git a/src/infra/control-ui-assets.fs.runtime.ts b/src/infra/control-ui-assets.fs.runtime.ts index ab3a459a12a8..512eeae7ecf5 100644 --- a/src/infra/control-ui-assets.fs.runtime.ts +++ b/src/infra/control-ui-assets.fs.runtime.ts @@ -1,5 +1,7 @@ import fs from "node:fs"; +// Control UI asset tests/runtime import fs through this facade so the asset +// resolver can be stubbed without mocking node:fs globally. export const existsSync = fs.existsSync.bind(fs); export const readFileSync = fs.readFileSync.bind(fs); export const statSync = fs.statSync.bind(fs); diff --git a/src/infra/dedupe.ts b/src/infra/dedupe.ts index 821e07cecbdc..46a1914da979 100644 --- a/src/infra/dedupe.ts +++ b/src/infra/dedupe.ts @@ -2,14 +2,18 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { pruneMapToMaxSize } from "./map-size.js"; import { resolveNonNegativeIntegerOption } from "./numeric-options.js"; +/** Small in-memory TTL/LRU-style cache for replay and duplicate suppression. */ export type DedupeCache = { + /** Returns true for a recent duplicate; records the key when it was not present. */ check: (key: string | undefined | null, now?: number) => boolean; + /** Returns true for a recent duplicate without refreshing or recording the key. */ peek: (key: string | undefined | null, now?: number) => boolean; delete: (key: string | undefined | null) => void; clear: () => void; size: () => number; }; +/** Dedupe cache bounds; ttlMs <= 0 disables expiry, maxSize <= 0 disables storage. */ export type DedupeCacheOptions = { ttlMs: number; maxSize: number; @@ -18,6 +22,7 @@ export type DedupeCacheOptions = { /** @deprecated Use resolveNonNegativeIntegerOption for new internal numeric option normalization. */ export { resolveNonNegativeIntegerOption as resolveDedupeNonNegativeInteger }; +/** Creates a bounded in-memory dedupe cache with optional TTL expiry. */ export function createDedupeCache(options: DedupeCacheOptions): DedupeCache { const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0); const maxSize = resolveNonNegativeIntegerOption(options.maxSize, 0); @@ -54,6 +59,7 @@ export function createDedupeCache(options: DedupeCacheOptions): DedupeCache { return false; } if (touchOnRead) { + // check() refreshes recency so active duplicate bursts keep their key near the LRU tail. touch(key, now); } return true; @@ -90,6 +96,7 @@ export function createDedupeCache(options: DedupeCacheOptions): DedupeCache { }; } +/** Resolves a process-global dedupe cache for hot paths that can load this module twice. */ export function resolveGlobalDedupeCache(key: symbol, options: DedupeCacheOptions): DedupeCache { return resolveGlobalSingleton(key, () => createDedupeCache(options)); } diff --git a/src/infra/delivery-queue-sqlite.ts b/src/infra/delivery-queue-sqlite.ts index 55a7aa1c156c..47a210dc2ef9 100644 --- a/src/infra/delivery-queue-sqlite.ts +++ b/src/infra/delivery-queue-sqlite.ts @@ -6,9 +6,12 @@ import { getNodeSqliteKysely, } from "./kysely-sync.js"; +// Generic durable delivery queue storage shared by session and outbound queues. +// Queue-specific wrappers own payload shape; this layer owns SQLite state. type QueueStatus = "pending" | "failed"; type DeliveryQueueDatabase = Pick; +/** Indexed metadata extracted from queue payloads for diagnostics and recovery. */ export type DeliveryQueueRowMetadata = { entryKind?: string; sessionKey?: string; @@ -17,6 +20,7 @@ export type DeliveryQueueRowMetadata = { accountId?: string; }; +/** Persisted queue entry fields common to all delivery queue payloads. */ export type DeliveryQueueEntryState = { id: string; enqueuedAt: number; @@ -87,6 +91,7 @@ function metadata(entry: DeliveryQueueEntryState): DeliveryQueueRowMetadata { }; } +/** Insert or replace a delivery queue entry under a queue namespace. */ export function upsertDeliveryQueueEntry(params: { queueName: string; entry: DeliveryQueueEntryState; @@ -144,6 +149,7 @@ export function upsertDeliveryQueueEntry(params: { ); } +/** Load a single pending delivery queue entry. */ export function loadDeliveryQueueEntry( queueName: string, id: string, @@ -172,6 +178,7 @@ export function loadDeliveryQueueEntry( return row ? inflate(row) : null; } +/** Load all pending entries for a queue namespace in database order. */ export function loadDeliveryQueueEntries( queueName: string, stateDir?: string, @@ -200,6 +207,7 @@ export function loadDeliveryQueueEntries( return rows.map(inflate); } +/** Delete a pending delivery queue entry after successful delivery. */ export function deleteDeliveryQueueEntry(queueName: string, id: string, stateDir?: string): void { const database = openStateDatabase(stateDir); const queueDb = getNodeSqliteKysely(database.db); @@ -213,6 +221,7 @@ export function deleteDeliveryQueueEntry(queueName: string, id: string, stateDir ); } +/** Load, transform, and persist a pending delivery queue entry. */ export function updateDeliveryQueueEntry( queueName: string, id: string, @@ -226,6 +235,7 @@ export function updateDeliveryQueueEntry( upsertDeliveryQueueEntry({ queueName, entry: update(current), stateDir }); } +/** Mark a pending delivery queue entry as failed for later diagnostics. */ export function moveDeliveryQueueEntryToFailed( queueName: string, id: string, diff --git a/src/infra/detect-binary.ts b/src/infra/detect-binary.ts index 94b47cbbe448..91e3f03b9039 100644 --- a/src/infra/detect-binary.ts +++ b/src/infra/detect-binary.ts @@ -4,6 +4,9 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { isSafeExecutableValue } from "./exec-safety.js"; +// Binary detection accepts safe executable names or explicit paths and avoids +// shell evaluation when probing PATH. +/** Return true when a safe executable name/path can be found on this host. */ export async function detectBinary(name: string): Promise { if (!name?.trim()) { return false; diff --git a/src/infra/detect-package-manager.ts b/src/infra/detect-package-manager.ts index 0676afdbf423..fa7562586fe8 100644 --- a/src/infra/detect-package-manager.ts +++ b/src/infra/detect-package-manager.ts @@ -50,6 +50,7 @@ async function isPnpmOwnedPackageRoot(root: string): Promise { return true; } +/** Detects the package manager that owns a package root from manifests, locks, and install layout. */ export async function detectPackageManager(root: string): Promise { const pm = (await readPackageManagerSpec(root))?.split("@")[0]?.trim(); const files = await fs.readdir(root).catch((): string[] => []); @@ -58,6 +59,8 @@ export async function detectPackageManager(root: string): Promise { const state = getDiagnosticEventsState(); while (state.asyncDrainScheduled || state.asyncQueue.length > 0) { @@ -1092,22 +1096,27 @@ function emitDiagnosticEventWithTrust( dispatchDiagnosticEvent(state, enriched, metadata, privateData); } +/** Emits an untrusted diagnostic event from external/plugin-facing code. */ export function emitDiagnosticEvent(event: DiagnosticEventInput) { emitDiagnosticEventWithTrust(event, false); } +/** Emits an untrusted diagnostic event tagged as internal dispatcher provenance. */ export function emitInternalDiagnosticEvent(event: DiagnosticEventInput) { emitDiagnosticEventWithTrust(event, false, { internal: true }); } +/** Returns the latest diagnostic event sequence number assigned in this process. */ export function getInternalDiagnosticEventSequence(): number { return getDiagnosticEventsState().seq; } +/** Emits a trusted diagnostic event from core/runtime-owned instrumentation. */ export function emitTrustedDiagnosticEvent(event: DiagnosticEventInput) { emitDiagnosticEventWithTrust(event, true); } +/** Emits a trusted diagnostic event with private listener-only payload data. */ export function emitTrustedDiagnosticEventWithPrivateData( event: DiagnosticEventInput, privateData?: DiagnosticEventPrivateData, @@ -1115,6 +1124,7 @@ export function emitTrustedDiagnosticEventWithPrivateData( emitDiagnosticEventWithTrust(event, true, { privateData }); } +/** Emits a trusted model failover diagnostic event. */ export function emitFailoverEvent(event: Omit) { emitTrustedDiagnosticEvent({ type: "model.failover", @@ -1122,6 +1132,7 @@ export function emitFailoverEvent(event: Omit void { const state = getDiagnosticEventsState(); state.listeners.add(listener); @@ -1130,6 +1141,7 @@ export function onInternalDiagnosticEvent(listener: DiagnosticEventListener): () }; } +/** Subscribes to all diagnostic events plus trusted private payload data. */ export function onTrustedInternalDiagnosticEvent( listener: TrustedDiagnosticEventListener, ): () => void { @@ -1140,6 +1152,7 @@ export function onTrustedInternalDiagnosticEvent( }; } +/** Checks currently queued async diagnostic events without draining the queue. */ export function hasPendingInternalDiagnosticEvent( predicate: (event: DiagnosticEventPayload, metadata: DiagnosticEventMetadata) => boolean, ): boolean { @@ -1158,6 +1171,7 @@ export function hasPendingInternalDiagnosticEvent( return false; } +/** Subscribes to public untrusted diagnostic events only. */ export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void { return onInternalDiagnosticEvent((event, metadata) => { if (metadata.trusted || event.type === "log.record") { @@ -1167,6 +1181,7 @@ export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => voi }); } +/** Formats traceparent only for trusted metadata created by the diagnostic dispatcher. */ export function formatDiagnosticTraceparentForPropagation( event: { trace?: DiagnosticTraceContext }, metadata: DiagnosticEventMetadata, @@ -1177,10 +1192,12 @@ export function formatDiagnosticTraceparentForPropagation( return formatDiagnosticTraceparent(event.trace); } +/** Returns whether listener metadata marks dispatcher-internal provenance. */ export function isInternalDiagnosticEventMetadata(metadata: DiagnosticEventMetadata): boolean { return metadata.internal === true; } +/** Resets dispatcher state between tests. */ export function resetDiagnosticEventsForTest(): void { const state = getDiagnosticEventsState(); state.enabled = true; diff --git a/src/infra/diagnostic-flags.ts b/src/infra/diagnostic-flags.ts index 2d0fe5af29f4..2ae0ae2ebe0c 100644 --- a/src/infra/diagnostic-flags.ts +++ b/src/infra/diagnostic-flags.ts @@ -37,6 +37,7 @@ function uniqueFlags(flags: string[]): string[] { return normalizeUniqueStringEntriesLower(flags); } +/** Resolves enabled diagnostic flags from config plus `OPENCLAW_DIAGNOSTICS` overrides. */ export function resolveDiagnosticFlags( cfg?: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -49,6 +50,7 @@ export function resolveDiagnosticFlags( return uniqueFlags([...configFlags, ...envFlags.flags]); } +/** Matches one diagnostic flag against exact, wildcard, and namespace-enabled flags. */ export function matchesDiagnosticFlag(flag: string, enabledFlags: string[]): boolean { const target = normalizeLowercaseStringOrEmpty(flag); if (!target) { @@ -81,6 +83,7 @@ export function matchesDiagnosticFlag(flag: string, enabledFlags: string[]): boo return false; } +/** Returns whether a diagnostic flag is enabled after config/env resolution. */ export function isDiagnosticFlagEnabled( flag: string, cfg?: OpenClawConfig, diff --git a/src/infra/diagnostic-llm-content.ts b/src/infra/diagnostic-llm-content.ts index 6232d558fa63..d8663a873c62 100644 --- a/src/infra/diagnostic-llm-content.ts +++ b/src/infra/diagnostic-llm-content.ts @@ -1,10 +1,18 @@ +/** Per-field policy for diagnostic traces that may include model-visible content. */ export type DiagnosticModelContentCapturePolicy = { + /** Capture chat/message payloads sent to a model. */ inputMessages: boolean; + /** Capture model response messages. */ outputMessages: boolean; + /** Capture tool invocation arguments. */ toolInputs: boolean; + /** Capture tool result payloads. */ toolOutputs: boolean; + /** Capture the system prompt or instruction block. */ systemPrompt: boolean; + /** Capture tool schemas/definitions presented to a model. */ toolDefinitions: boolean; + /** Whether any model-visible prompt/response/schema content is enabled. */ anyModelContent: boolean; }; @@ -35,6 +43,7 @@ function withDerivedFields( }; } +/** Resolves model-content diagnostic capture from config, defaulting to no content capture. */ export function resolveDiagnosticModelContentCapturePolicy( config: unknown, ): DiagnosticModelContentCapturePolicy { diff --git a/src/infra/diagnostic-trace-context.ts b/src/infra/diagnostic-trace-context.ts index 405366333761..621d1d797974 100644 --- a/src/infra/diagnostic-trace-context.ts +++ b/src/infra/diagnostic-trace-context.ts @@ -88,14 +88,17 @@ function getDiagnosticTraceScopeState(): DiagnosticTraceScopeState { return state; } +/** Returns whether a value is a non-zero W3C trace id. */ export function isValidDiagnosticTraceId(value: unknown): value is string { return typeof value === "string" && TRACE_ID_RE.test(value) && isNonZeroHex(value); } +/** Returns whether a value is a non-zero W3C span id. */ export function isValidDiagnosticSpanId(value: unknown): value is string { return typeof value === "string" && SPAN_ID_RE.test(value) && isNonZeroHex(value); } +/** Returns whether a value is a valid W3C trace-flags byte. */ export function isValidDiagnosticTraceFlags(value: unknown): value is string { return typeof value === "string" && TRACE_FLAGS_RE.test(value); } @@ -124,6 +127,7 @@ function normalizeTraceFlags(value: unknown): string | undefined { return isValidDiagnosticTraceFlags(normalized) ? normalized : undefined; } +/** Parses a W3C `traceparent` header into a normalized diagnostic trace context. */ export function parseDiagnosticTraceparent( traceparent: string | undefined, ): DiagnosticTraceContext | undefined { @@ -155,6 +159,7 @@ export function parseDiagnosticTraceparent( }; } +/** Formats a diagnostic trace context as a W3C `traceparent` header. */ export function formatDiagnosticTraceparent( context: DiagnosticTraceContext | undefined, ): string | undefined { @@ -170,6 +175,7 @@ export function formatDiagnosticTraceparent( return `${TRACEPARENT_VERSION}-${traceId}-${spanId}-${traceFlags}`; } +/** Creates a normalized trace context from explicit fields, traceparent, or generated ids. */ export function createDiagnosticTraceContext( input: DiagnosticTraceContextInput = {}, ): DiagnosticTraceContext { @@ -185,6 +191,7 @@ export function createDiagnosticTraceContext( }; } +/** Creates a child context that preserves the parent trace id and records the parent span id. */ export function createChildDiagnosticTraceContext( parent: DiagnosticTraceContext, input: Omit = {}, @@ -198,6 +205,7 @@ export function createChildDiagnosticTraceContext( }); } +/** Creates a child of the active trace scope, or a new root context when no scope is active. */ export function createDiagnosticTraceContextFromActiveScope( input: Omit = {}, ): DiagnosticTraceContext { @@ -208,6 +216,7 @@ export function createDiagnosticTraceContextFromActiveScope( return createChildDiagnosticTraceContext(active, input); } +/** Returns an immutable defensive copy of a trace context. */ export function freezeDiagnosticTraceContext( context: DiagnosticTraceContext, ): DiagnosticTraceContext { @@ -219,10 +228,12 @@ export function freezeDiagnosticTraceContext( }); } +/** Returns the trace context bound to the current async scope. */ export function getActiveDiagnosticTraceContext(): DiagnosticTraceContext | undefined { return getDiagnosticTraceScopeState().storage.getStore(); } +/** Runs a callback with a frozen trace context bound to async-local storage. */ export function runWithDiagnosticTraceContext( trace: DiagnosticTraceContext, callback: () => T, @@ -230,6 +241,7 @@ export function runWithDiagnosticTraceContext( return getDiagnosticTraceScopeState().storage.run(freezeDiagnosticTraceContext(trace), callback); } +/** Clears async-local trace context state between tests. */ export function resetDiagnosticTraceContextForTest(): void { getDiagnosticTraceScopeState().storage.disable(); } diff --git a/src/infra/diagnostics-timeline.ts b/src/infra/diagnostics-timeline.ts index cff2fff9ac5b..0367c1853e31 100644 --- a/src/infra/diagnostics-timeline.ts +++ b/src/infra/diagnostics-timeline.ts @@ -62,6 +62,7 @@ type DiagnosticsTimelineOptions = { env?: NodeJS.ProcessEnv; }; +/** Active timeline span carried through async-local scope for nested diagnostics. */ export type ActiveDiagnosticsTimelineSpan = { name: string; phase?: string; @@ -90,6 +91,7 @@ function resolveDiagnosticsTimelineOptions( }; } +/** Returns true when diagnostics flags and a JSONL output path both allow timeline writes. */ export function isDiagnosticsTimelineEnabled(options: DiagnosticsTimelineOptions = {}): boolean { const { config, env } = resolveDiagnosticsTimelineOptions(options); return ( @@ -167,6 +169,7 @@ function serializeTimelineEvent(event: DiagnosticsTimelineEvent, env: NodeJS.Pro return `${JSON.stringify(normalized)}\n`; } +/** Appends one normalized diagnostics timeline event to the configured JSONL file. */ export function emitDiagnosticsTimelineEvent( event: DiagnosticsTimelineEvent, options: DiagnosticsTimelineOptions = {}, @@ -190,11 +193,13 @@ export function emitDiagnosticsTimelineEvent( } catch (error) { if (!warnedAboutTimelineWrite) { warnedAboutTimelineWrite = true; + // Diagnostics output is best-effort; one warning avoids recursive stderr spam. process.stderr.write(`[diagnostics] failed to write timeline event: ${String(error)}\n`); } } } +/** Returns the currently active span so callers can preserve parentage across memoized work. */ export function getActiveDiagnosticsTimelineSpan(): ActiveDiagnosticsTimelineSpan | undefined { return activeDiagnosticsTimelineSpan.getStore(); } @@ -285,6 +290,7 @@ function emitFailedDiagnosticsTimelineSpan( ); } +/** Measures async work as a start/end timeline span, emitting an error span before rethrowing. */ export async function measureDiagnosticsTimelineSpan( name: string, run: () => Promise | T, @@ -304,6 +310,7 @@ export async function measureDiagnosticsTimelineSpan( } } +/** Measures sync work as a start/end timeline span, emitting an error span before rethrowing. */ export function measureDiagnosticsTimelineSpanSync( name: string, run: () => T, @@ -323,6 +330,7 @@ export function measureDiagnosticsTimelineSpanSync( } } +/** Lets tests await any future asynchronous timeline cleanup without changing call sites. */ export async function flushDiagnosticsTimelineForTest(): Promise { await Promise.resolve(); } diff --git a/src/infra/disk-space.ts b/src/infra/disk-space.ts index 9e0ba02c33c8..2900441a1297 100644 --- a/src/infra/disk-space.ts +++ b/src/infra/disk-space.ts @@ -31,10 +31,13 @@ function findExistingDiskSpacePath(targetPath: string): string | null { } } +/** Reads available bytes for the volume containing a target path when statfs is available. */ export function tryReadDiskSpace(targetPath: string): DiskSpaceSnapshot | null { if (typeof fs.statfsSync !== "function") { return null; } + // Install/update targets may not exist yet; statfs needs the nearest existing + // ancestor to identify the backing volume. const checkedPath = findExistingDiskSpacePath(targetPath); if (!checkedPath) { return null; @@ -58,6 +61,7 @@ export function tryReadDiskSpace(targetPath: string): DiskSpaceSnapshot | null { } } +/** Formats byte counts for compact operator-facing disk-space warnings. */ export function formatDiskSpaceBytes(bytes: number): string { const mib = bytes / (1024 * 1024); if (mib < 1024) { @@ -67,6 +71,7 @@ export function formatDiskSpaceBytes(bytes: number): string { return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`; } +/** Builds a soft low-disk warning for setup/update flows without failing the operation. */ export function createLowDiskSpaceWarning(params: { targetPath: string; purpose: string; diff --git a/src/infra/dotenv-global.ts b/src/infra/dotenv-global.ts index 85c21f34beba..69b8f2575f0b 100644 --- a/src/infra/dotenv-global.ts +++ b/src/infra/dotenv-global.ts @@ -7,6 +7,8 @@ import { resolveConfigDir } from "../utils.js"; import { resolveRequiredHomeDir } from "./home-dir.js"; import { normalizeEnvVarKey } from "./host-env-security.js"; +// Global dotenv loading imports operator-level gateway env files without +// overriding variables already present in the process environment. const logger = createSubsystemLogger("infra:dotenv"); type DotEnvEntry = { @@ -69,6 +71,8 @@ function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) { const previous = firstSeen.get(key); if (previous) { if (previous.value !== value) { + // First file wins for deterministic startup; conflicts are logged once + // after parsing so sensitive values are not printed. const conflictKey = `${previous.filePath}\u0000${file.filePath}`; const existing = conflicts.get(conflictKey); if (existing) { @@ -102,6 +106,7 @@ function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) { } } +/** Load global runtime dotenv files into `process.env` with first-wins precedence. */ export function loadGlobalRuntimeDotEnvFiles(opts?: { quiet?: boolean; stateEnvPath?: string }) { const quiet = opts?.quiet ?? true; const stateEnvPath = opts?.stateEnvPath ?? path.join(resolveConfigDir(process.env), ".env"); diff --git a/src/infra/embedded-mode.ts b/src/infra/embedded-mode.ts index 3112d9c66acb..e206738efd3c 100644 --- a/src/infra/embedded-mode.ts +++ b/src/infra/embedded-mode.ts @@ -1,9 +1,11 @@ let embeddedModeValue = false; +/** Sets the process-local embedded-mode flag used by UI and hosted runtimes. */ export function setEmbeddedMode(value: boolean): void { embeddedModeValue = value; } +/** Returns whether the current process is running inside an embedded OpenClaw host. */ export function isEmbeddedMode(): boolean { return embeddedModeValue; } diff --git a/src/infra/env.ts b/src/infra/env.ts index a297b7839132..8ce5f31862ac 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -33,6 +33,7 @@ function formatEnvValue(value: string, redact?: boolean): string { return `${singleLine.slice(0, 160)}…`; } +/** Logs an accepted env option once, with optional redaction for sensitive values. */ export function logAcceptedEnvOption(option: AcceptedEnvOption): void { if (process.env.VITEST || process.env.NODE_ENV === "test") { return; @@ -56,12 +57,14 @@ export function logAcceptedEnvOption(option: AcceptedEnvOption): void { }); } +/** Normalizes the legacy Z_AI_API_KEY spelling into the canonical ZAI_API_KEY env var. */ export function normalizeZaiEnv(): void { if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) { process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY; } } +/** Interprets common human/operator truthy env strings. */ export function isTruthyEnvValue(value?: string): boolean { if (typeof value !== "string") { return false; @@ -77,6 +80,7 @@ export function isTruthyEnvValue(value?: string): boolean { } } +/** Detects Vitest/test execution from the env shape used by local and worker processes. */ export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { return ( env.VITEST === "true" || @@ -87,6 +91,7 @@ export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolea ); } +/** Applies process-wide env normalization before runtime configuration is read. */ export function normalizeEnv(): void { normalizeZaiEnv(); } diff --git a/src/infra/event-session-routing.ts b/src/infra/event-session-routing.ts index 7af17bb92a5b..78f67a592aed 100644 --- a/src/infra/event-session-routing.ts +++ b/src/infra/event-session-routing.ts @@ -13,8 +13,11 @@ import { resolveEventSessionKey, scopedHeartbeatWakeOptions } from "../routing/s import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js"; +// Event session routing maps cron/heartbeat wakeups back to the right main, +// direct, or global session key while honoring DM allowlists and route policy. type UnknownRecord = Record; +/** Routing policy derived from config and the source session for an event. */ export type EventSessionRoutingPolicy = { mainKey?: string; sessionScope?: SessionScope; @@ -61,6 +64,7 @@ function normalizeEntry(value: string): string | undefined { return normalizeLowercaseStringOrEmpty(value) || undefined; } +/** Parse an agent direct-session key into channel/account/peer routing parts. */ export function parseDirectAgentSessionTarget( sessionKey: string | undefined | null, ): DirectSessionTarget | null { @@ -87,6 +91,7 @@ export function parseDirectAgentSessionTarget( }; } +/** Resolve the configured DM allowlist that applies to an event session. */ export function resolveEventSessionAllowFrom(params: { cfg?: OpenClawConfig; sessionKey?: string | null; @@ -137,6 +142,8 @@ function shouldPreserveDirectSessionKeyFromRoute(params: { }); const { baseSessionKey } = parseThreadSessionSuffix(params.sessionKey); const normalizedRouteSessionKey = normalizeLowercaseStringOrEmpty(route.sessionKey); + // If the configured route already chose this direct session, keep it rather + // than collapsing to main-session scope. return ( route.lastRoutePolicy === "session" && (normalizedRouteSessionKey === normalizeLowercaseStringOrEmpty(params.sessionKey) || @@ -148,6 +155,7 @@ function shouldPreserveDirectSessionKeyFromRoute(params: { } } +/** Build the routing policy used by event wakeups and scoped heartbeat options. */ export function resolveEventSessionRoutingPolicy(params: { cfg?: OpenClawConfig; sessionKey?: string | null; @@ -185,6 +193,7 @@ export function resolveEventSessionRoutingPolicy(params: { }; } +/** Resolve a direct DM event session to the configured main session when allowed. */ export function resolveMainScopedEventSessionKey(params: { cfg?: OpenClawConfig; sessionKey: string; @@ -237,6 +246,7 @@ export function resolveMainScopedEventSessionKey(params: { }); } +/** Apply event routing policy to a raw session key. */ export function resolveEventSessionKeyForPolicy( sessionKey: string, policy?: EventSessionRoutingPolicy, @@ -248,6 +258,7 @@ export function resolveEventSessionKeyForPolicy( return resolveMainScopedEventSessionKey({ sessionKey, policy }) ?? sessionKey; } +/** Apply event routing policy while preserving wake option typing. */ export function scopedHeartbeatWakeOptionsForPolicy( sessionKey: string, wakeOptions: T, diff --git a/src/infra/exec-approval-channel-runtime.ts b/src/infra/exec-approval-channel-runtime.ts index 7d90c527235c..1d56ec3d43ed 100644 --- a/src/infra/exec-approval-channel-runtime.ts +++ b/src/infra/exec-approval-channel-runtime.ts @@ -21,6 +21,7 @@ export type { type ApprovalRequestEvent = ExecApprovalRequest | PluginApprovalRequest; type ApprovalResolvedEvent = ExecApprovalResolved | PluginApprovalResolved; +/** Error raised when the gateway pauses approval reconnects after a terminal startup failure. */ export class ExecApprovalChannelRuntimeTerminalStartError extends Error { readonly detailCode: string | null; @@ -35,6 +36,7 @@ export class ExecApprovalChannelRuntimeTerminalStartError extends Error { } } +/** Narrows terminal approval runtime startup failures for bootstrap retry policy. */ export function isExecApprovalChannelRuntimeTerminalStartError( error: unknown, ): error is ExecApprovalChannelRuntimeTerminalStartError { @@ -73,6 +75,7 @@ function readGatewayConnectErrorDetailCode(error: unknown): string | null { return readConnectErrorDetailCode((error as { details?: unknown }).details); } +/** Creates the gateway-backed approval runtime that tracks pending requests and finalization. */ export function createExecApprovalChannelRuntime< TPending, TRequest extends ApprovalRequestEvent = ExecApprovalRequest, @@ -179,6 +182,7 @@ export function createExecApprovalChannelRuntime< entry.entries = entries; entry.delivering = false; if (entry.pendingResolution) { + // Resolution can arrive while native delivery is still creating entries; finalize after both. pending.delete(request.id); log.debug(`resolved ${entry.pendingResolution.id} with ${entry.pendingResolution.decision}`); await adapter.finalizeResolved({ @@ -319,6 +323,7 @@ export function createExecApprovalChannelRuntime< return; } readySettled = true; + // Hello, close, and reconnect-paused callbacks can race during startup. fn(); }; diff --git a/src/infra/exec-approval-channel-runtime.types.ts b/src/infra/exec-approval-channel-runtime.types.ts index 6ad91fb0dde7..e2e6056eccf7 100644 --- a/src/infra/exec-approval-channel-runtime.types.ts +++ b/src/infra/exec-approval-channel-runtime.types.ts @@ -5,8 +5,10 @@ import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-app type ApprovalRequestEvent = ExecApprovalRequest | PluginApprovalRequest; type ApprovalResolvedEvent = ExecApprovalResolved | PluginApprovalResolved; +/** Approval event families a channel-native approval runtime can subscribe to. */ export type ExecApprovalChannelRuntimeEventKind = "exec" | "plugin"; +/** Adapter implemented by a channel to deliver and finalize native approval prompts. */ export type ExecApprovalChannelRuntimeAdapter< TPending, TRequest extends ApprovalRequestEvent = ExecApprovalRequest, @@ -16,6 +18,7 @@ export type ExecApprovalChannelRuntimeAdapter< clientDisplayName: string; cfg: OpenClawConfig; gatewayUrl?: string; + /** Defaults to exec-only; include plugin when the adapter can handle plugin approvals. */ eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[]; isConfigured: () => boolean; shouldHandle: (request: TRequest) => boolean; @@ -31,6 +34,7 @@ export type ExecApprovalChannelRuntimeAdapter< nowMs?: () => number; }; +/** Runtime handle used by approval bootstrap code to manage a channel-native approval client. */ export type ExecApprovalChannelRuntime< TRequest extends ApprovalRequestEvent = ExecApprovalRequest, TResolved extends ApprovalResolvedEvent = ExecApprovalResolved, diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 3f3855b945c4..38b8968ab0f5 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -38,9 +38,13 @@ function escapeInvisibles(text: string, options?: { preserveLineBreaks?: boolean ); } +/** Sanitized approval text plus size-cap status for callers that need UI affordances. */ export type SanitizedExecApprovalDisplayText = { + /** Redacted, spoof-resistant command or warning text safe for an approval prompt. */ text: string; + /** True when sanitized output exceeded the display cap and was shortened. */ truncated: boolean; + /** True when raw input exceeded the hard cap and was replaced with a fixed marker. */ oversized: boolean; }; @@ -180,16 +184,23 @@ function sanitizeExecApprovalDisplayTextInternal( return truncateForDisplay(out); } +/** Sanitizes exec command text for approval UI without exposing status metadata. */ export function sanitizeExecApprovalDisplayText(commandText: string): string { return sanitizeExecApprovalDisplayTextInternal(commandText).text; } +/** + * Sanitizes exec command text for approval UI and reports whether size caps changed it. + */ export function sanitizeExecApprovalDisplayTextWithStatus( commandText: string, ): SanitizedExecApprovalDisplayText { return sanitizeExecApprovalDisplayTextInternal(commandText); } +/** + * Sanitizes warning prose for approval UI while preserving real line boundaries. + */ export function sanitizeExecApprovalWarningText(warningText: string): string { return sanitizeExecApprovalDisplayTextInternal(normalizeDisplayLineBreaks(warningText), { preserveLineBreaks: true, @@ -209,8 +220,11 @@ function normalizePreview(commandText: string, commandPreview?: string | null): return preview; } +/** Resolves sanitized command and preview text for exec approval prompts. */ export function resolveExecApprovalCommandDisplay(request: ExecApprovalRequestPayload): { + /** Primary command text rendered in the approval prompt. */ commandText: string; + /** Optional shorter preview, omitted when it would duplicate the primary command text. */ commandPreview: string | null; } { const commandTextSource = diff --git a/src/infra/exec-approval-forwarder.runtime.ts b/src/infra/exec-approval-forwarder.runtime.ts index 0efdf8bb159a..dfd406464ab4 100644 --- a/src/infra/exec-approval-forwarder.runtime.ts +++ b/src/infra/exec-approval-forwarder.runtime.ts @@ -1,2 +1,4 @@ +// Lazy runtime imports keep approval forwarding testable without eagerly loading +// channel delivery code. export { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js"; export { sendDurableMessageBatch } from "../channels/message/runtime.js"; diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 8035a42b4318..6e8d660e5dbb 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -44,6 +44,8 @@ import { type PluginApprovalResolved, } from "./plugin-approvals.js"; +// Approval forwarding mirrors foreground exec/plugin approvals into configured +// chat targets, then sends resolution/expiry notices to the same targets. const log = createSubsystemLogger("gateway/exec-approvals"); type DeliverApprovalPayloads = typeof import("../channels/message/runtime.js").sendDurableMessageBatch; @@ -209,6 +211,8 @@ function shouldSkipForwardingFallback(params: { if (!channel) { return false; } + // Channel adapters can suppress generic fallback delivery when they already + // own native approval UX for the same target. const adapter = resolveChannelApprovalAdapter(getLoadedChannelPlugin(channel)); return ( adapter?.delivery?.shouldSuppressForwardingFallback?.({ diff --git a/src/infra/exec-approval-session-target.ts b/src/infra/exec-approval-session-target.ts index b40a2a39ee0d..946e7f1f7e7e 100644 --- a/src/infra/exec-approval-session-target.ts +++ b/src/infra/exec-approval-session-target.ts @@ -10,6 +10,7 @@ import type { ExecApprovalRequest } from "./exec-approvals.js"; import { resolveSessionDeliveryTarget } from "./outbound/targets.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; +/** Delivery target recovered from an approval request's live turn-source or stored session. */ export type ExecApprovalSessionTarget = { channel?: string; to: string; @@ -17,6 +18,7 @@ export type ExecApprovalSessionTarget = { threadId?: string | number; }; +/** Parsed session conversation metadata used by channel-native approval routing. */ export type ApprovalRequestSessionConversation = { channel: string; kind: "group" | "channel"; @@ -78,6 +80,7 @@ function normalizeOptionalChannel(value?: string | null): string | undefined { return normalizeMessageChannel(value); } +/** Resolves the conversation encoded in an approval request session key for an optional channel. */ export function resolveApprovalRequestSessionConversation(params: { request: ApprovalRequestLike; channel?: string | null; @@ -109,6 +112,7 @@ export function resolveApprovalRequestSessionConversation(params: { }; } +/** Resolves the best known message target for an exec approval request. */ export function resolveExecApprovalSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; @@ -149,6 +153,7 @@ export function resolveExecApprovalSessionTarget(params: { }; } +/** Resolves the best known message target for either exec or plugin approval requests. */ export function resolveApprovalRequestSessionTarget(params: { cfg: OpenClawConfig; request: ApprovalRequestLike; @@ -175,6 +180,7 @@ function resolveApprovalRequestStoredSessionTarget(params: { }); } +/** Resolves a channel-specific origin target only when live and stored bindings are consistent. */ export function resolveApprovalRequestOriginTarget( params: ApprovalRequestOriginTargetResolver, ): TTarget | null { @@ -202,6 +208,7 @@ export function resolveApprovalRequestOriginTarget( : null; if (turnSourceTarget && sessionTarget && !params.targetsMatch(turnSourceTarget, sessionTarget)) { + // Avoid routing to an origin when live turn metadata disagrees with persisted session state. return null; } diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index ed397507c28f..83ce8ff5cb8a 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -11,6 +11,7 @@ import { normalizeMessageChannel, } from "../utils/message-channel.js"; +/** Native approval availability for the channel/account that initiated an approval. */ export type ExecApprovalInitiatingSurfaceState = | { kind: "enabled"; channel: string | undefined; channelLabel: string; accountId?: string } | { kind: "disabled"; channel: string; channelLabel: string; accountId?: string } @@ -39,6 +40,7 @@ function hasNativeExecApprovalCapability(channel?: string): boolean { return Boolean(capability.getExecInitiatingSurfaceState || capability.getActionAvailabilityState); } +/** Resolves whether exec approvals can be handled on the initiating surface. */ export function resolveExecApprovalInitiatingSurfaceState(params: { channel?: string | null; accountId?: string | null; @@ -47,6 +49,7 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { return resolveApprovalInitiatingSurfaceState({ ...params, approvalKind: "exec" }); } +/** Resolves whether approvals of a given kind can be handled on the initiating surface. */ export function resolveApprovalInitiatingSurfaceState(params: { channel?: string | null; accountId?: string | null; @@ -62,6 +65,8 @@ export function resolveApprovalInitiatingSurfaceState(params: { const cfg = params.cfg ?? getRuntimeConfig(); const capability = resolveChannelApprovalCapability(getChannelPlugin(channel)); + // Prefer the exec-specific hook, then the generic approval hook, before + // falling back to basic deliverability for channels without native state. const state = (params.approvalKind === "exec" ? capability?.getExecInitiatingSurfaceState?.({ @@ -85,6 +90,7 @@ export function resolveApprovalInitiatingSurfaceState(params: { return { kind: "unsupported", channel, channelLabel, accountId }; } +/** Returns whether a channel can present native exec approval UI. */ export function supportsNativeExecApprovalClient(channel?: string | null): boolean { const normalized = normalizeMessageChannel(channel); if (!normalized || normalized === INTERNAL_MESSAGE_CHANNEL || normalized === "tui") { @@ -93,6 +99,7 @@ export function supportsNativeExecApprovalClient(channel?: string | null): boole return hasNativeExecApprovalCapability(normalized); } +/** Lists native exec approval client labels for reply guidance. */ export function listNativeExecApprovalClientLabels(params?: { excludeChannel?: string | null; }): string[] { @@ -105,6 +112,7 @@ export function listNativeExecApprovalClientLabels(params?: { .toSorted((a, b) => a.localeCompare(b)); } +/** Returns channel-specific setup guidance for native exec approvals, when available. */ export function describeNativeExecApprovalClientSetup(params: { channel?: string | null; channelLabel?: string | null; diff --git a/src/infra/exec-approvals-test-helpers.ts b/src/infra/exec-approvals-test-helpers.ts index 5e97d16b1acd..4444282f9164 100644 --- a/src/infra/exec-approvals-test-helpers.ts +++ b/src/infra/exec-approvals-test-helpers.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import type { CommandResolution, ExecutableResolution } from "./exec-command-resolution.js"; +// Shared exec-approval fixtures keep parser, allowlist, and wrapper tests on +// the same mock resolution shape. export function makePathEnv(binDir: string): NodeJS.ProcessEnv { if (process.platform !== "win32") { return { PATH: binDir }; @@ -10,10 +12,12 @@ export function makePathEnv(binDir: string): NodeJS.ProcessEnv { return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" }; } +/** Create a real temp directory for exec-approval tests that need filesystem paths. */ export function makeTempDir(): string { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"))); } +/** Build a minimal executable resolution for command-policy tests. */ export function makeMockExecutableResolution(params: { rawExecutable: string; executableName: string; @@ -28,6 +32,7 @@ export function makeMockExecutableResolution(params: { }; } +/** Build a command resolution while preserving legacy getter accessors. */ export function makeMockCommandResolution(params: { execution: ExecutableResolution; policy?: ExecutableResolution; @@ -96,6 +101,7 @@ export function loadShellParserParityFixtureCases(): ShellParserParityFixtureCas return fixture.cases; } +/** Load wrapper resolution parity cases generated from shell-parser fixtures. */ export function loadWrapperResolutionParityFixtureCases(): WrapperResolutionParityFixtureCase[] { const fixturePath = path.join( process.cwd(), diff --git a/src/infra/exec-approvals.types.ts b/src/infra/exec-approvals.types.ts index 603efde62011..c4ab38424868 100644 --- a/src/infra/exec-approvals.types.ts +++ b/src/infra/exec-approvals.types.ts @@ -1,3 +1,5 @@ +// Serialized allowlist entries stored with enough command context to explain +// why an approval can be reused later. export type ExecAllowlistEntry = { id?: string; pattern: string; diff --git a/src/infra/exec-auto-review.ts b/src/infra/exec-auto-review.ts index bcf67eaf8519..ac42960a210e 100644 --- a/src/infra/exec-auto-review.ts +++ b/src/infra/exec-auto-review.ts @@ -1,5 +1,7 @@ +/** Risk level returned by exec auto-reviewers for approval routing decisions. */ export type ExecAutoReviewRisk = "unknown" | "low" | "medium" | "high"; +/** Auto-review outcome: either approve once or send the command to normal approval. */ export type ExecAutoReviewDecision = | { decision: "allow-once"; @@ -12,8 +14,10 @@ export type ExecAutoReviewDecision = risk: ExecAutoReviewRisk; }; +/** Execution host whose command policy context is being reviewed. */ export type ExecAutoReviewHost = "gateway" | "node"; +/** Command and policy facts supplied to an exec auto-reviewer. */ export type ExecAutoReviewInput = { command: string; argv?: readonly string[]; @@ -41,6 +45,7 @@ export type ExecAutoReviewInput = { }; }; +/** Reviewer function used by gateway/node exec paths before human approval fallback. */ export type ExecAutoReviewer = ( input: ExecAutoReviewInput, ) => Promise | ExecAutoReviewDecision; diff --git a/src/infra/exec-host.ts b/src/infra/exec-host.ts index df749c4f6d30..19db3a7d35bb 100644 --- a/src/infra/exec-host.ts +++ b/src/infra/exec-host.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; import { requestJsonlSocket } from "./jsonl-socket.js"; +// Exec host requests cross the local JSONL socket boundary into a privileged +// runner, so payloads stay explicit and HMAC-protected. export type ExecHostRequest = { command: string[]; rawCommand?: string | null; @@ -32,6 +34,7 @@ export type ExecHostResponse = | { ok: true; payload: ExecHostRunResult } | { ok: false; error: ExecHostError }; +/** Send an authenticated exec request over the host JSONL socket. */ export async function requestExecHostViaSocket(params: { socketPath: string; token: string; @@ -46,6 +49,8 @@ export async function requestExecHostViaSocket(params: { const requestJson = JSON.stringify(request); const nonce = crypto.randomBytes(16).toString("hex"); const ts = Date.now(); + // The host validates the exact JSON payload with nonce and timestamp, so the + // command body cannot be modified without invalidating the request HMAC. const hmac = crypto .createHmac("sha256", token) .update(`${nonce}:${ts}:${requestJson}`) diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index 1ca9456a7a95..573ef8cb790e 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -1,3 +1,5 @@ +// Safe-bin policy facade: profile metadata and argv validation live in split +// modules, while callers import the stable aggregate surface from here. export { DEFAULT_SAFE_BINS, SAFE_BIN_PROFILE_FIXTURES, diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index b62f0ee788a4..8904ee433ad8 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -66,6 +66,7 @@ const INTERPRETER_LIKE_PATTERNS = [ /^node\d+(?:\.\d+)?$/, ]; +/** Returns true for safeBins that can interpret scripts or execute broad embedded programs. */ export function isInterpreterLikeSafeBin(raw: string): boolean { const normalized = normalizeSafeBinName(raw); if (!normalized) { @@ -77,6 +78,7 @@ export function isInterpreterLikeSafeBin(raw: string): boolean { return INTERPRETER_LIKE_PATTERNS.some((pattern) => pattern.test(normalized)); } +/** Lists normalized interpreter-like safeBins from a configured entry set. */ export function listInterpreterLikeSafeBins(entries: Iterable): string[] { return Array.from(entries) .map((entry) => normalizeSafeBinName(entry)) @@ -84,6 +86,7 @@ export function listInterpreterLikeSafeBins(entries: Iterable): string[] .toSorted(); } +/** Merges global and local safe-bin profile fixtures, with local definitions winning. */ export function resolveMergedSafeBinProfileFixtures(params: { global?: ExecSafeBinConfigScope | null; local?: ExecSafeBinConfigScope | null; @@ -99,6 +102,7 @@ export function resolveMergedSafeBinProfileFixtures(params: { }; } +/** Resolves safe-bin names, profiles, trusted dirs, and warning metadata for exec evaluation. */ export function resolveExecSafeBinRuntimePolicy(params: { global?: ExecSafeBinConfigScope | null; local?: ExecSafeBinConfigScope | null; diff --git a/src/infra/exec-safe-bin-semantics.ts b/src/infra/exec-safe-bin-semantics.ts index 12669d4caf35..ed57eb30ba6c 100644 --- a/src/infra/exec-safe-bin-semantics.ts +++ b/src/infra/exec-safe-bin-semantics.ts @@ -54,6 +54,7 @@ const SAFE_BIN_SEMANTIC_RULES: Readonly> = { }, }; +/** Normalizes a configured safe-bin entry to its executable basename without Windows suffixes. */ export function normalizeSafeBinName(raw: string): string { const trimmed = normalizeLowercaseStringOrEmpty(raw); if (!trimmed) { @@ -69,10 +70,12 @@ function getSafeBinSemanticRule(binName?: string): SafeBinSemanticRule | undefin return normalized ? SAFE_BIN_SEMANTIC_RULES[normalized] : undefined; } +/** Applies command-specific semantic gates for executables that are risky as broad safeBins. */ export function validateSafeBinSemantics(params: SafeBinSemanticValidationParams): boolean { return getSafeBinSemanticRule(params.binName)?.validate?.(params) ?? true; } +/** Lists configured safeBins that need operator warnings because their semantics are broad. */ export function listRiskyConfiguredSafeBins(entries: Iterable): Array<{ bin: string; warning: string; diff --git a/src/infra/exec-safe-builtins.ts b/src/infra/exec-safe-builtins.ts index 99fccf9d0f77..627cdf550a6c 100644 --- a/src/infra/exec-safe-builtins.ts +++ b/src/infra/exec-safe-builtins.ts @@ -12,6 +12,7 @@ const DEFAULT_SAFE_BUILTINS: ReadonlySet = new Set([ "true", ]); +/** Returns true when a parsed POSIX shell segment is one of the closed safe builtin forms. */ export function isSafeBuiltinSegment(params: { segment: ExecCommandSegment; platform?: string | null; diff --git a/src/infra/exec-safety.ts b/src/infra/exec-safety.ts index f7ee77f01491..e3fde141ca9a 100644 --- a/src/infra/exec-safety.ts +++ b/src/infra/exec-safety.ts @@ -13,6 +13,7 @@ function isLikelyPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value); } +/** Validates that a configured executable value cannot smuggle shell syntax. */ export function isSafeExecutableValue(value: string | null | undefined): boolean { if (!value) { return false; @@ -34,6 +35,7 @@ export function isSafeExecutableValue(value: string | null | undefined): boolean return false; } + // Path-like executables may contain separators, but still reject shell syntax above. if (isLikelyPath(trimmed)) { return true; } diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 2f8b89d7b1ee..7a24ea8f3129 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -1,3 +1,5 @@ +// Wrapper resolution facade for executable tokens, dispatch wrappers, and shell +// multiplexers used by exec approval policy. export { basenameLower, normalizeExecutableToken } from "./exec-wrapper-tokens.js"; export { extractEnvAssignmentKeysFromDispatchWrappers, diff --git a/src/infra/exec-wrapper-tokens.ts b/src/infra/exec-wrapper-tokens.ts index dbc0a5e73a2e..27fa39a1987d 100644 --- a/src/infra/exec-wrapper-tokens.ts +++ b/src/infra/exec-wrapper-tokens.ts @@ -12,6 +12,7 @@ function stripWindowsExecutableSuffix(value: string): string { return value; } +/** Return a lowercase basename using the shorter POSIX/Windows interpretation. */ export function basenameLower(token: string): string { const win = path.win32.basename(token); const posix = path.posix.basename(token); @@ -19,6 +20,7 @@ export function basenameLower(token: string): string { return normalizeLowercaseStringOrEmpty(base); } +/** Normalize an executable token for wrapper and policy matching. */ export function normalizeExecutableToken(token: string): string { return stripWindowsExecutableSuffix(basenameLower(token)); } diff --git a/src/infra/exec-wrapper-trust-plan.ts b/src/infra/exec-wrapper-trust-plan.ts index f6101ba7b7cd..4cbdab73868b 100644 --- a/src/infra/exec-wrapper-trust-plan.ts +++ b/src/infra/exec-wrapper-trust-plan.ts @@ -62,6 +62,11 @@ function finalizeExecWrapperTrustPlan( return plan; } +/** + * Resolves transparent dispatch wrappers into the executable that policy should inspect. + * Shell multiplexers keep their original argv as the trust target while exposing the + * nested shell command for shell-specific approval checks. + */ export function resolveExecWrapperTrustPlan( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, @@ -109,7 +114,7 @@ export function resolveExecWrapperTrustPlan( if (shellMultiplexerUnwrap.kind === "unwrapped") { wrapperChain.push(shellMultiplexerUnwrap.wrapper); if (!sawShellMultiplexer) { - // Preserve the real executable target for trust checks. + // Trust policy must see the multiplexer applet, not only the shell it launches. policyArgv = current; sawShellMultiplexer = true; } diff --git a/src/infra/fatal-error-hooks.ts b/src/infra/fatal-error-hooks.ts index 8d875de8f5c5..a2335d67a89c 100644 --- a/src/infra/fatal-error-hooks.ts +++ b/src/infra/fatal-error-hooks.ts @@ -1,8 +1,10 @@ +/** Context passed to fatal-error hooks before the process exits. */ export type FatalErrorHookContext = { reason: string; error?: unknown; }; +/** Hook that can return one extra diagnostic line for fatal error output. */ export type FatalErrorHook = (context: FatalErrorHookContext) => string | undefined | void; const hooks = new Set(); @@ -12,6 +14,7 @@ function formatHookFailure(error: unknown): string { return `fatal-error hook failed: ${name}`; } +/** Registers a fatal-error hook and returns an unsubscribe callback. */ export function registerFatalErrorHook(hook: FatalErrorHook): () => void { hooks.add(hook); return () => { @@ -19,6 +22,7 @@ export function registerFatalErrorHook(hook: FatalErrorHook): () => void { }; } +/** Runs registered fatal-error hooks and returns non-empty diagnostic lines. */ export function runFatalErrorHooks(context: FatalErrorHookContext): string[] { const messages: string[] = []; for (const hook of hooks) { @@ -28,12 +32,14 @@ export function runFatalErrorHooks(context: FatalErrorHookContext): string[] { messages.push(message); } } catch (err) { + // Fatal output must keep progressing even if a diagnostic hook itself throws. messages.push(formatHookFailure(err)); } } return messages; } +/** Clears registered fatal-error hooks; test-only helper. */ export function resetFatalErrorHooksForTest(): void { hooks.clear(); } diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 2b9aa8dd9c73..47abab86763f 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -7,6 +7,8 @@ type FetchWithPreconnect = typeof fetch & { type RequestInitWithDuplex = RequestInit & { duplex?: "half" }; +// Mark wrapped fetch functions so repeated resolver calls preserve identity and +// avoid stacking abort relays around the same implementation. const wrapFetchWithAbortSignalMarker = Symbol.for("openclaw.fetch.abort-signal-wrapped"); type FetchWithAbortSignalMarker = typeof fetch & { @@ -29,11 +31,16 @@ function withDuplex( if (init && "duplex" in (init as Record)) { return init; } + // Node requires `duplex: "half"` for streaming request bodies; browsers ignore it. return init ? ({ ...init, duplex: "half" as const } as RequestInitWithDuplex) : ({ duplex: "half" as const } as RequestInitWithDuplex); } +/** + * Wraps fetch so Node-compatible duplex bodies, normalized headers, and foreign + * AbortSignal implementations work against runtimes expecting native signals. + */ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { if ((fetchImpl as FetchWithAbortSignalMarker)[wrapFetchWithAbortSignalMarker]) { return fetchImpl; @@ -101,6 +108,7 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch return wrappedFetch; } +/** Resolves an optional fetch implementation, wrapping it when fetch is available. */ export function resolveFetch(fetchImpl?: typeof fetch): typeof fetch | undefined { const resolved = fetchImpl ?? globalThis.fetch; if (!resolved) { diff --git a/src/infra/file-lock-manager.ts b/src/infra/file-lock-manager.ts index ff5e50bb77eb..53a94f79288f 100644 --- a/src/infra/file-lock-manager.ts +++ b/src/infra/file-lock-manager.ts @@ -1,5 +1,7 @@ import "./fs-safe-defaults.js"; +// Process-local file lock manager used by code that needs explicit lifecycle +// control instead of a one-shot withFileLock call. export { createFileLockManager, type FileLockHeldEntry, diff --git a/src/infra/file-lock.ts b/src/infra/file-lock.ts index 90f14f390f41..51756988231e 100644 --- a/src/infra/file-lock.ts +++ b/src/infra/file-lock.ts @@ -1,3 +1,5 @@ +// Plugin SDK file-lock surface re-exported for infra callers that should share +// the same durable lock semantics as plugins. export type { FileLockHandle, FileLockOptions, diff --git a/src/infra/file-store.ts b/src/infra/file-store.ts index 315ab14d407f..9cbf600e9751 100644 --- a/src/infra/file-store.ts +++ b/src/infra/file-store.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Safe file-store facade. Callers get the repo default fs-safe configuration +// before constructing root-scoped stores. export { fileStore, type FileStore, diff --git a/src/infra/fixed-window-rate-limit.ts b/src/infra/fixed-window-rate-limit.ts index 50e61a2d07fb..f5333e088a25 100644 --- a/src/infra/fixed-window-rate-limit.ts +++ b/src/infra/fixed-window-rate-limit.ts @@ -1,12 +1,25 @@ +/** + * Shared fixed-window rate-limit primitive for gateway, ACP, and webhook ingress. + * + * It is intentionally in-memory and process-local; callers that need distributed + * limits must layer their own persistence before invoking request work. + */ + +/** Minimal fixed-window limiter interface used by memory and request guard helpers. */ export type FixedWindowRateLimiter = { consume: () => { + /** Whether the current call consumed quota successfully. */ allowed: boolean; + /** Milliseconds until the next fixed window when quota is exhausted. */ retryAfterMs: number; + /** Requests left in the current window after this consume call. */ remaining: number; }; + /** Clears the current fixed-window count and starts fresh on the next consume call. */ reset: () => void; }; +/** Normalizes rate-limit numeric config to a finite integer with a lower bound. */ export function resolveFixedWindowRateLimitInteger( value: number | undefined, fallback: number, @@ -16,9 +29,13 @@ export function resolveFixedWindowRateLimitInteger( return Math.max(params.min, Math.floor(candidate)); } +/** Creates a fixed-window counter that reports allowance, remaining quota, and retry delay. */ export function createFixedWindowRateLimiter(params: { + /** Maximum successful consume calls allowed per window. */ maxRequests: number; + /** Fixed window duration in milliseconds. */ windowMs: number; + /** Optional clock for tests or deterministic host runtimes. */ now?: () => number; }): FixedWindowRateLimiter { const maxRequests = resolveFixedWindowRateLimitInteger(params.maxRequests, 1, { min: 1 }); @@ -32,10 +49,12 @@ export function createFixedWindowRateLimiter(params: { consume() { const nowMs = now(); if (nowMs - windowStartMs >= windowMs) { + // Fixed-window semantics reset all quota at the first request after the window expires. windowStartMs = nowMs; count = 0; } if (count >= maxRequests) { + // Clamp retryAfterMs for injected clocks that move unexpectedly between consume calls. return { allowed: false, retryAfterMs: Math.max(0, windowStartMs + windowMs - nowMs), diff --git a/src/infra/fs-safe-advanced.ts b/src/infra/fs-safe-advanced.ts index ee9dcd8c9771..d7fe52faf028 100644 --- a/src/infra/fs-safe-advanced.ts +++ b/src/infra/fs-safe-advanced.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; + +// Advanced fs-safe helpers for symlink, hardlink, and sibling-temp protections. export { assertNoHardlinkedFinalPath, assertNoSymlinkParents, diff --git a/src/infra/fs-safe-defaults.ts b/src/infra/fs-safe-defaults.ts index 5cbe997e9f3a..0551e8965a59 100644 --- a/src/infra/fs-safe-defaults.ts +++ b/src/infra/fs-safe-defaults.ts @@ -1,5 +1,7 @@ import { configureFsSafePython } from "@openclaw/fs-safe/config"; +// OpenClaw does not rely on Python helpers for normal filesystem safety. Tests +// and operators can still opt in with fs-safe's documented env override. const hasPythonModeOverride = process.env.FS_SAFE_PYTHON_MODE != null || process.env.OPENCLAW_FS_SAFE_PYTHON_MODE != null; diff --git a/src/infra/gateway-discovery-targets.ts b/src/infra/gateway-discovery-targets.ts index ad5ee520de27..97007a41fc89 100644 --- a/src/infra/gateway-discovery-targets.ts +++ b/src/infra/gateway-discovery-targets.ts @@ -5,6 +5,8 @@ import { type GatewayDiscoveryResolvedEndpoint, } from "./bonjour-discovery.js"; +// Gateway discovery targets turn Bonjour beacons into display, websocket, and +// SSH connection hints without assuming every beacon has all fields. type GatewayDiscoveryTarget = { title: string; domain: string; @@ -20,6 +22,7 @@ function pickSshPort(beacon: GatewayBonjourBeacon): number | null { : null; } +/** Build normalized connection details for a discovered gateway beacon. */ export function buildGatewayDiscoveryTarget( beacon: GatewayBonjourBeacon, opts?: { sshUser?: string | null }, @@ -41,12 +44,14 @@ export function buildGatewayDiscoveryTarget( }; } +/** Build the compact label shown in discovery lists. */ export function buildGatewayDiscoveryLabel(beacon: GatewayBonjourBeacon): string { const target = buildGatewayDiscoveryTarget(beacon); const hint = target.endpoint ? `${target.endpoint.host}:${target.endpoint.port}` : "host unknown"; return `${target.title} (${hint})`; } +/** Serialize a beacon with resolved websocket information for CLI/UI output. */ export function serializeGatewayDiscoveryBeacon(beacon: GatewayBonjourBeacon) { const target = buildGatewayDiscoveryTarget(beacon); return { diff --git a/src/infra/gateway-processes.ts b/src/infra/gateway-processes.ts index 40fea27c24f0..5146a47f4611 100644 --- a/src/infra/gateway-processes.ts +++ b/src/infra/gateway-processes.ts @@ -8,6 +8,9 @@ import { readWindowsProcessArgsSync, } from "./windows-port-pids.js"; +// Gateway process helpers verify argv before signaling or reporting listener +// PIDs so stale port owners cannot be mistaken for OpenClaw. +/** Read command argv for a PID using the current platform's process APIs. */ export function readGatewayProcessArgsSync(pid: number): string[] | null { if (process.platform === "linux") { try { @@ -33,6 +36,7 @@ export function readGatewayProcessArgsSync(pid: number): string[] | null { return null; } +/** Signal a PID only after its argv matches a gateway process. */ export function signalVerifiedGatewayPidSync(pid: number, signal: "SIGTERM" | "SIGUSR1"): void { const args = readGatewayProcessArgsSync(pid); if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) { @@ -41,6 +45,7 @@ export function signalVerifiedGatewayPidSync(pid: number, signal: "SIGTERM" | "S process.kill(pid, signal); } +/** Find listener PIDs on `port` and keep only verified gateway processes. */ export function findVerifiedGatewayListenerPidsOnPortSync(port: number): number[] { const rawPids = process.platform === "win32" @@ -55,6 +60,7 @@ export function findVerifiedGatewayListenerPidsOnPortSync(port: number): number[ }); } +/** Format gateway PIDs for human-facing diagnostics. */ export function formatGatewayPidList(pids: number[]): string { return pids.join(", "); } diff --git a/src/infra/heartbeat-active-hours.ts b/src/infra/heartbeat-active-hours.ts index 9215ce306757..46f29a56c9dd 100644 --- a/src/infra/heartbeat-active-hours.ts +++ b/src/infra/heartbeat-active-hours.ts @@ -2,10 +2,13 @@ import { resolveUserTimezone } from "../agents/date-time.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Heartbeat active-hours helpers interpret user/local/IANA timezones and treat +// invalid config as permissive so bad schedules do not disable heartbeats. type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; const ACTIVE_HOURS_TIME_PATTERN = /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/; +/** Resolve the timezone used to evaluate heartbeat active hours. */ export function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { const trimmed = raw?.trim(); if (!trimmed || trimmed === "user") { @@ -67,6 +70,7 @@ function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | nul } } +/** Return true when the current time is inside the configured heartbeat window. */ export function isWithinActiveHours( cfg: OpenClawConfig, heartbeat?: HeartbeatConfig, diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts index 451de1d37bdc..efbbc0d07d68 100644 --- a/src/infra/heartbeat-reason.ts +++ b/src/infra/heartbeat-reason.ts @@ -1,5 +1,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +// Heartbeat wake reasons are displayed/logged, so normalize blanks to a stable +// default before they reach scheduling or diagnostics. +/** Normalize a heartbeat wake reason for logs and UI. */ export function normalizeHeartbeatWakeReason(reason?: string): string { return normalizeOptionalString(reason) ?? "requested"; } diff --git a/src/infra/heartbeat-runner.runtime.ts b/src/infra/heartbeat-runner.runtime.ts index 6cba74ded252..c16f7177f67d 100644 --- a/src/infra/heartbeat-runner.runtime.ts +++ b/src/infra/heartbeat-runner.runtime.ts @@ -1 +1,3 @@ +// Lazy heartbeat runtime facade keeps tests from importing the full auto-reply +// runtime unless the runner path needs it. export { getReplyFromConfig } from "../auto-reply/reply.js"; diff --git a/src/infra/heartbeat-runner.test-harness.ts b/src/infra/heartbeat-runner.test-harness.ts index 0528b2920c12..b85dd29693f0 100644 --- a/src/infra/heartbeat-runner.test-harness.ts +++ b/src/infra/heartbeat-runner.test-harness.ts @@ -7,6 +7,9 @@ import { import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +// Heartbeat runner tests install lightweight channel plugin facades so delivery +// behavior can be verified without real channel credentials. +/** Install the heartbeat runner channel registry before each test. */ export function installHeartbeatRunnerTestRuntime(params?: { includeSlack?: boolean }): void { beforeEach(() => { if (params?.includeSlack) { diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 4506184e3956..c983471418e4 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -9,6 +9,8 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import type { HeartbeatDeps } from "./heartbeat-runner.js"; +// Heartbeat test utilities seed session stores and temporary heartbeat prompts +// while keeping plugin registry and environment state isolated per test. type HeartbeatSessionSeed = { sessionId?: string; updatedAt?: number; @@ -35,6 +37,7 @@ function createHeartbeatReplySpy(): HeartbeatReplySpy { return replySpy; } +/** Write a single heartbeat session entry into a JSON session store. */ export async function seedSessionStore( storePath: string, sessionKey: string, @@ -59,6 +62,7 @@ export async function seedSessionStore( ); } +/** Seed the configured main session and return its session key. */ export async function seedMainSessionStore( storePath: string, cfg: OpenClawConfig, @@ -69,6 +73,7 @@ export async function seedMainSessionStore( return sessionKey; } +/** Run a heartbeat test inside a temporary prompt/session-store sandbox. */ export async function withTempHeartbeatSandbox( fn: (ctx: { tmpDir: string; storePath: string; replySpy: HeartbeatReplySpy }) => Promise, options?: { @@ -100,6 +105,7 @@ export async function withTempHeartbeatSandbox( } } +/** Run a Telegram heartbeat test with Telegram credentials removed. */ export async function withTempTelegramHeartbeatSandbox( fn: (ctx: { tmpDir: string; storePath: string; replySpy: HeartbeatReplySpy }) => Promise, options?: { @@ -112,6 +118,7 @@ export async function withTempTelegramHeartbeatSandbox( }); } +/** Install only the Telegram heartbeat plugin in the active test registry. */ export function setupTelegramHeartbeatPluginRuntimeForTests() { setActivePluginRegistry( createTestRegistry([ diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index bdbb0ff8d0bd..945656a0cb85 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -10,8 +10,11 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; +// Heartbeat summaries merge default and per-agent heartbeat config for CLI/UI +// display without scheduling any work. type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; +/** Normalized heartbeat configuration for one agent. */ export type HeartbeatSummary = { enabled: boolean; every: string; @@ -29,6 +32,7 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { return list.some((entry) => Boolean(entry?.heartbeat)); } +/** Return whether heartbeat scheduling applies to an agent. */ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); const list = cfg.agents?.list ?? []; @@ -44,6 +48,7 @@ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string return resolvedAgentId === resolveDefaultAgentId(cfg); } +/** Resolve a heartbeat interval string to milliseconds. */ export function resolveHeartbeatIntervalMs( cfg: OpenClawConfig, overrideEvery?: string, @@ -73,6 +78,7 @@ export function resolveHeartbeatIntervalMs( return ms; } +/** Resolve display-ready heartbeat settings for an agent. */ export function resolveHeartbeatSummaryForAgent( cfg: OpenClawConfig, agentId?: string, diff --git a/src/infra/heartbeat-typing.ts b/src/infra/heartbeat-typing.ts index 15fc59db42d0..96b7db7cfa40 100644 --- a/src/infra/heartbeat-typing.ts +++ b/src/infra/heartbeat-typing.ts @@ -4,6 +4,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; const DEFAULT_HEARTBEAT_TYPING_INTERVAL_SECONDS = 6; +// Heartbeat typing callbacks use optional channel heartbeat hooks to keep a +// typing indicator alive while the heartbeat response is generated. type HeartbeatTypingLogger = { debug?: (message: string, meta?: Record) => void; }; @@ -15,6 +17,7 @@ type HeartbeatTypingTarget = { threadId?: string | number | null; }; +/** Create typing start/stop/keepalive callbacks for a heartbeat delivery target. */ export function createHeartbeatTypingCallbacks(params: { cfg: OpenClawConfig; target: HeartbeatTypingTarget; diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index d4d78fac5c21..89d460dd42fc 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -2,9 +2,13 @@ import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels. import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; +/** Resolved heartbeat presentation toggles after defaults/channel/account precedence. */ export type ResolvedHeartbeatVisibility = { + /** Whether successful heartbeat content should be sent as visible chat text. */ showOk: boolean; + /** Whether warning/error heartbeat content should be sent as visible chat text. */ showAlerts: boolean; + /** Whether heartbeat status should emit indicator events for UI surfaces. */ useIndicator: boolean; }; @@ -14,11 +18,7 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = { useIndicator: true, // Emit indicator events }; -/** - * Resolve heartbeat visibility settings for a channel. - * Supports both deliverable channels and webchat. - * For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config. - */ +/** Resolves heartbeat visibility for a channel, applying account > channel > defaults precedence. */ export function resolveHeartbeatVisibility(params: { cfg: OpenClawConfig; channel: GatewayMessageChannel; @@ -26,7 +26,7 @@ export function resolveHeartbeatVisibility(params: { }): ResolvedHeartbeatVisibility { const { cfg, channel, accountId } = params; - // Webchat uses channel defaults only (no per-channel or per-account config) + // Webchat has no channel/account config branch, so only shared channel defaults apply. if (channel === "webchat") { const channelDefaults = cfg.channels?.defaults?.heartbeat; return { @@ -52,7 +52,6 @@ export function resolveHeartbeatVisibility(params: { const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined; const perAccount = accountCfg?.heartbeat; - // Precedence: per-account > per-channel > channel-defaults > global defaults return { showOk: perAccount?.showOk ?? diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 5f3fccef286a..ad766a3752ea 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -22,6 +22,8 @@ function resolveTermuxHome(env: NodeJS.ProcessEnv): string | undefined { if (!prefix || !normalize(env.ANDROID_DATA)) { return undefined; } + // Termux exposes PREFIX under the app sandbox; other Android/chroot prefixes + // should not be treated as user-home evidence. if (!/(?:^|\/)com\.termux\/files\/usr\/?$/u.test(prefix.replace(/\\/gu, "/"))) { return undefined; } @@ -49,6 +51,7 @@ function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): strin return explicitHome; } +/** Resolves OpenClaw's effective home, honoring OPENCLAW_HOME before OS homes. */ export function resolveEffectiveHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, @@ -57,6 +60,7 @@ export function resolveEffectiveHomeDir( return raw ? path.resolve(raw) : undefined; } +/** Resolves the underlying OS user home, ignoring OPENCLAW_HOME overrides. */ export function resolveOsHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, @@ -64,6 +68,8 @@ export function resolveOsHomeDir( const raw = resolveRawOsHomeDir(env, homedir); return raw ? path.resolve(raw) : undefined; } + +/** Resolves the effective home or falls back to cwd when no home source exists. */ export function resolveRequiredHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, @@ -71,6 +77,7 @@ export function resolveRequiredHomeDir( return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); } +/** Resolves the OS home or falls back to cwd when no OS home source exists. */ export function resolveRequiredOsHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, @@ -78,6 +85,7 @@ export function resolveRequiredOsHomeDir( return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd()); } +/** Expands leading `~`, `~/`, or `~\` with the effective home when one is known. */ export function expandHomePrefix( input: string, opts?: { @@ -98,6 +106,7 @@ export function expandHomePrefix( return input.replace(/^~(?=$|[\\/])/, home); } +/** Resolves a user-supplied path after trimming and expanding against the effective home. */ export function resolveHomeRelativePath( input: string, opts?: { @@ -120,6 +129,7 @@ export function resolveHomeRelativePath( return path.resolve(trimmed); } +/** Backward-compatible alias for resolving user paths against the effective home. */ export function resolveUserPath( input: string, env: NodeJS.ProcessEnv = process.env, @@ -128,6 +138,7 @@ export function resolveUserPath( return resolveHomeRelativePath(input, { env, homedir }); } +/** Resolves a user-supplied path against the OS home, ignoring OPENCLAW_HOME. */ export function resolveOsHomeRelativePath( input: string, opts?: { diff --git a/src/infra/host-env-security-policy.d.ts b/src/infra/host-env-security-policy.d.ts index 96dfc0e1498e..083c5fca95c1 100644 --- a/src/infra/host-env-security-policy.d.ts +++ b/src/infra/host-env-security-policy.d.ts @@ -1,3 +1,5 @@ +// Generated host-env security policy declarations used by TypeScript tests and +// runtime parity checks. type HostEnvSecurityPolicy = Readonly<{ blockedEverywhereKeys: readonly string[]; blockedOverrideOnlyKeys: readonly string[]; @@ -10,8 +12,10 @@ type HostEnvSecurityPolicy = Readonly<{ blockedOverrideKeys: readonly string[]; }>; +/** Load the host env security policy, optionally merging a raw override. */ export declare function loadHostEnvSecurityPolicy( rawPolicy?: Partial, ): HostEnvSecurityPolicy; +/** Default host env security policy generated from the repo policy source. */ export declare const HOST_ENV_SECURITY_POLICY: HostEnvSecurityPolicy; diff --git a/src/infra/inline-option-token.ts b/src/infra/inline-option-token.ts index d82bac358b43..2faee902d327 100644 --- a/src/infra/inline-option-token.ts +++ b/src/infra/inline-option-token.ts @@ -1,3 +1,4 @@ +/** Parsed command-line option token, preserving whether `=` appeared in the original token. */ export type InlineOptionToken = | { name: string; @@ -9,11 +10,14 @@ export type InlineOptionToken = inlineValue: string; }; +/** Splits one CLI-style option token into its flag name and optional inline value. */ export function parseInlineOptionToken(token: string): InlineOptionToken { const separatorIndex = token.indexOf("="); if (separatorIndex < 0) { return { name: token, hasInlineValue: false }; } + // Only the first separator is structural; subsequent `=` bytes belong to values like tokens, + // query strings, or file names passed through root/daemon command options. return { name: token.slice(0, separatorIndex), hasInlineValue: true, diff --git a/src/infra/install-flow.ts b/src/infra/install-flow.ts index 24c5663808c8..b17ff3c4a626 100644 --- a/src/infra/install-flow.ts +++ b/src/infra/install-flow.ts @@ -6,6 +6,8 @@ import { type ArchiveLogger, extractArchive, resolvePackedRootDir } from "./arch import { pathExists } from "./fs-safe.js"; import { withTempDir } from "./install-source-utils.js"; +// Install-flow helpers validate local install paths and unpack archives inside +// temporary workspaces before handing the resolved package root to callers. type ExistingInstallPathResult = | { ok: true; @@ -17,6 +19,7 @@ type ExistingInstallPathResult = error: string; }; +/** Resolve and stat a user-provided install path. */ export async function resolveExistingInstallPath( inputPath: string, ): Promise { @@ -28,6 +31,7 @@ export async function resolveExistingInstallPath( return { ok: true, resolvedPath, stat }; } +/** Extract an archive to a temp dir and run work against the detected package root. */ export async function withExtractedArchiveRoot(params: { archivePath: string; tempDirPrefix: string; diff --git a/src/infra/install-from-npm-spec.ts b/src/infra/install-from-npm-spec.ts index 76877fa05256..347746ecc03e 100644 --- a/src/infra/install-from-npm-spec.ts +++ b/src/infra/install-from-npm-spec.ts @@ -6,6 +6,11 @@ import { } from "./npm-pack-install.js"; import { validateRegistryNpmSpec } from "./npm-registry-spec.js"; +/** + * Validates a registry npm spec, downloads its archive, and delegates final installation. + * The caller supplies archive-specific params without `archivePath`; this helper injects + * the downloaded archive path and normalizes the npm archive flow result. + */ export async function installFromValidatedNpmSpecArchive< TResult extends { ok: boolean }, TArchiveInstallParams extends { archivePath: string }, @@ -22,6 +27,7 @@ export async function installFromValidatedNpmSpecArchive< const spec = params.spec.trim(); const specError = validateRegistryNpmSpec(spec); if (specError) { + // Reject unsupported specs before any network or archive extraction work starts. return { ok: false, error: specError }; } const flowResult = await installFromNpmSpecArchiveWithInstaller({ diff --git a/src/infra/install-mode-options.ts b/src/infra/install-mode-options.ts index c12186875df6..05463635a82b 100644 --- a/src/infra/install-mode-options.ts +++ b/src/infra/install-mode-options.ts @@ -10,6 +10,7 @@ type TimedInstallModeOptions = InstallModeOptions & { timeoutMs?: number; }; +/** Resolves shared install/update mode options with a required logger fallback. */ export function resolveInstallModeOptions( params: InstallModeOptions, defaultLogger: TLogger, @@ -25,6 +26,7 @@ export function resolveInstallModeOptions( }; } +/** Resolves install/update mode options plus an operation timeout default. */ export function resolveTimedInstallModeOptions( params: TimedInstallModeOptions, defaultLogger: TLogger, diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 3c4d2122961d..a540da79ffd7 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -148,6 +148,11 @@ async function resolveInstallPublishTarget(params: { }; } +/** + * Publishes a package directory into an install target via a staged copy. + * Update mode backs up the existing target, runs optional validation hooks, + * and rolls back when copy, dependency install, or validation fails. + */ export async function installPackageDir(params: { sourceDir: string; targetDir: string; @@ -354,6 +359,10 @@ export async function installPackageDir(params: { return { ok: true }; } +/** + * Installs a manifest-backed package directory while deriving whether npm + * dependencies must be installed and which hardlink policy is safe to use. + */ export async function installPackageDirWithManifestDeps(params: { sourceDir: string; targetDir: string; diff --git a/src/infra/install-safe-path.ts b/src/infra/install-safe-path.ts index fc0c6a100f7f..4686c649592d 100644 --- a/src/infra/install-safe-path.ts +++ b/src/infra/install-safe-path.ts @@ -6,6 +6,7 @@ export { safePathSegmentHashed, } from "@openclaw/fs-safe/advanced"; +/** Returns the package basename for scoped npm names while preserving plain ids. */ export function unscopedPackageName(name: string): string { const trimmed = name.trim(); if (!trimmed) { @@ -14,6 +15,7 @@ export function unscopedPackageName(name: string): string { return trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed; } +/** Matches a requested install id against either the full package name or unscoped basename. */ export function packageNameMatchesId(packageName: string, id: string): boolean { const trimmedId = id.trim(); if (!trimmedId) { diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index 25a060fd416a..ecb0fe4362f0 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -9,6 +9,7 @@ import { pathExists } from "./fs-safe.js"; import { applyNpmFreshnessBypassEnv, type NpmProjectInstallEnvOptions } from "./npm-install-env.js"; import { withTempWorkspace } from "./private-temp-workspace.js"; +/** Metadata npm reports when resolving a registry spec or packed archive. */ export type NpmSpecResolution = { name?: string; version?: string; @@ -19,6 +20,7 @@ export type NpmSpecResolution = { packageOpenClaw?: Record; }; +/** Flattened npm resolution fields stored on install results and diagnostics. */ export type NpmResolutionFields = { resolvedName?: string; resolvedVersion?: string; @@ -28,6 +30,7 @@ export type NpmResolutionFields = { resolvedAt?: string; }; +/** Converts npm resolution metadata into stable result field names. */ export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmResolutionFields { return { resolvedName: resolution?.name, @@ -39,6 +42,7 @@ export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmRes }; } +/** Creates a script-free npm environment for metadata and pack commands. */ export function createNpmMetadataEnv( scope: Pick = {}, ): NodeJS.ProcessEnv { @@ -70,6 +74,7 @@ function normalizeNpmViewMetadata(value: unknown): NpmSpecResolution | null { }; } +/** Reads npm registry metadata for a package spec without running package scripts. */ export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?: number }): Promise< | { ok: true; @@ -120,11 +125,13 @@ export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?: } } +/** Captures expected and actual npm integrity values when an install source drifts. */ export type NpmIntegrityDrift = { expectedIntegrity: string; actualIntegrity: string; }; +/** Runs a callback in a private temp directory and removes it afterward. */ export async function withTempDir( prefix: string, fn: (tmpDir: string) => Promise, @@ -132,6 +139,7 @@ export async function withTempDir( return await withTempWorkspace({ rootDir: os.tmpdir(), prefix }, async (tmp) => fn(tmp.dir)); } +/** Resolves and validates a user-supplied archive path before extraction. */ export async function resolveArchiveSourcePath(archivePath: string): Promise< | { ok: true; @@ -282,6 +290,7 @@ async function findPackedArchiveInDir(cwd: string): Promise return sortedByMtime[0]?.name; } +/** Packs an npm spec into a tarball in `cwd` and returns archive metadata. */ export async function packNpmSpecToArchive(params: { spec: string; timeoutMs: number; @@ -342,6 +351,10 @@ export async function packNpmSpecToArchive(params: { }; } +/** + * Reads package metadata from an existing npm archive using `npm pack --dry-run`. + * The archive path is validated first so callers get path errors before npm errors. + */ export async function resolveNpmPackArchiveMetadata(params: { archivePath: string; timeoutMs?: number; diff --git a/src/infra/install-target.ts b/src/infra/install-target.ts index 549871467936..8adbc8dcbcd1 100644 --- a/src/infra/install-target.ts +++ b/src/infra/install-target.ts @@ -3,6 +3,7 @@ import { formatErrorMessage } from "./errors.js"; import { pathExists } from "./fs-safe.js"; import { assertCanonicalPathWithinBase, resolveSafeInstallDir } from "./install-safe-path.js"; +/** Resolves and verifies an install target directory under a canonical base directory. */ export async function resolveCanonicalInstallTarget(params: { baseDir: string; id: string; @@ -32,6 +33,7 @@ export async function resolveCanonicalInstallTarget(params: { return { ok: true, targetDir: targetDirResult.path }; } +/** Ensures install mode does not overwrite an existing target; update mode may reuse it. */ export async function ensureInstallTargetAvailable(params: { mode: "install" | "update"; targetDir: string; diff --git a/src/infra/is-main.ts b/src/infra/is-main.ts index 060ebd8b92ee..1ffc77bdbf05 100644 --- a/src/infra/is-main.ts +++ b/src/infra/is-main.ts @@ -19,6 +19,7 @@ function normalizePathCandidate(candidate: string | undefined, cwd: string): str const resolved = path.resolve(cwd, candidate); try { + // Compare real paths so symlinked package bins and resolved entry files still match. return fs.realpathSync.native(resolved); } catch { return resolved; @@ -29,10 +30,13 @@ function resolveDefaultCwd(currentFile: string): string { try { return process.cwd(); } catch { + // `process.cwd()` can throw when the launch directory was removed; entrypoint checks should + // still work relative to the current module path. return path.dirname(currentFile); } } +/** Detects whether a module is executing as the process entrypoint, including wrapper launches. */ export function isMainModule({ currentFile, argv = process.argv, diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index ce6f6002eee8..2fcd22a8992f 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -24,6 +24,7 @@ export { writeJsonSync, } from "@openclaw/fs-safe/json"; +/** Reads and parses JSON, wrapping unexpected read failures in JsonFileReadError. */ export async function readJson(filePath: string): Promise { try { return await readJsonImpl(filePath); @@ -32,10 +33,12 @@ export async function readJson(filePath: string): Promise { } } +/** Strict JSON read alias for callers that must fail on missing or invalid files. */ export async function readJsonFileStrict(filePath: string): Promise { return readJson(filePath); } +/** Reads JSON when the file exists, returning null only for a missing path. */ export async function readJsonIfExists(filePath: string): Promise { try { return await readJsonIfExistsImpl(filePath); @@ -47,6 +50,7 @@ export async function readJsonIfExists(filePath: string): Promise { } } +/** Durable JSON read alias that keeps parse/read errors visible to callers. */ export async function readDurableJsonFile(filePath: string): Promise { return readJsonIfExists(filePath); } @@ -65,6 +69,7 @@ export async function tryReadJson(filePath: string): Promise { } } +/** Optional JSON read that returns null for missing, invalid, or racing files. */ export async function readJsonFile(filePath: string): Promise { return tryReadJson(filePath); } @@ -85,6 +90,7 @@ export type WriteTextAtomicOptions = { tempPrefix?: string; }; +/** Writes text through the repo atomic replace helper with durable fsync by default. */ export async function writeTextAtomic( filePath: string, content: string, diff --git a/src/infra/json-utf8-bytes.ts b/src/infra/json-utf8-bytes.ts index c35406d8643d..5934e98388dc 100644 --- a/src/infra/json-utf8-bytes.ts +++ b/src/infra/json-utf8-bytes.ts @@ -1,3 +1,4 @@ +/** Returns the UTF-8 byte length of JSON.stringify(value), falling back to String(value). */ export function jsonUtf8Bytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), "utf8"); @@ -6,11 +7,15 @@ export function jsonUtf8Bytes(value: unknown): number { } } +/** Best-effort byte count result for bounded JSON traversal. */ export type BoundedJsonUtf8Bytes = { + /** Bytes counted, or a value greater than the requested max when incomplete. */ bytes: number; + /** True when traversal completed without unsupported/circular/over-limit input. */ complete: boolean; }; +/** Returns JSON UTF-8 byte length, or Infinity when the value cannot serialize safely. */ export function jsonUtf8BytesOrInfinity(value: unknown): number { try { const serialized = JSON.stringify(value); @@ -38,6 +43,7 @@ function* enumerableOwnEntries(value: object): Generator<[string, unknown]> { } } +/** Returns the first enumerable own keys in JavaScript enumeration order. */ export function firstEnumerableOwnKeys(value: object, maxKeys: number): string[] { const keys: string[] = []; for (const key in value as Record) { @@ -52,6 +58,7 @@ export function firstEnumerableOwnKeys(value: object, maxKeys: number): string[] return keys; } +/** Counts JSON UTF-8 bytes up to a hard limit without fully serializing large objects. */ export function boundedJsonUtf8Bytes(value: unknown, maxBytes: number): BoundedJsonUtf8Bytes { let bytes = 0; const seen = new WeakSet(); @@ -95,6 +102,8 @@ export function boundedJsonUtf8Bytes(value: unknown, maxBytes: number): BoundedJ if (seen.has(objectEntry)) { throw new Error("json_byte_length_circular"); } + // Custom toJSON can hide arbitrary work or reshape output, so bounded + // traversal only handles Date's well-known JSON conversion explicitly. if ( typeof (objectEntry as { toJSON?: unknown }).toJSON === "function" && !(objectEntry instanceof Date) diff --git a/src/infra/kysely-node-sqlite.ts b/src/infra/kysely-node-sqlite.ts index 5bd41957210d..f7e601f51ae3 100644 --- a/src/infra/kysely-node-sqlite.ts +++ b/src/infra/kysely-node-sqlite.ts @@ -20,14 +20,18 @@ import { createQueryId, } from "kysely"; +// Kysely dialect for Node's synchronous node:sqlite API. The driver serializes +// connection use because DatabaseSync is single-connection and blocking. type MaybePromise = T | Promise; +/** Configuration for the node:sqlite Kysely dialect. */ export type NodeSqliteKyselyDialectConfig = { database: DatabaseSync | (() => MaybePromise); onCreateConnection?: (connection: DatabaseConnection) => MaybePromise; transactionMode?: "deferred" | "immediate" | "exclusive"; }; +/** Kysely dialect backed by a node:sqlite DatabaseSync instance. */ export class NodeSqliteKyselyDialect implements Dialect { readonly #config: NodeSqliteKyselyDialectConfig; @@ -74,6 +78,8 @@ class NodeSqliteKyselyDriver implements Driver { } async acquireConnection(): Promise { + // Kysely expects async acquisition even though node:sqlite is sync; the + // mutex preserves transaction ordering across concurrent callers. await this.#mutex.lock(); return this.#connection!; } diff --git a/src/infra/kysely-sync.ts b/src/infra/kysely-sync.ts index 53c6c85f14f6..e208e371f73f 100644 --- a/src/infra/kysely-sync.ts +++ b/src/infra/kysely-sync.ts @@ -3,6 +3,8 @@ import type { CompiledQuery, Kysely, QueryResult } from "kysely"; import { InsertQueryNode, Kysely as KyselyInstance } from "kysely"; import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js"; +// Sync query helpers execute compiled Kysely SQL against node:sqlite without +// going through Kysely's async driver path. type CompilableQuery = { compile(): CompiledQuery; }; @@ -21,6 +23,7 @@ export function getNodeSqliteKysely(db: DatabaseSync): Kysely( db: DatabaseSync, compiledQuery: CompiledQuery, @@ -46,6 +49,7 @@ export function executeCompiledSqliteQuerySync( return result; } +/** Compile and execute a Kysely query synchronously. */ export function executeSqliteQuerySync( db: DatabaseSync, query: CompilableQuery, @@ -53,6 +57,7 @@ export function executeSqliteQuerySync( return executeCompiledSqliteQuerySync(db, query.compile()); } +/** Execute a Kysely query synchronously and return its first row. */ export function executeSqliteQueryTakeFirstSync( db: DatabaseSync, query: CompilableQuery, @@ -60,6 +65,7 @@ export function executeSqliteQueryTakeFirstSync( return executeSqliteQuerySync(db, query).rows[0]; } +/** Drop the cached Kysely facade for a DatabaseSync after close/test reset. */ export function clearNodeSqliteKyselyCacheForDatabase(db: DatabaseSync): void { kyselyByDatabase.delete(db); } diff --git a/src/infra/local-file-access.ts b/src/infra/local-file-access.ts index cda4a2d83e6a..dd460af670df 100644 --- a/src/infra/local-file-access.ts +++ b/src/infra/local-file-access.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; + +// Local user-file URL helpers centralize encoded separator and UNC path checks. export { assertNoWindowsNetworkPath, basenameFromMediaSource, diff --git a/src/infra/machine-name.ts b/src/infra/machine-name.ts index 0299a936c936..969a7cf7c9b4 100644 --- a/src/infra/machine-name.ts +++ b/src/infra/machine-name.ts @@ -5,6 +5,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe const execFileAsync = promisify(execFile); +// Machine display names prefer macOS ComputerName when available and fall back +// to hostname for deterministic tests and non-macOS hosts. let cachedPromise: Promise | null = null; async function tryScutil(key: "ComputerName" | "LocalHostName") { @@ -25,6 +27,7 @@ function fallbackHostName() { return trimmed.replace(/\.local$/i, "") || "openclaw"; } +/** Resolve a user-facing name for the current machine. */ export async function getMachineDisplayName(): Promise { if (cachedPromise) { return cachedPromise; diff --git a/src/infra/map-size.ts b/src/infra/map-size.ts index b99a55cb9079..f00182ed2107 100644 --- a/src/infra/map-size.ts +++ b/src/infra/map-size.ts @@ -1,5 +1,7 @@ +/** Prunes a Map in insertion order until it fits the requested maximum size. */ export function pruneMapToMaxSize(map: Map, maxSize: number): void { if (Number.isNaN(maxSize) || maxSize === Number.POSITIVE_INFINITY) { + // Treat "unknown" or unlimited sizes as no-op so callers can wire optional caps directly. return; } const limit = Math.max(0, Math.floor(maxSize)); @@ -9,6 +11,8 @@ export function pruneMapToMaxSize(map: Map, maxSize: number): void { } while (map.size > limit) { + // Map iteration is insertion ordered; deleting the first key preserves the newest tracked + // entries for request/memory guard caches. const oldest = map.keys().next(); if (oldest.done) { break; diff --git a/src/infra/net/configured-local-origin-bypass.ts b/src/infra/net/configured-local-origin-bypass.ts index 6f6cbf5e3e8c..ff32a6178538 100644 --- a/src/infra/net/configured-local-origin-bypass.ts +++ b/src/infra/net/configured-local-origin-bypass.ts @@ -2,6 +2,8 @@ import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { getActiveManagedProxyLoopbackMode } from "./proxy/active-proxy-state.js"; import { SsrFBlockedError } from "./ssrf.js"; +// Configured local-origin bypass allows managed proxy calls to skip proxying +// only when config, DNS, and active loopback policy all prove a loopback target. export type ConfiguredLocalOriginManagedProxyBypass = { kind: "configured-local-origin"; baseUrl: string; @@ -56,6 +58,7 @@ function isPinnedLoopbackTarget(addresses: readonly string[]): boolean { return addresses.length > 0 && addresses.every((address) => isLoopbackIpAddress(address)); } +/** Return whether a configured local provider origin may bypass the managed proxy. */ export function shouldUseConfiguredLocalOriginManagedProxyBypass(params: { url: URL; managedProxyBypass: ConfiguredLocalOriginManagedProxyBypass | undefined; diff --git a/src/infra/net/hostname.ts b/src/infra/net/hostname.ts index 3993e07ef5fd..21449aa119ea 100644 --- a/src/infra/net/hostname.ts +++ b/src/infra/net/hostname.ts @@ -1,5 +1,8 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Hostname normalization is intentionally display/compare-oriented: lowercase, +// trim brackets for IPv6 literals, and remove trailing DNS dots. +/** Normalize a hostname for policy comparisons. */ export function normalizeHostname(hostname: string): string { const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.+$/, ""); if (normalized.startsWith("[") && normalized.endsWith("]")) { diff --git a/src/infra/network-discovery-display.ts b/src/infra/network-discovery-display.ts index 2ee4017813ca..07dd2cd9f7f1 100644 --- a/src/infra/network-discovery-display.ts +++ b/src/infra/network-discovery-display.ts @@ -2,6 +2,8 @@ import type { GatewayBindMode } from "../config/types.js"; import { pickPrimaryLanIPv4, resolveGatewayBindHost } from "../gateway/net.js"; import { pickPrimaryTailnetIPv4 } from "./tailnet.js"; +// Display helpers are best-effort wrappers around network discovery. Startup +// and config output should keep rendering even when interface probes fail. function summarizeDisplayNetworkError(error: unknown): string { if (error instanceof Error) { const message = error.message.trim(); @@ -22,6 +24,7 @@ function fallbackBindHostForDisplay(bindMode: GatewayBindMode, customBindHost?: return "127.0.0.1"; } +/** Return a LAN IPv4 for display, or undefined when interface discovery fails. */ export function pickBestEffortPrimaryLanIPv4(): string | undefined { try { return pickPrimaryLanIPv4(); @@ -30,6 +33,7 @@ export function pickBestEffortPrimaryLanIPv4(): string | undefined { } } +/** Return a tailnet IPv4 plus an optional warning suitable for user output. */ export function inspectBestEffortPrimaryTailnetIPv4(params?: { warningPrefix?: string }): { tailnetIPv4: string | undefined; warning?: string; @@ -43,6 +47,7 @@ export function inspectBestEffortPrimaryTailnetIPv4(params?: { warningPrefix?: s } } +/** Resolve the gateway bind host for display, falling back to a safe placeholder. */ export async function resolveBestEffortGatewayBindHostForDisplay(params: { bindMode: GatewayBindMode; customBindHost?: string; diff --git a/src/infra/network-interfaces.ts b/src/infra/network-interfaces.ts index 42a52652f875..17d0344304ae 100644 --- a/src/infra/network-interfaces.ts +++ b/src/infra/network-interfaces.ts @@ -1,5 +1,6 @@ import os from "node:os"; +/** Raw `os.networkInterfaces()` snapshot used by gateway discovery helpers. */ export type NetworkInterfacesSnapshot = ReturnType; type NetworkInterfaceFamily = "IPv4" | "IPv6"; type ExternalNetworkInterfaceAddress = { @@ -11,6 +12,7 @@ type ExternalNetworkInterfaceAddress = { function normalizeNetworkInterfaceFamily( family: string | number | undefined, ): NetworkInterfaceFamily | undefined { + // Node versions and test fixtures can expose family as either string or number. if (family === "IPv4" || family === 4) { return "IPv4"; } @@ -20,12 +22,14 @@ function normalizeNetworkInterfaceFamily( return undefined; } +/** Reads the current network interface snapshot, allowing tests to inject a reader. */ export function readNetworkInterfaces( networkInterfaces: () => NetworkInterfacesSnapshot = os.networkInterfaces, ): NetworkInterfacesSnapshot { return networkInterfaces(); } +/** Best-effort interface read that returns undefined when OS inspection fails. */ export function safeNetworkInterfaces( networkInterfaces: () => NetworkInterfacesSnapshot = os.networkInterfaces, ): NetworkInterfacesSnapshot | undefined { @@ -36,6 +40,7 @@ export function safeNetworkInterfaces( } } +/** Lists non-internal interface addresses, optionally filtered by IP family. */ export function listExternalInterfaceAddresses( snapshot: NetworkInterfacesSnapshot | undefined, family?: NetworkInterfaceFamily, @@ -68,6 +73,7 @@ export function listExternalInterfaceAddresses( return addresses; } +/** Picks a matching external address, honoring preferred interface names first. */ export function pickMatchingExternalInterfaceAddress( snapshot: NetworkInterfacesSnapshot | undefined, params: { diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index 3aa35051d2dc..c4ea8b957fbb 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -1,3 +1,4 @@ +// Node tool command names shared by routing, auth, and approval surfaces. export const NODE_SYSTEM_RUN_COMMANDS = [ "system.run.prepare", "system.run", diff --git a/src/infra/node-shell.ts b/src/infra/node-shell.ts index b25d3a1fc2cf..7c561cc791f5 100644 --- a/src/infra/node-shell.ts +++ b/src/infra/node-shell.ts @@ -1,5 +1,8 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Node shell command construction keeps platform shell flags centralized for +// system.run and related command execution paths. +/** Build argv for running a command through the platform default shell. */ export function buildNodeShellCommand(command: string, platform?: string | null) { const normalized = normalizeLowercaseStringOrEmpty((platform ?? "").trim()); if (normalized.startsWith("win")) { diff --git a/src/infra/node-sqlite.ts b/src/infra/node-sqlite.ts index 11c1c586f7cd..6344d8400a94 100644 --- a/src/infra/node-sqlite.ts +++ b/src/infra/node-sqlite.ts @@ -4,6 +4,9 @@ import { installProcessWarningFilter } from "./warning-filter.js"; const require = createRequire(import.meta.url); +// node:sqlite is optional across Node versions, so callers get a clear runtime +// error instead of a low-level module resolution failure. +/** Load node:sqlite after installing the process warning filter. */ export function requireNodeSqlite(): typeof import("node:sqlite") { installProcessWarningFilter(); try { diff --git a/src/infra/non-fatal-cleanup.ts b/src/infra/non-fatal-cleanup.ts index 712198ebd334..d8cbaf2586be 100644 --- a/src/infra/non-fatal-cleanup.ts +++ b/src/infra/non-fatal-cleanup.ts @@ -1,3 +1,6 @@ +// Best-effort cleanup helper for temp files and disposable resources where +// cleanup failure should be reported but not replace the main result. +/** Run cleanup and swallow failures after invoking the optional error hook. */ export async function runBestEffortCleanup(params: { cleanup: () => Promise; onError?: (error: unknown) => void; diff --git a/src/infra/npm-install-env.ts b/src/infra/npm-install-env.ts index f6ac1fa070ea..a66491448866 100644 --- a/src/infra/npm-install-env.ts +++ b/src/infra/npm-install-env.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +/** Options that scope npm config and cache paths for project-local installs. */ export type NpmProjectInstallEnvOptions = { cacheDir?: string; npmConfigCwd?: string; @@ -253,6 +254,10 @@ function resolveNpmFreshnessBypassMode( return hasRawNpmConfigKey(env, "before", scope) ? "before" : "min-release-age"; } +/** + * Builds npm args that bypass host freshness policies for OpenClaw-managed installs. + * Existing npmrc policy decides whether `before` or `min-release-age` is safer. + */ export function createNpmFreshnessBypassArgs( env: NodeJS.ProcessEnv = process.env, now = new Date(), @@ -264,6 +269,7 @@ export function createNpmFreshnessBypassArgs( return [`--before=${now.toISOString()}`]; } +/** Applies the same npm freshness bypass policy through environment variables. */ export function applyNpmFreshnessBypassEnv( env: NodeJS.ProcessEnv, now = new Date(), @@ -284,6 +290,10 @@ export function applyNpmFreshnessBypassEnv( } } +/** + * Creates npm env for project-local installs, clearing global/workspace config + * and adding fetch, freshness, cache, and POSIX script-shell defaults. + */ export function createNpmProjectInstallEnv( env: NodeJS.ProcessEnv, options: NpmProjectInstallEnvOptions = {}, @@ -313,10 +323,12 @@ export function createNpmProjectInstallEnv( return installEnv; } +/** Returns true when caller env already pins npm's lifecycle script shell. */ export function hasNpmScriptShellSetting(env: NodeJS.ProcessEnv): boolean { return NPM_CONFIG_SCRIPT_SHELL_KEYS.some((key) => Boolean(env[key]?.trim())); } +/** Resolves an absolute POSIX shell for npm lifecycle scripts when one is available. */ export function resolvePosixNpmScriptShell(env: NodeJS.ProcessEnv): string | null { if (process.platform === "win32") { return null; @@ -328,6 +340,7 @@ export function resolvePosixNpmScriptShell(env: NodeJS.ProcessEnv): string | nul return shell && path.isAbsolute(shell) && fsSync.existsSync(shell) ? shell : null; } +/** Sets npm's script-shell env only when the caller has not configured one. */ export function applyPosixNpmScriptShellEnv(env: NodeJS.ProcessEnv): void { if (hasNpmScriptShellSetting(env)) { return; diff --git a/src/infra/npm-integrity.ts b/src/infra/npm-integrity.ts index 36e782a4961b..62a9369e1a0b 100644 --- a/src/infra/npm-integrity.ts +++ b/src/infra/npm-integrity.ts @@ -1,5 +1,6 @@ import type { NpmIntegrityDrift, NpmSpecResolution } from "./install-source-utils.js"; +/** Payload passed to npm integrity drift handlers during archive installs. */ export type NpmIntegrityDriftPayload = { spec: string; expectedIntegrity: string; @@ -32,6 +33,10 @@ function normalizeIntegrity(value: string | undefined): string | undefined { return normalized ? normalized : undefined; } +/** + * Compares expected and resolved npm integrity values and asks the caller + * whether a drifted archive may still be installed. + */ export async function resolveNpmIntegrityDrift( params: ResolveNpmIntegrityDriftParams, ): Promise> { @@ -73,6 +78,10 @@ type ResolveNpmIntegrityDriftWithDefaultMessageParams = { warn?: (message: string) => void; }; +/** + * Resolves integrity drift with OpenClaw's default warning and abort messages. + * Used by npm archive installers that do not need a custom payload shape. + */ export async function resolveNpmIntegrityDriftWithDefaultMessage( params: ResolveNpmIntegrityDriftWithDefaultMessageParams, ): Promise<{ integrityDrift?: NpmIntegrityDrift; error?: string }> { diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 567d7bb33256..be9eabcecad8 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -12,6 +12,8 @@ import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js"; import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js"; +// Managed npm roots are private package roots used for installed plugins. This +// module owns package.json dependency/override edits and peer repair helpers. type ManagedNpmRootManifest = { private?: boolean; dependencies?: Record; @@ -33,11 +35,13 @@ type ManagedNpmRootOpenClawMetadata = { [key: string]: unknown; }; +/** Snapshot of root dependencies that were inserted only for peer satisfaction. */ export type ManagedNpmRootPeerDependencySnapshot = { dependencies: Record; managedPeerDependencies: string[]; }; +/** Installed dependency metadata read from a managed root lockfile. */ export type ManagedNpmRootInstalledDependency = { version?: string; integrity?: string; @@ -190,6 +194,7 @@ function filterUnsupportedManagedNpmRootOverrides(value: unknown): Record { @@ -656,6 +664,7 @@ export async function readManagedNpmRootPeerDependencySnapshot(params: { }; } +/** Restore a previously captured managed peer dependency snapshot. */ export async function restoreManagedNpmRootPeerDependencySnapshot(params: { npmRoot: string; snapshot: ManagedNpmRootPeerDependencySnapshot; @@ -686,6 +695,7 @@ export async function restoreManagedNpmRootPeerDependencySnapshot(params: { await writeJson(manifestPath, next, { trailingNewline: true }); } +/** Sync package.json with peer dependency pins resolved from npm's lock plan. */ export async function syncManagedNpmRootPeerDependencies(params: { npmRoot: string; managedOverrides?: Record; @@ -756,6 +766,7 @@ export async function syncManagedNpmRootPeerDependencies(params: { return changed; } +/** Remove stale managed-root openclaw peer installs while preserving active host links. */ export async function repairManagedNpmRootOpenClawPeer(params: { npmRoot: string; packageRoot?: string | null; @@ -992,6 +1003,7 @@ async function scrubManagedNpmRootOpenClawPeer(params: { }); } +/** Read lockfile metadata for an installed dependency in the managed root. */ export async function readManagedNpmRootInstalledDependency(params: { npmRoot: string; packageName: string; @@ -1012,6 +1024,7 @@ export async function readManagedNpmRootInstalledDependency(params: { }; } +/** Remove a dependency from the managed root manifest. */ export async function removeManagedNpmRootDependency(params: { npmRoot: string; packageName: string; diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index 1a36d175e60c..67715dad3d33 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -26,6 +26,11 @@ type NpmSpecArchiveInstallFlowResult = integrityDrift?: NpmIntegrityDrift; }; +/** + * Adapts installers with additional domain params to the shared npm-pack flow. + * The archive path stays owned by this module so callers cannot install a stale + * or caller-supplied tarball while reusing the npm resolution checks. + */ export async function installFromNpmSpecArchiveWithInstaller< TResult extends { ok: boolean }, TArchiveInstallParams extends { archivePath: string }, @@ -54,6 +59,11 @@ export async function installFromNpmSpecArchiveWithInstaller< }); } +/** + * Final caller-facing result after a packed npm spec install. + * Failed pack/validation results and installer failures keep their original + * shapes; successful installs gain the npm resolution metadata. + */ export type NpmSpecArchiveFinalInstallResult = | { ok: false; error: string } | Exclude @@ -68,6 +78,10 @@ function isSuccessfulInstallResult( return result.ok; } +/** + * Collapses the shared flow result back into the installer's result union while + * preserving npm metadata only for a successful install. + */ export function finalizeNpmSpecArchiveInstall( flowResult: NpmSpecArchiveInstallFlowResult, ): NpmSpecArchiveFinalInstallResult { @@ -89,6 +103,10 @@ export function finalizeNpmSpecArchiveInstall( return finalized; } +/** + * Packs a validated registry npm spec into a temporary tarball, verifies the + * resolved package metadata, then delegates archive extraction to the caller. + */ export async function installFromNpmSpecArchive(params: { tempDirPrefix: string; spec: string; @@ -106,6 +124,8 @@ export async function installFromNpmSpecArchive error: "unsupported npm spec", }; } + // Pack before checking prerelease policy so dist-tag and range specs are + // evaluated against the version the registry actually resolved. const packedResult = await packNpmSpecToArchive({ spec: params.spec, timeoutMs: params.timeoutMs, @@ -135,6 +155,8 @@ export async function installFromNpmSpecArchive }; } + // Integrity drift is the last shared gate before extraction; installer + // callbacks should only run for archives the caller accepted. const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec: params.spec, expectedIntegrity: params.expectedIntegrity, diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 929c7fc9138a..283d1282c5a0 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -11,6 +11,7 @@ const OPENCLAW_BETA_VERSION_RE = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +/** Parsed date-based OpenClaw release version used for channel-aware ordering. */ type OpenClawReleaseVersion = { channel: "alpha" | "beta" | "stable"; dateTime: number; @@ -19,6 +20,11 @@ type OpenClawReleaseVersion = { correctionNumber?: number; }; +/** + * Parsed registry-only npm spec accepted by plugin install flows. + * Selectors are limited to exact versions and dist-tags; URL/git/file specs + * are rejected before they can execute on the gateway host. + */ export type ParsedRegistryNpmSpec = { name: string; raw: string; @@ -54,6 +60,8 @@ function parseRegistryNpmSpecInternal( const name = hasSelector ? spec.slice(0, at) : spec; const selector = hasSelector ? spec.slice(at + 1) : ""; + // Accept only registry package names; file paths, aliases, and URL/git specs are intentionally + // rejected before this point because plugin installs run on the gateway host. const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/; const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/; const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name); @@ -112,25 +120,30 @@ function parseRegistryNpmSpecInternal( }; } +/** Parses a registry-only npm package spec into package name and optional selector metadata. */ export function parseRegistryNpmSpec(rawSpec: string): ParsedRegistryNpmSpec | null { const parsed = parseRegistryNpmSpecInternal(rawSpec); return parsed.ok ? parsed.parsed : null; } +/** Returns whether a user-provided npm spec resolves to the official OpenClaw npm scope. */ export function isOpenClawOrgNpmSpec(rawSpec: string | undefined): boolean { const parsed = rawSpec ? parseRegistryNpmSpec(rawSpec) : null; return parsed?.name.startsWith("@openclaw/") === true; } +/** Validates a registry-only npm spec and returns a user-facing error when rejected. */ export function validateRegistryNpmSpec(rawSpec: string): string | null { const parsed = parseRegistryNpmSpecInternal(rawSpec); return parsed.ok ? null : parsed.error; } +/** Returns whether a value is an exact semver selector, with optional leading `v`. */ export function isExactSemverVersion(value: string): boolean { return EXACT_SEMVER_VERSION_RE.test(value.trim()); } +/** Parses OpenClaw's date-based stable/alpha/beta/correction version format. */ function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null { const trimmed = value.trim(); const candidates = [ @@ -163,6 +176,8 @@ function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | nu candidate.channel === "stable" && candidate.match.groups.correction ? Number.parseInt(candidate.match.groups.correction, 10) : undefined; + // Stable correction releases share the stable channel rank; the optional + // correction number is compared later so base stable sorts before fixes. const alphaNumber = candidate.channel === "alpha" ? Number.parseInt(candidate.match.groups.alpha ?? "", 10) @@ -181,11 +196,13 @@ function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | nu }; } +/** Returns whether a version is an OpenClaw date-based stable correction release. */ export function isOpenClawStableCorrectionVersion(value: string): boolean { const parsed = parseOpenClawReleaseVersion(value); return parsed?.channel === "stable" && parsed.correctionNumber !== undefined; } +/** Compares OpenClaw date-based release versions across alpha, beta, stable, and corrections. */ export function compareOpenClawReleaseVersions(left: string, right: string): number | null { const parsedLeft = parseOpenClawReleaseVersion(left); const parsedRight = parseOpenClawReleaseVersion(right); @@ -208,12 +225,18 @@ export function compareOpenClawReleaseVersions(left: string, right: string): num return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0)); } +/** Returns whether an exact semver value is a prerelease, excluding stable correction releases. */ export function isPrereleaseSemverVersion(value: string): boolean { const trimmed = value.trim(); const match = EXACT_SEMVER_VERSION_RE.exec(trimmed); return Boolean(match?.[4]) && !isOpenClawStableCorrectionVersion(trimmed); } +/** + * Enforces explicit opt-in before an npm spec may resolve to a prerelease. + * Bare specs and `latest` stay on stable releases unless the resolved version + * is an OpenClaw stable correction. + */ export function isPrereleaseResolutionAllowed(params: { spec: ParsedRegistryNpmSpec; resolvedVersion?: string; @@ -221,6 +244,8 @@ export function isPrereleaseResolutionAllowed(params: { if (!params.resolvedVersion || !isPrereleaseSemverVersion(params.resolvedVersion)) { return true; } + // Bare specs and `latest` should not drift into beta/rc builds; prereleases require a tag or + // exact prerelease selector so automation remains stable. if (params.spec.selectorKind === "none") { return false; } @@ -230,6 +255,7 @@ export function isPrereleaseResolutionAllowed(params: { return normalizeLowercaseStringOrEmpty(params.spec.selector) !== "latest"; } +/** Formats the install error shown when a registry spec resolves to a disallowed prerelease. */ export function formatPrereleaseResolutionError(params: { spec: ParsedRegistryNpmSpec; resolvedVersion: string; diff --git a/src/infra/numeric-options.ts b/src/infra/numeric-options.ts index 9d0f5146f479..e1811904c3dd 100644 --- a/src/infra/numeric-options.ts +++ b/src/infra/numeric-options.ts @@ -3,10 +3,14 @@ import { resolveNonNegativeIntegerOption as resolveSharedNonNegativeIntegerOption, } from "@openclaw/normalization-core/number-coercion"; +// Numeric option facades keep legacy infra imports aligned with shared +// normalization-core semantics. +/** Resolve a non-negative integer option or return the fallback. */ export function resolveNonNegativeIntegerOption(value: number, fallback: number): number { return resolveSharedNonNegativeIntegerOption(value, fallback); } +/** Resolve an integer option with a minimum bound or return the fallback. */ export function resolveIntegerOption( value: number, fallback: number, diff --git a/src/infra/openclaw-exec-env.ts b/src/infra/openclaw-exec-env.ts index b4e8a8765841..9242814a6733 100644 --- a/src/infra/openclaw-exec-env.ts +++ b/src/infra/openclaw-exec-env.ts @@ -1,14 +1,23 @@ +/** Process env key that marks child commands as launched by the OpenClaw CLI. */ export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI"; + +/** Stable marker value used for OpenClaw-launched subprocess detection. */ export const OPENCLAW_CLI_ENV_VALUE = "1"; -export function markOpenClawExecEnv>(env: T): T { +/** Returns a cloned env object with the OpenClaw CLI marker set. */ +export function markOpenClawExecEnv>( + /** Source environment to clone before adding the subprocess marker. */ + env: T, +): T { return { ...env, [OPENCLAW_CLI_ENV_VAR]: OPENCLAW_CLI_ENV_VALUE, }; } +/** Mutates an existing process env object so current-process children inherit the marker. */ export function ensureOpenClawExecMarkerOnProcess( + /** Process env object to mutate; defaults to the current process environment. */ env: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { env[OPENCLAW_CLI_ENV_VAR] = OPENCLAW_CLI_ENV_VALUE; diff --git a/src/infra/openclaw-root.fs.runtime.ts b/src/infra/openclaw-root.fs.runtime.ts index 89210054c447..3e0ff74996b7 100644 --- a/src/infra/openclaw-root.fs.runtime.ts +++ b/src/infra/openclaw-root.fs.runtime.ts @@ -1,2 +1,4 @@ +// OpenClaw root resolution imports fs through this facade so tests can replace +// filesystem behavior without mocking node:fs globally. export { default as openClawRootFsSync } from "node:fs"; export { default as openClawRootFs } from "node:fs/promises"; diff --git a/src/infra/os-summary.ts b/src/infra/os-summary.ts index 82e3c3b275de..5b9e6d178c6b 100644 --- a/src/infra/os-summary.ts +++ b/src/infra/os-summary.ts @@ -17,11 +17,14 @@ function macosVersion(): string { return out || os.release(); } +/** Resolves a compact OS label for diagnostics, logs, and environment summaries. */ export function resolveOsSummary(): OsSummary { const platform = os.platform(); const release = os.release(); const arch = os.arch(); const cacheKey = `${platform}\0${release}\0${arch}`; + // Cache by stable os.* facts; darwin's sw_vers lookup is comparatively slow + // and only needed once per observed platform/release/arch tuple. const cached = cachedOsSummaryByKey.get(cacheKey); if (cached) { return cached; diff --git a/src/infra/outbound/channel-selection.runtime.ts b/src/infra/outbound/channel-selection.runtime.ts index f93b4a349b53..7170735b8398 100644 --- a/src/infra/outbound/channel-selection.runtime.ts +++ b/src/infra/outbound/channel-selection.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for outbound channel selection without importing test helpers. export { resolveMessageChannelSelection } from "./channel-selection.js"; diff --git a/src/infra/outbound/delivery-queue.test-helpers.ts b/src/infra/outbound/delivery-queue.test-helpers.ts index 0960799a43dc..8d567d6c88e1 100644 --- a/src/infra/outbound/delivery-queue.test-helpers.ts +++ b/src/infra/outbound/delivery-queue.test-helpers.ts @@ -5,6 +5,7 @@ import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import type { DeliverFn, RecoveryLogger } from "./delivery-queue.js"; +/** Installs Vitest hooks that provide a fresh delivery-queue state dir per case. */ export function installDeliveryQueueTmpDirHooks(): { readonly tmpDir: () => string } { let tmpDir = ""; let fixtureRoot = ""; diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 751ceaea7030..8eac5066713a 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -1,3 +1,4 @@ +// Public outbound delivery queue facade for storage and recovery operations. export { ackDelivery, enqueueDelivery, diff --git a/src/infra/outbound/message-action-runner.test-helpers.ts b/src/infra/outbound/message-action-runner.test-helpers.ts index f47033602cdf..a0e984ffad14 100644 --- a/src/infra/outbound/message-action-runner.test-helpers.ts +++ b/src/infra/outbound/message-action-runner.test-helpers.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createChannelTestPluginBase } from "../../test-utils/channel-plugins.js"; import { runMessageAction } from "./message-action-runner.js"; +/** Workspace-style config fixture used by message action runner tests. */ export const workspaceConfig = { channels: { workspace: { @@ -18,6 +19,7 @@ export const workspaceConfig = { }, } as OpenClawConfig; +/** Direct-chat config fixture that allows any sender. */ export const directChatConfig = { channels: { directchat: { @@ -28,6 +30,7 @@ export const directChatConfig = { export const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" }; +// Test plugins model token-gated workspace sends without booting real channel runtimes. function hasChannelBotToken(channelConfig: unknown): boolean { if (channelConfig == null || typeof channelConfig !== "object" || Array.isArray(channelConfig)) { return false; diff --git a/src/infra/outbound/message-action-test-fixtures.ts b/src/infra/outbound/message-action-test-fixtures.ts index 354e33bfbb89..e3014a8a7caa 100644 --- a/src/infra/outbound/message-action-test-fixtures.ts +++ b/src/infra/outbound/message-action-test-fixtures.ts @@ -1,3 +1,4 @@ +/** Returns a bootstrap registry mock for message-action alias tests. */ export function createPinboardMessageActionBootstrapRegistryMock() { return (channel: string) => { if (channel === "pinboard") { diff --git a/src/infra/outbound/message-action-threading.test-helpers.ts b/src/infra/outbound/message-action-threading.test-helpers.ts index f8839d18c7a0..751c5a85f95e 100644 --- a/src/infra/outbound/message-action-threading.test-helpers.ts +++ b/src/infra/outbound/message-action-threading.test-helpers.ts @@ -17,6 +17,7 @@ type OutboundThreadContext = { resolveAutoThreadId?: AutoThreadResolver; }; +// Mutate actionParams like the real outbound path so tests assert forwarded thread ids. function resolveOutboundThreadId( actionParams: Record, context: OutboundThreadContext, @@ -38,6 +39,7 @@ function resolveOutboundThreadId( return resolved ?? undefined; } +/** Creates mocks for reply/thread resolution used by outbound message action tests. */ export function createOutboundThreadingMock() { const resolveOutboundReplyToId = vi.fn( ( diff --git a/src/infra/outbound/message.config.runtime.ts b/src/infra/outbound/message.config.runtime.ts index 6cac39b0d6f6..27263d7b04ed 100644 --- a/src/infra/outbound/message.config.runtime.ts +++ b/src/infra/outbound/message.config.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for message-action config reads. export { getRuntimeConfig } from "../../config/io.js"; diff --git a/src/infra/outbound/message.gateway.runtime.ts b/src/infra/outbound/message.gateway.runtime.ts index 63a3d89723cd..ae390693afd4 100644 --- a/src/infra/outbound/message.gateway.runtime.ts +++ b/src/infra/outbound/message.gateway.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for Gateway calls used by outbound message delivery. export { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; diff --git a/src/infra/outbound/outbound-session.test-helpers.ts b/src/infra/outbound/outbound-session.test-helpers.ts index 0b5117c09759..dd10f6187d3e 100644 --- a/src/infra/outbound/outbound-session.test-helpers.ts +++ b/src/infra/outbound/outbound-session.test-helpers.ts @@ -22,6 +22,7 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; +// Session route fixtures cover direct, group, and threaded outbound routes without real plugins. function createSessionRouteTestPlugin(params: { id: ChannelPlugin["id"]; label: string; @@ -506,6 +507,7 @@ function resolveTlonOutboundSessionRouteForTest(params: ChannelOutboundSessionRo }); } +/** Installs a minimal channel registry for outbound session route tests. */ export function setMinimalOutboundSessionPluginRegistryForTests(): void { const plugins: ChannelPlugin[] = [ createSessionRouteTestPlugin({ diff --git a/src/infra/outbound/source-reply-mirror.ts b/src/infra/outbound/source-reply-mirror.ts index 9773b375f541..c47edcbd3423 100644 --- a/src/infra/outbound/source-reply-mirror.ts +++ b/src/infra/outbound/source-reply-mirror.ts @@ -29,6 +29,7 @@ type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & sessionKey: string; }; +// Mirror only enough delivered payload detail to preserve transcript context. function readStringArray(value: unknown): string[] | undefined { return normalizeOptionalTrimmedStringList(value); } @@ -117,6 +118,7 @@ function isCurrentSourceConversation( ); } +/** Mirrors successful outbound source replies into the owning session transcript. */ export async function mirrorDeliveredSourceReplyToTranscript( params: SourceReplyTranscriptMirrorParams, ): Promise { diff --git a/src/infra/outbound/targets.runtime.ts b/src/infra/outbound/targets.runtime.ts index 8fa572ff4514..b56dc831872e 100644 --- a/src/infra/outbound/targets.runtime.ts +++ b/src/infra/outbound/targets.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for outbound target resolution. export { resolveOutboundTarget } from "./targets.js"; diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index e35a39357e6a..d635d9cb79c4 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -8,6 +8,7 @@ import { createTestChannelPlugin, } from "./targets.test-helpers.js"; +/** Installs target-resolution plugin registry fixtures around shared tests. */ export function installResolveOutboundTargetPluginRegistryHooks(): void { beforeEach(() => { setActivePluginRegistry( diff --git a/src/infra/outbound/targets.test-helpers.ts b/src/infra/outbound/targets.test-helpers.ts index 0163dc880d4f..1bcde54dd1ef 100644 --- a/src/infra/outbound/targets.test-helpers.ts +++ b/src/infra/outbound/targets.test-helpers.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +// Target fixtures keep normalization deterministic while exercising plugin-owned seams. function readTestDefaultTo(cfg: OpenClawConfig, channelId: string): string | undefined { const channels = cfg.channels as Record | undefined; const value = channels?.[channelId]?.defaultTo; @@ -17,6 +18,7 @@ function stripTestPrefix(raw: string, channelId: string): string { return raw.replace(new RegExp(`^${channelId}:`, "i"), "").trim(); } +/** Parses forum test targets with optional topic/thread suffixes. */ export function parseForumTargetForTest(raw: string): { roomId: string; threadId?: number; diff --git a/src/infra/outbound/tool-payload.ts b/src/infra/outbound/tool-payload.ts index 7c2ac4d97211..1536bc607942 100644 --- a/src/infra/outbound/tool-payload.ts +++ b/src/infra/outbound/tool-payload.ts @@ -1 +1,2 @@ +// Infra facade for plugin SDK tool payload extraction. export { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 0c4bf2fa7470..353f87cea818 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -149,6 +149,7 @@ function isLegacyPluginDependencyDirPath(relativePath: string): boolean { return pluginDependencyDir.toLowerCase() === "node_modules"; } +/** Detects transient plugin dependency install-stage directories inside packaged extension dist. */ export function isLegacyPluginDependencyInstallStagePath(relativePath: string): boolean { const parts = splitRelativePath(relativePath); return ( @@ -379,6 +380,7 @@ async function collectRelativeFiles( } } +/** Collects package dist files that should be present after install/update publication. */ export async function collectPackageDistInventory(packageRoot: string): Promise { const rules = await collectPackageDistInventoryRulesForRoot(packageRoot); const scanContext = createPackageDistInventoryScanContext(); @@ -390,6 +392,7 @@ export async function collectPackageDistInventory(packageRoot: string): Promise< ); } +/** Lists legacy plugin dependency staging directories that must not ship in package dist. */ export async function collectLegacyPluginDependencyStagingDebrisPaths( packageRoot: string, ): Promise { @@ -465,6 +468,7 @@ export async function collectLegacyPluginDependencyStagingDebrisPaths( return debris.toSorted((left, right) => left.localeCompare(right)); } +/** Fails when transient plugin dependency staging debris remains in package dist. */ export async function assertNoLegacyPluginDependencyStagingDebris( packageRoot: string, ): Promise { @@ -477,6 +481,7 @@ export async function assertNoLegacyPluginDependencyStagingDebris( ); } +/** Writes the current sorted package dist inventory and returns the entries written. */ export async function writePackageDistInventory(packageRoot: string): Promise { await assertNoLegacyPluginDependencyStagingDebris(packageRoot); const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot)); @@ -497,12 +502,14 @@ async function readPackageDistInventoryOptional(packageRoot: string): Promise { return await readPackageDistInventoryOptional(packageRoot); } +/** Compares recorded and current package dist inventory entries and returns human-readable errors. */ export async function collectPackageDistInventoryErrors(packageRoot: string): Promise { const expectedFiles = await readPackageDistInventoryIfPresent(packageRoot); if (expectedFiles === null) { diff --git a/src/infra/package-json.ts b/src/infra/package-json.ts index e060157e4ff1..c40251918cda 100644 --- a/src/infra/package-json.ts +++ b/src/infra/package-json.ts @@ -8,6 +8,7 @@ type PackageJson = { version?: unknown; }; +/** Reads package.json as a loose object, returning null for missing or invalid manifests. */ export async function readPackageJson(root: string): Promise { const parsed = await tryReadJson(path.join(root, "package.json")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) @@ -15,14 +16,17 @@ export async function readPackageJson(root: string): Promise : null; } +/** Reads and trims the package version string, returning null for blank or non-string values. */ export async function readPackageVersion(root: string): Promise { return normalizeString((await readPackageJson(root))?.version); } +/** Reads and trims the package name string, returning null for blank or non-string values. */ export async function readPackageName(root: string): Promise { return normalizeString((await readPackageJson(root))?.name); } +/** Reads and trims the packageManager spec, returning null for blank or non-string values. */ export async function readPackageManagerSpec(root: string): Promise { return normalizeString((await readPackageJson(root))?.packageManager); } diff --git a/src/infra/package-tag.ts b/src/infra/package-tag.ts index 62f666a1e9df..1a2e67389196 100644 --- a/src/infra/package-tag.ts +++ b/src/infra/package-tag.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Normalizes a package tag input, stripping known package-name prefixes when present. */ export function normalizePackageTagInput( value: string | undefined | null, packageNames: readonly string[], diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index f711ab94f3b0..ec766031d021 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -20,6 +20,10 @@ import { const PACKAGE_MANAGER_SWAP_SOURCE_HARDLINKS = "allow" as const; +/** + * Captures one package-manager or filesystem step from the global update flow. + * Callers surface these records directly in update diagnostics. + */ export type PackageUpdateStepResult = { name: string; command: string; @@ -486,6 +490,10 @@ async function swapStagedNpmInstall(params: { } } +/** + * Runs the global package update flow, including npm staging when possible, + * package verification, optional post-verification, and cleanup. + */ export async function runGlobalPackageUpdateSteps(params: { installTarget: ResolvedGlobalInstallTarget; installSpec: string; diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts index d71b5e3bc26f..1e4e8d783ecd 100644 --- a/src/infra/package-update-utils.ts +++ b/src/infra/package-update-utils.ts @@ -3,6 +3,9 @@ import path from "node:path"; import { readRootJsonObjectSync } from "@openclaw/fs-safe/json"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +// Package update utilities inspect installed package metadata without trusting +// paths outside the provided package root. +/** Return expected integrity only for concrete semver package specs. */ export function expectedIntegrityForUpdate( spec: string | undefined, integrity: string | undefined, @@ -34,11 +37,13 @@ function readInstalledPackageManifest(dir: string): Record | un return result.ok ? result.value : undefined; } +/** Read the installed package version from a package root. */ export async function readInstalledPackageVersion(dir: string): Promise { const manifest = readInstalledPackageManifest(dir); return typeof manifest?.version === "string" ? manifest.version : undefined; } +/** Read string-valued peer dependencies from an installed package. */ export function readInstalledPackagePeerDependencies(dir: string): Record { const manifest = readInstalledPackageManifest(dir); const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {}; @@ -50,6 +55,7 @@ export function readInstalledPackagePeerDependencies(dir: string): Record): string { return "PATH"; } +/** Normalizes configured PATH prepends by trimming blanks and preserving first-seen order. */ export function normalizePathPrepend(entries?: string[]) { if (!Array.isArray(entries)) { return []; @@ -41,6 +42,7 @@ export function normalizePathPrepend(entries?: string[]) { return normalized; } +/** Merges prepended PATH entries ahead of the existing PATH while deduping normalized parts. */ export function mergePathPrepend(existing: string | undefined, prepend: string[]) { if (prepend.length === 0) { return existing; @@ -50,6 +52,7 @@ export function mergePathPrepend(existing: string | undefined, prepend: string[] ); } +/** Removes managed prepend entries from an existing PATH, including later duplicate copies. */ export function removePathPrepend( existing: string | undefined, prepend: string[], @@ -67,6 +70,7 @@ export function removePathPrepend( return remaining.join(path.delimiter); } +/** Applies configured PATH prepends in-place, preserving Windows PATH key casing. */ export function applyPathPrepend( env: Record, prepend: string[] | undefined, diff --git a/src/infra/path-safety.ts b/src/infra/path-safety.ts index 018a082ec6dc..c046c3bf9ff1 100644 --- a/src/infra/path-safety.ts +++ b/src/infra/path-safety.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; + +// Back-compat import path for path guard helpers used across core surfaces. export { isNotFoundPathError, hasNodeErrorCode, diff --git a/src/infra/permissions.ts b/src/infra/permissions.ts index e19f6358e92d..a129eb74eba0 100644 --- a/src/infra/permissions.ts +++ b/src/infra/permissions.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Permission inspection facades expose fs-safe POSIX and Windows ACL helpers +// after applying OpenClaw's fs-safe defaults. export { formatPermissionDetail, formatPermissionRemediation, diff --git a/src/infra/plugin-approvals.ts b/src/infra/plugin-approvals.ts index 6b452892f1a5..110a026da51e 100644 --- a/src/infra/plugin-approvals.ts +++ b/src/infra/plugin-approvals.ts @@ -1,5 +1,8 @@ import type { ExecApprovalDecision } from "./exec-approvals.js"; +// Plugin approval types and renderers mirror exec approval decisions while +// keeping plugin-facing request text and action metadata separate. +/** Button/action metadata shown with a plugin approval request. */ export type PluginApprovalActionView = { kind?: "command" | "decision"; label: string; @@ -8,6 +11,7 @@ export type PluginApprovalActionView = { style?: "primary" | "secondary" | "success" | "danger"; }; +/** Request payload supplied by plugin approval callers. */ export type PluginApprovalRequestPayload = { pluginId?: string | null; title: string; @@ -25,6 +29,7 @@ export type PluginApprovalRequestPayload = { turnSourceThreadId?: string | number | null; }; +/** Timed plugin approval request persisted while awaiting a decision. */ export type PluginApprovalRequest = { id: string; request: PluginApprovalRequestPayload; @@ -32,6 +37,7 @@ export type PluginApprovalRequest = { expiresAtMs: number; }; +/** Resolved plugin approval decision plus optional request snapshot. */ export type PluginApprovalResolved = { id: string; decision: ExecApprovalDecision; @@ -50,6 +56,7 @@ export const DEFAULT_PLUGIN_APPROVAL_DECISIONS = [ "deny", ] as const satisfies readonly ExecApprovalDecision[]; +/** Clamp a plugin approval timeout to the supported runtime bounds. */ export function resolvePluginApprovalTimeoutMs(value: unknown): number { const candidate = typeof value === "number" && Number.isFinite(value) @@ -58,6 +65,7 @@ export function resolvePluginApprovalTimeoutMs(value: unknown): number { return Math.min(MAX_PLUGIN_APPROVAL_TIMEOUT_MS, Math.max(1, Math.floor(candidate))); } +/** Format an approval decision for user-facing messages. */ export function approvalDecisionLabel(decision: ExecApprovalDecision): string { if (decision === "allow-once") { return "allowed once"; @@ -68,6 +76,7 @@ export function approvalDecisionLabel(decision: ExecApprovalDecision): string { return "denied"; } +/** Resolve explicit plugin approval decisions or fall back to defaults. */ export function resolvePluginApprovalRequestAllowedDecisions(params?: { allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null; }): readonly ExecApprovalDecision[] { @@ -85,6 +94,7 @@ export function resolvePluginApprovalRequestAllowedDecisions(params?: { return explicit.length > 0 ? explicit : DEFAULT_PLUGIN_APPROVAL_DECISIONS; } +/** Build the pending plugin approval message. */ export function buildPluginApprovalRequestMessage( request: PluginApprovalRequest, nowMsValue: number, @@ -115,12 +125,14 @@ export function buildPluginApprovalRequestMessage( return lines.join("\n"); } +/** Build the plugin approval resolution message. */ export function buildPluginApprovalResolvedMessage(resolved: PluginApprovalResolved): string { const base = `✅ Plugin approval ${approvalDecisionLabel(resolved.decision)}.`; const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : ""; return `${base}${by} ID: ${resolved.id}`; } +/** Build the plugin approval expiration message. */ export function buildPluginApprovalExpiredMessage(request: PluginApprovalRequest): string { return `⏱️ Plugin approval expired. ID: ${request.id}`; } diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index bf4d37f27269..66b0fb21d028 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -2,6 +2,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { formatCliCommand } from "../cli/command-format.js"; import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js"; +/** Classifies a listener as OpenClaw Gateway, SSH tunnel, or unknown process. */ export function classifyPortListener(listener: PortListener, port: number): PortListenerKind { const raw = normalizeLowercaseStringOrEmpty( `${listener.commandLine ?? ""} ${listener.command ?? ""}`, @@ -48,6 +49,8 @@ function parseListenerAddress(address: string): { host: string; port: number } | return Number.isFinite(port) ? { host, port } : null; } +// Dual-stack listener output can include IPv4-mapped IPv6 addresses; keep them +// in the IPv6 family so the benign loopback-pair detection stays conservative. function classifyLoopbackAddressFamily(host: string): "ipv4" | "ipv6" | null { if (host === "127.0.0.1" || host === "localhost") { return "ipv4"; @@ -70,6 +73,7 @@ function isExpectedGatewayBindAddress(host: string): boolean { return classifyLoopbackAddressFamily(host) !== null || isWildcardAddress(host); } +/** Returns true for one Gateway listener bound to an expected loopback or wildcard address. */ export function isSingleExpectedGatewayListener(listeners: PortListener[], port: number): boolean { if (listeners.length !== 1) { return false; @@ -93,6 +97,7 @@ export function isSingleExpectedGatewayListener(listeners: PortListener[], port: ); } +/** Returns true for one Gateway process represented by separate IPv4 and IPv6 loopback rows. */ export function isDualStackLoopbackGatewayListeners( listeners: PortListener[], port: number, @@ -127,6 +132,7 @@ export function isDualStackLoopbackGatewayListeners( return pids.size === 1 && families.has("ipv4") && families.has("ipv6"); } +/** Returns true when listener rows describe a benign Gateway bind pattern. */ export function isExpectedGatewayListeners(listeners: PortListener[], port: number): boolean { return ( isSingleExpectedGatewayListener(listeners, port) || @@ -134,6 +140,7 @@ export function isExpectedGatewayListeners(listeners: PortListener[], port: numb ); } +/** Builds user-facing remediation hints for processes occupying a port. */ export function buildPortHints(listeners: PortListener[], port: number): string[] { if (listeners.length === 0) { return []; @@ -162,6 +169,7 @@ export function buildPortHints(listeners: PortListener[], port: number): string[ return hints; } +/** Formats one listener row for CLI diagnostics. */ export function formatPortListener(listener: PortListener): string { const pid = listener.pid ? `pid ${listener.pid}` : "pid ?"; const user = listener.user ? ` ${listener.user}` : ""; @@ -170,6 +178,7 @@ export function formatPortListener(listener: PortListener): string { return `${pid}${user}: ${command}${address}`; } +/** Formats free/busy port diagnostics into CLI output lines. */ export function formatPortDiagnostics(diagnostics: PortUsage): string[] { if (diagnostics.status !== "busy") { return [`Port ${diagnostics.port} is free.`]; diff --git a/src/infra/ports-probe.ts b/src/infra/ports-probe.ts index 9d971ea94935..ceb22c4ad487 100644 --- a/src/infra/ports-probe.ts +++ b/src/infra/ports-probe.ts @@ -1,8 +1,12 @@ import net from "node:net"; +/** Opens and closes a temporary listener to verify that a port can be bound. */ export async function tryListenOnPort(params: { + /** TCP port to probe; `0` lets the OS allocate an available ephemeral port. */ port: number; + /** Optional host/interface to bind during the probe. */ host?: string; + /** Whether the probe should request an exclusive server handle from Node. */ exclusive?: boolean; }): Promise { const listenOptions: net.ListenOptions = { port: params.port }; @@ -17,6 +21,7 @@ export async function tryListenOnPort(params: { .createServer() .once("error", (err) => reject(err)) .once("listening", () => { + // Binding succeeded; close immediately so the real server can claim the same port. tester.close(() => resolve()); }) .listen(listenOptions); diff --git a/src/infra/ports-types.ts b/src/infra/ports-types.ts index d34f64af3eed..f61c621d4f8e 100644 --- a/src/infra/ports-types.ts +++ b/src/infra/ports-types.ts @@ -1,3 +1,5 @@ +// Port probe types are shared by lsof/netstat readers and CLI status formatters. +/** Process metadata for one listener on a port. */ export type PortListener = { pid?: number; ppid?: number; @@ -9,12 +11,14 @@ export type PortListener = { export type PortConnectionDirection = "client" | "server" | "unknown"; +/** Listener plus inferred client/server direction. */ export type PortConnection = PortListener & { direction: PortConnectionDirection; }; export type PortUsageStatus = "free" | "busy" | "unknown"; +/** Port usage summary returned by port probes. */ export type PortUsage = { port: number; status: PortUsageStatus; @@ -26,6 +30,7 @@ export type PortUsage = { export type PortListenerKind = "gateway" | "ssh" | "unknown"; +/** Connection list for a single port probe. */ export type PortConnections = { port: number; connections: PortConnection[]; diff --git a/src/infra/private-file-store.ts b/src/infra/private-file-store.ts index 3c171ca76244..03c115cd81ac 100644 --- a/src/infra/private-file-store.ts +++ b/src/infra/private-file-store.ts @@ -6,14 +6,17 @@ import { type FileStoreSync, } from "@openclaw/fs-safe/store"; +// Private stores create owner-only files under a caller-provided root. export type PrivateFileStore = FileStore; +/** Create an async private file store rooted at `rootDir`. */ export function privateFileStore(rootDir: string): FileStore { return fileStore({ rootDir, private: true }); } export type PrivateFileStoreSync = FileStoreSync; +/** Create a sync private file store rooted at `rootDir`. */ export function privateFileStoreSync(rootDir: string): PrivateFileStoreSync { return fileStoreSync({ rootDir, private: true }); } diff --git a/src/infra/private-temp-workspace.ts b/src/infra/private-temp-workspace.ts index 41d13028aa6a..f46a03bb964e 100644 --- a/src/infra/private-temp-workspace.ts +++ b/src/infra/private-temp-workspace.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Private temp workspaces isolate downloads and generated artifacts under a +// caller-selected temp root with cleanup ownership. export { tempWorkspace, tempWorkspaceSync, diff --git a/src/infra/prototype-keys.ts b/src/infra/prototype-keys.ts index 9762aae019a1..59eb0764f912 100644 --- a/src/infra/prototype-keys.ts +++ b/src/infra/prototype-keys.ts @@ -1,5 +1,8 @@ +// Keys blocked from object writes to avoid prototype pollution at untrusted +// object boundaries. const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]); +/** Return true when assigning `key` could mutate an object prototype. */ export function isBlockedObjectKey(key: string): boolean { return BLOCKED_OBJECT_KEYS.has(key); } diff --git a/src/infra/provider-usage-plugin-runtime.test-mocks.ts b/src/infra/provider-usage-plugin-runtime.test-mocks.ts index f1c3bdae56b7..668aa3b6175a 100644 --- a/src/infra/provider-usage-plugin-runtime.test-mocks.ts +++ b/src/infra/provider-usage-plugin-runtime.test-mocks.ts @@ -20,6 +20,7 @@ vi.mock("../plugins/provider-runtime.js", async () => { }; }); +/** Resets the plugin-backed provider usage mock to the default no-snapshot behavior. */ export function resetProviderUsageSnapshotWithPluginMock() { resolveProviderUsageSnapshotWithPluginMock.mockReset(); resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 469bf94258a7..0efd84953b4e 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -3,6 +3,7 @@ import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number import { PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; +/** Fetches JSON-compatible provider usage endpoints with an abort timeout. */ export async function fetchJson( url: string, init: RequestInit, @@ -30,6 +31,7 @@ type BuildUsageHttpErrorSnapshotOptions = { tokenExpiredStatuses?: readonly number[]; }; +/** Builds a provider usage snapshot for non-HTTP fetch or parse failures. */ export function buildUsageErrorSnapshot( provider: UsageProviderId, error: string, diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 7a68604b12fc..6e2eb7022f03 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,3 +1,4 @@ +// Public facade for built-in provider usage fetch implementations. export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; export { fetchDeepSeekUsage } from "./provider-usage.fetch.deepseek.js"; diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index 4fc4a632d8bc..27d7202a7cf8 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -1,6 +1,7 @@ import { clampPercent } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageSummary, UsageWindow } from "./provider-usage.types.js"; +// Compact reset times for chat/status lines; long windows fall back to a date. function formatResetRemaining(targetMs?: number, now?: number): string | null { if (!targetMs) { return null; @@ -40,6 +41,7 @@ function formatWindowShort(window: UsageWindow, now?: number): string { return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`; } +/** Formats one provider snapshot into a short usage-window summary. */ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index dd145f9f4dd4..9cdb22050b08 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -15,6 +15,7 @@ import type { UsageSummary, } from "./provider-usage.types.js"; +// Built-in fallback intentionally reports unsupported until a plugin supplies usage behavior. async function fetchProviderUsageSnapshotFallback(params: { auth: ProviderAuth; timeoutMs: number; @@ -79,6 +80,7 @@ async function fetchProviderUsageSnapshot(params: { }); } +/** Loads usage snapshots from configured provider auth and plugin-backed usage hooks. */ export async function loadProviderUsageSummary( opts: UsageSummaryOptions = {}, ): Promise { diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index d094f16b9814..46f5c053160d 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -2,6 +2,7 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import type { UsageProviderId } from "./provider-usage.types.js"; +/** Default timeout for provider usage collection. */ export const DEFAULT_TIMEOUT_MS = 5000; export const PROVIDER_LABELS: Record = { @@ -28,10 +29,12 @@ export const usageProviders: UsageProviderId[] = [ "zai", ]; +/** Returns true for providers whose usage endpoint is only meaningful with OAuth/token auth. */ export function isOAuthOnlyUsageProvider(provider: UsageProviderId): boolean { return provider === "openai"; } +/** Maps model/provider ids and credential type into supported usage provider ids. */ export function resolveUsageProviderId( provider?: string | null, options?: { credentialType?: string | null }, @@ -72,6 +75,7 @@ export const ignoredErrors = new Set([ export const clampPercent = (value: number) => Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)); +/** Resolves a promise with a fallback when usage collection exceeds the timeout. */ export const withTimeout = async (work: Promise, ms: number, fallback: T): Promise => { let timeout: NodeJS.Timeout | undefined; const timeoutMs = resolveTimerTimeoutMs(ms, 1); diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index af69b18f9d71..824ba0742f42 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -1,3 +1,4 @@ +// Public provider usage facade for formatting, loading, and shared types. export { formatUsageReportLines, formatUsageSummaryLine, diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index fb166829dd2d..76c287258289 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -1,3 +1,4 @@ +/** One quota window reported by a provider usage endpoint. */ export type UsageWindow = { label: string; usedPercent: number; diff --git a/src/infra/regular-file.ts b/src/infra/regular-file.ts index 64cdba8df0ef..ecbd539822a4 100644 --- a/src/infra/regular-file.ts +++ b/src/infra/regular-file.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Regular-file IO helpers reject symlinks and non-file targets before reads or +// appends touch user-controlled paths. export { appendRegularFile, appendRegularFileSync, diff --git a/src/infra/remote-env.ts b/src/infra/remote-env.ts index 7ff6008a2e34..4b24c2095309 100644 --- a/src/infra/remote-env.ts +++ b/src/infra/remote-env.ts @@ -1,5 +1,7 @@ import { isWSLEnv } from "./wsl.js"; +// Remote environment detection gates local UX that depends on a desktop session +// or direct host access. export function isRemoteEnvironment(): boolean { if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { return true; diff --git a/src/infra/replace-file.ts b/src/infra/replace-file.ts index 09a2232412c9..041b00f4cd87 100644 --- a/src/infra/replace-file.ts +++ b/src/infra/replace-file.ts @@ -18,12 +18,18 @@ export { type ReplaceFileAtomicSyncOptions, } from "@openclaw/fs-safe/atomic"; +/** Atomic file replacement primitive re-exported through the fs-safe defaults shim. */ export const replaceFileAtomic = replaceFileAtomicBase; +/** Options for moving paths while optionally rejecting hardlinked source files. */ export type MovePathWithCopyFallbackOptions = BaseMovePathWithCopyFallbackOptions & { sourceHardlinks?: "allow" | "reject"; }; +/** + * Moves a path using fs-safe's copy fallback, with an OpenClaw hardlink guard + * for install/update flows that must not preserve package-manager links. + */ export async function movePathWithCopyFallback( options: MovePathWithCopyFallbackOptions, ): Promise { diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts index dc4881bbf81f..66e90af2b756 100644 --- a/src/infra/restart-coordinator.ts +++ b/src/infra/restart-coordinator.ts @@ -7,6 +7,8 @@ import { } from "../tasks/task-registry.maintenance.js"; import { scheduleGatewaySigusr1Restart, type ScheduledRestart } from "./restart.js"; +// Safe restart coordination checks active local work before scheduling SIGUSR1 +// restarts, while still allowing explicit deferral bypasses for operators. export type SafeGatewayRestartCounts = { queueSize: number; pendingReplies: number; @@ -118,6 +120,8 @@ export function createSafeGatewayRestartPreflight( if (taskBlockers.length === 0) { blockers.push(createFallbackTaskBlocker(counts.activeTasks)); } else { + // Cap task details so restart diagnostics stay bounded even during a + // backlog; counts still preserve the total active-task signal. for (const task of taskBlockers.slice(0, 8)) { blockers.push({ kind: "task", @@ -145,6 +149,7 @@ export function createSafeGatewayRestartPreflight( }; } +/** Schedule a gateway restart after collecting queue/reply/task blockers. */ export function requestSafeGatewayRestart( opts: { reason?: string; diff --git a/src/infra/restart-handoff.ts b/src/infra/restart-handoff.ts index 02ce563ff0a0..cd483cbd32b5 100644 --- a/src/infra/restart-handoff.ts +++ b/src/infra/restart-handoff.ts @@ -5,6 +5,8 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +// Restart handoff files let a supervisor explain a recent gateway restart after +// the old process exits. The file is short-lived, bounded, and regular-file only. export const GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME = "gateway-supervisor-restart-handoff.json"; export const GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND = "gateway-supervisor-restart-handoff"; @@ -77,6 +79,7 @@ function formatDiagnosticValue(value: string): string { return normalized.trimEnd(); } +/** Format a compact diagnostic for a recently consumed restart handoff. */ export function formatGatewayRestartHandoffDiagnostic( handoff: GatewayRestartHandoff, now = Date.now(), @@ -110,6 +113,7 @@ function unlinkRegularFileSync(filePath: string): boolean { } } +/** Remove the restart handoff file when it is a regular single-link file. */ export function clearGatewayRestartHandoffSync(env: NodeJS.ProcessEnv = process.env): void { unlinkRegularFileSync(resolveGatewayRestartHandoffPath(env)); } @@ -268,6 +272,8 @@ function readGatewayRestartHandoffRawSync(env: NodeJS.ProcessEnv): string | null const handoffPath = resolveGatewayRestartHandoffPath(env); try { const stat = fs.lstatSync(handoffPath); + // Handoff reads ignore symlinks, hardlinks, and oversized files because the + // state directory may be user-writable on some installs. if (!stat.isFile() || stat.nlink > 1 || stat.size > GATEWAY_RESTART_HANDOFF_MAX_BYTES) { return null; } @@ -277,6 +283,7 @@ function readGatewayRestartHandoffRawSync(env: NodeJS.ProcessEnv): string | null } } +/** Write the bounded supervisor restart handoff atomically. */ export function writeGatewayRestartHandoffSync(opts: { env?: NodeJS.ProcessEnv; pid?: number; @@ -350,6 +357,7 @@ export function writeGatewayRestartHandoffSync(opts: { } } +/** Read the current unexpired restart handoff without consuming it. */ export function readGatewayRestartHandoffSync( env: NodeJS.ProcessEnv = process.env, now = Date.now(), @@ -365,6 +373,7 @@ export function readGatewayRestartHandoffSync( return payload; } +/** Consume a handoff only when it belongs to the just-exited process. */ export function consumeGatewayRestartHandoffForExitedProcessSync(opts: { env?: NodeJS.ProcessEnv; exitedPid?: number; @@ -376,6 +385,8 @@ export function consumeGatewayRestartHandoffForExitedProcessSync(opts: { let raw: string | null = null; try { const stat = fs.lstatSync(handoffPath); + // Consume uses the same regular-file guard as reads, then clears the file + // even if parsing fails so stale handoffs do not repeat. if (!stat.isFile() || stat.nlink > 1 || stat.size > GATEWAY_RESTART_HANDOFF_MAX_BYTES) { return null; } diff --git a/src/infra/restart.types.ts b/src/infra/restart.types.ts index 7e82ca2bf140..32a716e0f5f6 100644 --- a/src/infra/restart.types.ts +++ b/src/infra/restart.types.ts @@ -1,3 +1,5 @@ +// RestartAttempt records the supervisor mechanism tried by platform-specific +// restart paths. export type RestartAttempt = { ok: boolean; method: "launchctl" | "systemd" | "schtasks" | "supervisor"; diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts index 41b0e0a29a3f..34c924424a11 100644 --- a/src/infra/retry-policy.ts +++ b/src/infra/retry-policy.ts @@ -2,8 +2,10 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { formatErrorMessage } from "./errors.js"; import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js"; +/** Runs an async operation with a policy-specific retry wrapper and optional log label. */ export type RetryRunner = (fn: () => Promise, label?: string) => Promise; +/** Default retry envelope for channel API operations that hit transient network edges. */ export const CHANNEL_API_RETRY_DEFAULTS = { attempts: 3, minDelayMs: 400, @@ -25,6 +27,8 @@ function resolveChannelApiShouldRetry(params: { if (params.strictShouldRetry) { return params.shouldRetry; } + // Channel APIs often wrap network failures differently by provider. Keep the + // fallback regex unless callers opt into strict idempotency control. return (err: unknown) => params.shouldRetry?.(err) || CHANNEL_API_RETRY_RE.test(formatErrorMessage(err)); } @@ -34,6 +38,8 @@ function getChannelApiRetryAfterMs(err: unknown): number | undefined { return undefined; } const candidate = + // Telegram-style clients may expose retry_after on the root error, response, + // or nested error object; keep all shapes aligned so rate-limit sleeps match. "parameters" in err && err.parameters && typeof err.parameters === "object" ? (err.parameters as { retry_after?: unknown }).retry_after : "response" in err && @@ -51,6 +57,7 @@ function getChannelApiRetryAfterMs(err: unknown): number | undefined { return typeof candidate === "number" && Number.isFinite(candidate) ? candidate * 1000 : undefined; } +/** Creates a generic rate-limit-aware retry runner from explicit retry policy pieces. */ export function createRateLimitRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; @@ -82,6 +89,7 @@ export function createRateLimitRetryRunner(params: { }); } +/** Creates the channel API retry runner used by outbound messaging integrations. */ export function createChannelApiRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; diff --git a/src/infra/retry.ts b/src/infra/retry.ts index 62bbfdd34baf..d7199cc5c5dd 100644 --- a/src/infra/retry.ts +++ b/src/infra/retry.ts @@ -3,6 +3,7 @@ import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "../shared/number-co import { sleep } from "../utils.js"; import { generateSecureFraction } from "./secure-random.js"; +/** Retry timing knobs shared by generic retry runners and channel retry policies. */ export type RetryConfig = { attempts?: number; minDelayMs?: number; @@ -10,6 +11,7 @@ export type RetryConfig = { jitter?: number; }; +/** Metadata emitted before a retry attempt sleeps and reruns the operation. */ export type RetryInfo = { attempt: number; maxAttempts: number; @@ -18,6 +20,7 @@ export type RetryInfo = { label?: string; }; +/** Retry execution options, including predicates, Retry-After hooks, and retry callbacks. */ export type RetryOptions = RetryConfig & { label?: string; shouldRetry?: (err: unknown, attempt: number) => boolean; @@ -54,6 +57,7 @@ function resolveRetryDelayMs(value: number): number { return resolveTimerTimeoutMs(value, 0, 0); } +/** Resolves retry config overrides into clamped timer-safe settings. */ export function resolveRetryConfig( defaults: Required = DEFAULT_RETRY_CONFIG, overrides?: RetryConfig, @@ -97,6 +101,7 @@ function applyJitter(delayMs: number, jitter: number, mode: JitterMode = "symmet return Math.max(0, mode === "positive" ? Math.ceil(raw) : Math.round(raw)); } +/** Runs an async operation until it succeeds, retry policy stops, or attempts are exhausted. */ export async function retryAsync( fn: () => Promise, attemptsOrOptions: number | RetryOptions = 3, diff --git a/src/infra/root-paths.ts b/src/infra/root-paths.ts index 95cec3bccb7b..be1b677a3cac 100644 --- a/src/infra/root-paths.ts +++ b/src/infra/root-paths.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Root path helpers resolve writable and existing paths without allowing +// traversal outside the configured root. export { ensureDirectoryWithinRoot, resolveExistingPathsWithinRoot, diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index 85408c8406d0..053c21527409 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -12,6 +12,7 @@ type Semver = { const MIN_NODE: Semver = { major: 22, minor: 19, patch: 0 }; const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i; +/** Runtime facts included in startup/runtime-version diagnostics. */ export type RuntimeDetails = { kind: RuntimeKind; version: string | null; @@ -21,6 +22,7 @@ export type RuntimeDetails = { const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/; +/** Parses the first major/minor/patch triple from a runtime or package version label. */ export function parseSemver(version: string | null): Semver | null { if (!version) { return null; @@ -37,6 +39,7 @@ export function parseSemver(version: string | null): Semver | null { }; } +/** Compares parsed semver triples against an inclusive minimum version. */ export function isAtLeast(version: Semver | null, minimum: Semver): boolean { if (!version) { return false; @@ -50,6 +53,7 @@ export function isAtLeast(version: Semver | null, minimum: Semver): boolean { return version.patch >= minimum.patch; } +/** Reads current process runtime metadata for startup support checks. */ export function detectRuntime(): RuntimeDetails { const kind: RuntimeKind = process.versions?.node ? "node" : "unknown"; const version = process.versions?.node ?? null; @@ -62,6 +66,7 @@ export function detectRuntime(): RuntimeDetails { }; } +/** Returns whether a detected runtime meets OpenClaw's minimum runtime contract. */ export function runtimeSatisfies(details: RuntimeDetails): boolean { const parsed = parseSemver(details.version); if (details.kind === "node") { @@ -70,10 +75,12 @@ export function runtimeSatisfies(details: RuntimeDetails): boolean { return false; } +/** Checks a Node version label against OpenClaw's current minimum Node version. */ export function isSupportedNodeVersion(version: string | null): boolean { return isAtLeast(parseSemver(version), MIN_NODE); } +/** Parses simple package `engines.node` ranges of the form `>=x.y.z`. */ export function parseMinimumNodeEngine(engine: string | null): Semver | null { if (!engine) { return null; @@ -85,6 +92,7 @@ export function parseMinimumNodeEngine(engine: string | null): Semver | null { return parseSemver(match[1] ?? null); } +/** Returns whether a Node version satisfies a simple minimum engine range, or null if unsupported. */ export function nodeVersionSatisfiesEngine( version: string | null, engine: string | null, @@ -96,6 +104,7 @@ export function nodeVersionSatisfiesEngine( return isAtLeast(parseSemver(version), minimum); } +/** Exits through the provided runtime when the current Node runtime is unsupported. */ export function assertSupportedRuntime( runtime: RuntimeEnv = defaultRuntime, details: RuntimeDetails = detectRuntime(), diff --git a/src/infra/runtime-status.ts b/src/infra/runtime-status.ts index 79d88053e9fb..635f1c8301da 100644 --- a/src/infra/runtime-status.ts +++ b/src/infra/runtime-status.ts @@ -7,6 +7,7 @@ type RuntimeStatusFormatInput = { details?: string[]; }; +/** Formats runtime health/status text with optional pid, state, and extra diagnostic details. */ export function formatRuntimeStatusWithDetails({ status, pid, @@ -21,6 +22,8 @@ export function formatRuntimeStatusWithDetails({ const normalizedState = state?.trim(); if ( normalizedState && + // State often mirrors status from different process managers; suppressing + // case-only duplicates keeps restart/status output readable. normalizeLowercaseStringOrEmpty(normalizedState) !== normalizeLowercaseStringOrEmpty(runtimeStatus) ) { diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts index 573698051f38..58118ce6542f 100644 --- a/src/infra/safe-package-install.ts +++ b/src/infra/safe-package-install.ts @@ -18,6 +18,11 @@ type SafeNpmInstallArgsOptions = { omitPeer?: boolean; }; +/** + * Creates a project-local npm install environment for untrusted package dirs. + * It disables lifecycle scripts, global/workspace leakage, prompts, and noisy + * npm features while preserving caller-supplied process env values. + */ export function createSafeNpmInstallEnv( env: NodeJS.ProcessEnv, options: SafeNpmInstallEnvOptions = {}, @@ -45,6 +50,10 @@ export function createSafeNpmInstallEnv( return nextEnv; } +/** + * Builds npm install argv that mirrors the safe environment defaults. + * Callers opt into dependency omission, legacy peer resolution, and quiet flags. + */ export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {}): string[] { return [ "install", diff --git a/src/infra/scp-host.ts b/src/infra/scp-host.ts index c3df04032662..026d98b695e2 100644 --- a/src/infra/scp-host.ts +++ b/src/infra/scp-host.ts @@ -1,5 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +// SCP host/path normalization rejects shell metacharacters before values are +// embedded in remote-copy commands. const SSH_TOKEN = /^[A-Za-z0-9._-]+$/; const BRACKETED_IPV6 = /^\[[0-9A-Fa-f:.%]+\]$/; const WHITESPACE = /\s/; @@ -15,6 +17,7 @@ function hasControlOrWhitespace(value: string): boolean { return false; } +/** Normalize an optional `[user@]host` SCP target or reject unsafe tokens. */ export function normalizeScpRemoteHost(value: string | null | undefined): string | undefined { const trimmed = normalizeOptionalString(value); if (!trimmed) { @@ -57,10 +60,12 @@ export function normalizeScpRemoteHost(value: string | null | undefined): string return user ? `${user}@${host}` : host; } +/** Return true when a value is safe for the SCP host position. */ export function isSafeScpRemoteHost(value: string | null | undefined): boolean { return normalizeScpRemoteHost(value) !== undefined; } +/** Normalize an absolute remote path that is safe for SCP command construction. */ export function normalizeScpRemotePath(value: string | null | undefined): string | undefined { const trimmed = normalizeOptionalString(value); if (!trimmed || !trimmed.startsWith("/")) { @@ -77,6 +82,7 @@ export function normalizeScpRemotePath(value: string | null | undefined): string return trimmed; } +/** Return true when a value is safe for the SCP remote path position. */ export function isSafeScpRemotePath(value: string | null | undefined): boolean { return normalizeScpRemotePath(value) !== undefined; } diff --git a/src/infra/scripts-modules.d.ts b/src/infra/scripts-modules.d.ts index fdd12dd1b315..6a370db57b47 100644 --- a/src/infra/scripts-modules.d.ts +++ b/src/infra/scripts-modules.d.ts @@ -1,3 +1,5 @@ +// Type declarations for repo scripts imported by tests without publishing them +// as normal TypeScript modules. declare module "../../scripts/watch-node.mjs" { export function resolveWatchLockPath(cwd: string, args?: string[]): string; export function runWatchMain(params?: { diff --git a/src/infra/secure-random.ts b/src/infra/secure-random.ts index bbca0a95473d..74f167e46f84 100644 --- a/src/infra/secure-random.ts +++ b/src/infra/secure-random.ts @@ -1,13 +1,16 @@ import { randomBytes, randomInt, randomUUID } from "node:crypto"; +/** Generates a cryptographically secure UUID for runtime ids and cache keys. */ export function generateSecureUuid(): string { return randomUUID(); } +/** Generates a URL-safe cryptographic token from the requested byte count. */ export function generateSecureToken(bytes = 16): string { return randomBytes(bytes).toString("base64url"); } +/** Generates a hex-encoded cryptographic token from the requested byte count. */ export function generateSecureHex(bytes = 16): string { return randomBytes(bytes).toString("hex"); } @@ -17,7 +20,9 @@ export function generateSecureFraction(): number { return randomBytes(4).readUInt32BE(0) / 0x1_0000_0000; } +/** Generates a cryptographically secure integer in `[0, maxExclusive)`. */ export function generateSecureInt(maxExclusive: number): number; +/** Generates a cryptographically secure integer in `[minInclusive, maxExclusive)`. */ export function generateSecureInt(minInclusive: number, maxExclusive: number): number; export function generateSecureInt(a: number, b?: number): number { return typeof b === "number" ? randomInt(a, b) : randomInt(a); diff --git a/src/infra/semver-compare.ts b/src/infra/semver-compare.ts index 8404a8e80e10..4313dde08dcc 100644 --- a/src/infra/semver-compare.ts +++ b/src/infra/semver-compare.ts @@ -5,6 +5,9 @@ type ComparableSemver = { prerelease: string[] | null; }; +/** + * Converts legacy OpenClaw `1.2.3.beta.N` tags into semver-compatible `1.2.3-beta.N`. + */ export function normalizeLegacyDotBetaVersion(version: string): string { const trimmed = version.trim(); const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); @@ -16,6 +19,9 @@ export function normalizeLegacyDotBetaVersion(version: string): string { return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; } +/** + * Parses an exact semver-like version into fields used by update and plugin version ordering. + */ export function parseComparableSemver( version: string | null | undefined, options?: { normalizeLegacyDotBeta?: boolean }, @@ -44,10 +50,14 @@ export function parseComparableSemver( }; } +/** + * Compares semver prerelease identifiers using numeric-before-string semver precedence rules. + */ export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number { if (!a?.length && !b?.length) { return 0; } + // A stable release has higher precedence than any prerelease for the same major/minor/patch. if (!a?.length) { return 1; } @@ -91,6 +101,9 @@ export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | n return 0; } +/** + * Compares parsed semver values, returning null when either side could not be parsed. + */ export function compareComparableSemver( a: ComparableSemver | null, b: ComparableSemver | null, diff --git a/src/infra/session-delivery-queue-recovery.ts b/src/infra/session-delivery-queue-recovery.ts index b19e24b944e7..5951f0cb53e0 100644 --- a/src/infra/session-delivery-queue-recovery.ts +++ b/src/infra/session-delivery-queue-recovery.ts @@ -13,6 +13,8 @@ import { type QueuedSessionDelivery, } from "./session-delivery-queue-storage.js"; +// Session delivery recovery replays persisted messages after crashes while +// bounding retry count, backoff, and concurrent drain work. type SessionDeliveryRecoverySummary = { recovered: number; failed: number; @@ -136,6 +138,7 @@ async function drainQueuedEntry(opts: { } } +/** Drain matching queued session deliveries with retry/backoff protection. */ export async function drainPendingSessionDeliveries(opts: { drainKey: string; logLabel: string; @@ -211,6 +214,7 @@ export async function drainPendingSessionDeliveries(opts: { } } +/** Replay pending session deliveries until the recovery budget is exhausted. */ export async function recoverPendingSessionDeliveries(opts: { deliver: DeliverSessionDeliveryFn; log: SessionDeliveryRecoveryLogger; diff --git a/src/infra/session-delivery-queue-storage.ts b/src/infra/session-delivery-queue-storage.ts index 258f22206d15..c5ac90f06bcc 100644 --- a/src/infra/session-delivery-queue-storage.ts +++ b/src/infra/session-delivery-queue-storage.ts @@ -11,6 +11,8 @@ import { } from "./delivery-queue-sqlite.js"; import { generateSecureUuid } from "./secure-random.js"; +// Session delivery queue persists session-scoped messages until channel +// delivery acknowledges them or recovery exhausts retry policy. const QUEUE_NAME = "session"; type SessionDeliveryContext = { @@ -33,6 +35,7 @@ export type SessionDeliveryRoute = { chatType: ChatType; }; +/** Payload variants that can be replayed by session delivery recovery. */ export type QueuedSessionDeliveryPayload = | ({ kind: "systemEvent"; @@ -78,6 +81,7 @@ function queuedSessionDeliveryMetadata(entry: QueuedSessionDelivery): DeliveryQu }; } +/** Enqueue a session delivery and return its durable id. */ export async function enqueueSessionDelivery( params: QueuedSessionDeliveryPayload, stateDir?: string, @@ -103,10 +107,12 @@ export async function enqueueSessionDelivery( return id; } +/** Acknowledge a successfully delivered session entry. */ export async function ackSessionDelivery(id: string, stateDir?: string): Promise { deleteDeliveryQueueEntry(QUEUE_NAME, id, stateDir); } +/** Record a failed delivery attempt and increment retry metadata. */ export async function failSessionDelivery( id: string, error: string, @@ -123,6 +129,7 @@ export async function failSessionDelivery( }); } +/** Load one pending session delivery by durable id. */ export async function loadPendingSessionDelivery( id: string, stateDir?: string, @@ -130,12 +137,14 @@ export async function loadPendingSessionDelivery( return loadDeliveryQueueEntry(QUEUE_NAME, id, stateDir) as QueuedSessionDelivery | null; } +/** Load all pending session deliveries in retry order. */ export async function loadPendingSessionDeliveries( stateDir?: string, ): Promise { return loadDeliveryQueueEntries(QUEUE_NAME, stateDir) as QueuedSessionDelivery[]; } +/** Move an exhausted session delivery out of the pending queue. */ export async function moveSessionDeliveryToFailed(id: string, stateDir?: string): Promise { moveDeliveryQueueEntryToFailed(QUEUE_NAME, id, stateDir); } diff --git a/src/infra/session-delivery-queue.ts b/src/infra/session-delivery-queue.ts index dac1d98d87a4..64d80e3b8303 100644 --- a/src/infra/session-delivery-queue.ts +++ b/src/infra/session-delivery-queue.ts @@ -1,3 +1,5 @@ +// Public session delivery queue facade: storage and recovery live in split +// modules, callers import the stable aggregate API from here. export { ackSessionDelivery, enqueueSessionDelivery, diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index 54d6fdd08109..de68ce44fc98 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -7,6 +7,8 @@ import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/m import { buildOutboundSessionContext } from "./outbound/session-context.js"; import { enqueueSystemEvent } from "./system-events.js"; +// Session maintenance warnings notify an active session before warn-only +// cleanup would prune it, with per-session dedupe and system-event fallback. type WarningParams = { cfg: OpenClawConfig; sessionKey: string; @@ -100,6 +102,7 @@ function resolveWarningDeliveryTarget(entry: SessionEntry): { }; } +/** Deliver or enqueue a warn-only session maintenance notification. */ export async function deliverSessionMaintenanceWarning(params: WarningParams): Promise { if (!shouldSendWarning()) { return; @@ -109,6 +112,8 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P if (warnedContexts.get(params.sessionKey) === contextKey) { return; } + // Dedupe by effective warning context so repeated maintenance scans do not + // spam the same session, but changed limits still produce a fresh warning. warnedContexts.set(params.sessionKey, contextKey); const text = buildWarningText(params.warning); diff --git a/src/infra/shell-inline-command.ts b/src/infra/shell-inline-command.ts index 701bfe137726..8ed6ef80188d 100644 --- a/src/infra/shell-inline-command.ts +++ b/src/infra/shell-inline-command.ts @@ -1,5 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Shell inline-command parsing recognizes POSIX, cmd, and PowerShell command +// flags so approval surfaces can distinguish wrapper argv from executed text. export const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); function expandPowerShellSwitchPrefixForms(match: string, smallestMatch: string): string[] { @@ -135,6 +137,7 @@ function isPosixShortOption(token: string, option: string): boolean { return hasOption; } +/** Return how many argv tokens a POSIX shell option consumes while scanning. */ export function advancePosixInlineOptionScan(token: string): number { const combinedValueCount = combinedSeparateValueOptionCount(token); if (combinedValueCount > 0) { @@ -150,6 +153,7 @@ function isPowerShellOptionToken(token: string): boolean { return token.startsWith("-") || /^\/[A-Za-z][A-Za-z0-9]*$/.test(token); } +/** Find the inline command payload for a shell wrapper argv. */ export function resolveInlineCommandMatch( argv: string[], flags: ReadonlySet, @@ -211,6 +215,7 @@ export function resolveInlineCommandMatch( return { command: null, valueTokenIndex: null }; } +/** Find the PowerShell inline command payload and value token index. */ export function resolvePowerShellInlineCommandMatch(argv: string[]): { command: string | null; valueTokenIndex: number | null; @@ -223,14 +228,17 @@ export function resolvePowerShellInlineCommandMatch(argv: string[]): { }); } +/** Return true when a PowerShell flag consumes the rest of argv as command text. */ export function isPowerShellInlineRestCommandFlag(token: string): boolean { return POWERSHELL_INLINE_REST_COMMAND_FLAGS.has(normalizeLowercaseStringOrEmpty(token)); } +/** Return true when a PowerShell flag treats the next token as script file text. */ export function isPowerShellInlineFileCommandFlag(token: string): boolean { return POWERSHELL_INLINE_FILE_FLAGS.has(normalizeLowercaseStringOrEmpty(token)); } +/** Detect POSIX interactive startup before an inline command flag. */ export function hasPosixInteractiveStartupBeforeInlineCommand( argv: string[], flags: ReadonlySet, @@ -259,6 +267,7 @@ export function hasPosixInteractiveStartupBeforeInlineCommand( return false; } +/** Detect POSIX login startup before an inline command flag. */ export function hasPosixLoginStartupBeforeInlineCommand( argv: string[], flags: ReadonlySet, @@ -287,6 +296,7 @@ export function hasPosixLoginStartupBeforeInlineCommand( return false; } +/** Detect fish init-command options that run before the inline command. */ export function hasFishInitCommandOption(argv: string[]): boolean { for (let i = 1; i < argv.length; i += 1) { const token = argv[i]?.trim(); @@ -311,6 +321,7 @@ export function hasFishInitCommandOption(argv: string[]): boolean { return false; } +/** Detect fish attached `-cCOMMAND` forms that should not be rebound. */ export function hasFishAttachedCommandOption(argv: string[]): boolean { for (let i = 1; i < argv.length; i += 1) { const token = argv[i]?.trim(); diff --git a/src/infra/shell-wrapper-resolution.ts b/src/infra/shell-wrapper-resolution.ts index e252d3ec52ce..4d89938e05aa 100644 --- a/src/infra/shell-wrapper-resolution.ts +++ b/src/infra/shell-wrapper-resolution.ts @@ -15,6 +15,8 @@ import { resolvePowerShellInlineCommandMatch, } from "./shell-inline-command.js"; +// Shell wrapper resolution unwraps dispatch wrappers and shell multiplexers so +// approval policy can reason about the actual inline command being run. const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; @@ -136,6 +138,7 @@ function isWithinDispatchClassificationDepth(depth: number): boolean { return depth <= MAX_DISPATCH_WRAPPER_DEPTH; } +/** Return true when an executable token names a supported shell wrapper. */ export function isShellWrapperExecutable(token: string): boolean { return SHELL_WRAPPER_CANONICAL.has(normalizeExecutableToken(token)); } @@ -145,6 +148,7 @@ function isShellWrapperInvocationInternal(argv: string[], depth: number): boolea return candidate ? isShellWrapperExecutable(candidate.token0) : false; } +/** Return true when argv resolves to a shell wrapper invocation. */ export function isShellWrapperInvocation(argv: string[]): boolean { return isShellWrapperInvocationInternal(argv, 0); } @@ -168,6 +172,7 @@ type ShellMultiplexerUnwrapResult = | { kind: "blocked"; wrapper: string } | { kind: "unwrapped"; wrapper: string; argv: string[] }; +/** Unwrap busybox/toybox shell applets or fail closed for ambiguous applets. */ export function unwrapKnownShellMultiplexerInvocation( argv: string[], ): ShellMultiplexerUnwrapResult { @@ -309,6 +314,7 @@ function hasEnvManipulationBeforeShellWrapperInternal( return candidate.state; } +/** Return true when dispatch wrappers set env before the shell wrapper. */ export function hasEnvManipulationBeforeShellWrapper(argv: string[]): boolean { return hasEnvManipulationBeforeShellWrapperInternal(argv, 0, false); } @@ -366,14 +372,17 @@ function extractShellWrapperCommandInternal( }; } +/** Resolve the argv segment that should be transported for shell execution. */ export function resolveShellWrapperTransportArgv(argv: string[]): string[] | null { return resolveShellWrapperSpecAndArgvInternal(argv, 0)?.argv ?? null; } +/** Extract the raw inline command payload from a shell wrapper argv. */ export function extractShellWrapperInlineCommand(argv: string[]): string | null { return resolveShellWrapperSpecAndArgvInternal(argv, 0)?.payload ?? null; } +/** Extract a command payload only when it is safe to bind to raw command text. */ export function extractBindableShellWrapperInlineCommand( argv: string[], rawCommand?: string | null, @@ -381,6 +390,7 @@ export function extractBindableShellWrapperInlineCommand( return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0).command; } +/** Classify shell wrapper argv and return the approval-display command when safe. */ export function extractShellWrapperCommand( argv: string[], rawCommand?: string | null, @@ -388,6 +398,7 @@ export function extractShellWrapperCommand( return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); } +/** Return true when shell wrapper startup behavior blocks command rebinding. */ export function isBlockedShellWrapperCommand(argv: string[], rawCommand?: string | null): boolean { const extracted = extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); return extracted.isWrapper && extracted.command === null; diff --git a/src/infra/sibling-temp-file.ts b/src/infra/sibling-temp-file.ts index a0845107221b..fef3d9eb0301 100644 --- a/src/infra/sibling-temp-file.ts +++ b/src/infra/sibling-temp-file.ts @@ -1,4 +1,7 @@ import "./fs-safe-defaults.js"; + +// Atomic sibling temp writes preserve target-directory permissions and avoid +// cross-device rename behavior. export { writeSiblingTempFile, type WriteSiblingTempFileOptions, diff --git a/src/infra/sqlite-pragma.test-support.ts b/src/infra/sqlite-pragma.test-support.ts index 1188b17f407f..23df5fcd0508 100644 --- a/src/infra/sqlite-pragma.test-support.ts +++ b/src/infra/sqlite-pragma.test-support.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; +// SQLite pragma test helpers normalize node:sqlite bigint/number outputs. export type SqliteNumberPragma = | "busy_timeout" | "foreign_keys" @@ -7,6 +8,7 @@ export type SqliteNumberPragma = | "user_version" | "wal_autocheckpoint"; +/** Read a numeric SQLite pragma from a DatabaseSync instance. */ export function readSqliteNumberPragma(db: DatabaseSync, pragma: SqliteNumberPragma): number { const row = db.prepare(`PRAGMA ${pragma}`).get() as Record | undefined; const value = row?.[pragma] ?? row?.timeout; diff --git a/src/infra/sqlite-wal.ts b/src/infra/sqlite-wal.ts index 15db016da02b..f4e37f8c1e0a 100644 --- a/src/infra/sqlite-wal.ts +++ b/src/infra/sqlite-wal.ts @@ -1,6 +1,8 @@ import type { DatabaseSync } from "node:sqlite"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; +// WAL maintenance configures SQLite write-ahead logging and schedules bounded +// checkpoints so state databases do not accumulate unbounded WAL files. export const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000; export const DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS = 30 * 60 * 1000; @@ -15,6 +17,7 @@ export type SqliteWalMaintenance = { close: () => boolean; }; +/** Options controlling WAL autocheckpoint and periodic checkpoint behavior. */ export type SqliteWalMaintenanceOptions = { autoCheckpointPages?: number; checkpointIntervalMs?: number; @@ -31,6 +34,7 @@ function normalizeNonNegativeInteger(value: number, label: string): number { return value; } +/** Configure WAL pragmas and return a handle for checkpoint/close maintenance. */ export function configureSqliteWalMaintenance( db: DatabaseSync, options: SqliteWalMaintenanceOptions = {}, diff --git a/src/infra/state-migrations.fs.ts b/src/infra/state-migrations.fs.ts index b903e3f51dd5..90f951ce0988 100644 --- a/src/infra/state-migrations.fs.ts +++ b/src/infra/state-migrations.fs.ts @@ -1,11 +1,13 @@ import fs from "node:fs"; import JSON5 from "json5"; +/** Minimal session-store entry shape needed by state migration ordering and repair logic. */ export type SessionEntryLike = { sessionId?: string; updatedAt?: number; } & Record; +/** Reads directory entries or returns an empty list when the directory is missing/unreadable. */ export function safeReadDir(dir: string): fs.Dirent[] { try { return fs.readdirSync(dir, { withFileTypes: true }); @@ -14,6 +16,7 @@ export function safeReadDir(dir: string): fs.Dirent[] { } } +/** Returns whether a path exists and resolves to a directory. */ export function existsDir(dir: string): boolean { try { return fs.existsSync(dir) && fs.statSync(dir).isDirectory(); @@ -22,10 +25,12 @@ export function existsDir(dir: string): boolean { } } +/** Creates a directory tree for migration targets. */ export function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } +/** Returns whether a path exists and resolves to a regular file. */ export function fileExists(p: string): boolean { try { return fs.existsSync(p) && fs.statSync(p).isFile(); @@ -34,6 +39,7 @@ export function fileExists(p: string): boolean { } } +/** Matches legacy WhatsApp auth shard names that should move into the channel auth dir. */ export function isLegacyWhatsAppAuthFile(name: string): boolean { if (name === "creds.json" || name === "creds.json.bak") { return true; @@ -44,6 +50,7 @@ export function isLegacyWhatsAppAuthFile(name: string): boolean { return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); } +/** Reads a session store from disk, accepting JSON first and JSON5 as legacy/operator input. */ export function readSessionStoreJson5(storePath: string): { store: Record; ok: boolean; @@ -57,6 +64,7 @@ export function readSessionStoreJson5(storePath: string): { return { store: {}, ok: false }; } +/** Parses session-store text, preferring strict JSON before JSON5 compatibility. */ export function parseSessionStoreJson5(raw: string): { store: Record; ok: boolean; diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index c83ab42ea4bd..fb03eb45e699 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -6,6 +6,7 @@ const SUPERVISOR_HINTS = { schtasks: ["OPENCLAW_WINDOWS_TASK_NAME"], } as const; +/** Environment keys that imply the gateway process is supervised by an external respawner. */ export const SUPERVISOR_HINT_ENV_VARS = [ "LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", @@ -17,6 +18,7 @@ export const SUPERVISOR_HINT_ENV_VARS = [ "OPENCLAW_SERVICE_KIND", ] as const; +/** Supported supervisor families that can respawn the gateway after update/restart handoff. */ export type RespawnSupervisor = "launchd" | "systemd" | "schtasks"; function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { @@ -36,6 +38,7 @@ function isCurrentGatewayLaunchdJob(env: NodeJS.ProcessEnv): boolean { return env.XPC_SERVICE_NAME?.trim() === GATEWAY_LAUNCH_AGENT_LABEL; } +/** Detects the current platform supervisor from process environment hints. */ export function detectRespawnSupervisor( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, diff --git a/src/infra/system-message.ts b/src/infra/system-message.ts index 0982880a876e..67b3db248480 100644 --- a/src/infra/system-message.ts +++ b/src/infra/system-message.ts @@ -1,13 +1,17 @@ +// System messages use a stable prefix so generated system events can be +// identified without extra metadata in plain chat transcripts. export const SYSTEM_MARK = "⚙️"; function normalizeSystemText(value: string): string { return value.trim(); } +/** Return true when text already carries the system-message prefix. */ export function hasSystemMark(text: string): boolean { return normalizeSystemText(text).startsWith(SYSTEM_MARK); } +/** Prefix non-empty text as a system message without double-prefixing. */ export function prefixSystemMessage(text: string): string { const normalized = normalizeSystemText(text); if (!normalized) { diff --git a/src/infra/system-run-approval-context.ts b/src/infra/system-run-approval-context.ts index e4671341155a..5ec54c027a0d 100644 --- a/src/infra/system-run-approval-context.ts +++ b/src/infra/system-run-approval-context.ts @@ -8,6 +8,8 @@ import { normalizeSystemRunApprovalPlan } from "./system-run-approval-binding.js import { formatExecCommand, resolveSystemRunCommandRequest } from "./system-run-command.js"; import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js"; +// System-run approval context normalizes prepared node-run payloads and legacy +// command fields before they enter exec approval policy. export type PreparedRunExecPolicy = { security: ExecSecurity; ask: ExecAsk; @@ -151,6 +153,7 @@ export function parsePreparedSystemRunPayload(payload: unknown): PreparedRunPayl }; } +/** Build the approval request context from tool payload fields. */ export function resolveSystemRunApprovalRequestContext(params: { host?: unknown; command?: unknown; @@ -183,6 +186,7 @@ export function resolveSystemRunApprovalRequestContext(params: { }; } +/** Build the runtime approval context from already-normalized command inputs. */ export function resolveSystemRunApprovalRuntimeContext(params: { plan?: unknown; command?: unknown; diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index 71e4cb1cb0b6..7275bab7d2a4 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -12,6 +12,8 @@ import { resolvePowerShellInlineCommandMatch, } from "./shell-inline-command.js"; +// System-run command helpers keep argv authoritative while still exposing a +// human-readable shell preview when the wrapper shape is unambiguous. type SystemRunCommandValidation = | { ok: true; @@ -39,6 +41,7 @@ type ResolvedSystemRunCommand = details?: Record; }; +/** Format argv with minimal shell-style quoting for display and consistency checks. */ export function formatExecCommand(argv: string[]): string { return argv .map((arg) => { @@ -54,6 +57,7 @@ export function formatExecCommand(argv: string[]): string { .join(" "); } +/** Extract the inline shell payload carried by a shell wrapper argv. */ export function extractShellCommandFromArgv(argv: string[]): string | null { return extractShellWrapperCommand(argv).command; } @@ -150,6 +154,8 @@ export function validateSystemRunCommandConsistency(params: { const display = buildSystemRunCommandDisplay(params.argv, raw); if (raw) { + // rawCommand is display-only metadata. Reject mismatches so approvals cannot + // show one command while executing a different argv. const matchesCanonicalArgv = raw === display.commandText; const matchesLegacyShellText = params.allowLegacyShellText === true && @@ -177,6 +183,7 @@ export function validateSystemRunCommandConsistency(params: { }; } +/** Resolve system-run command fields with strict rawCommand matching. */ export function resolveSystemRunCommand(params: { command?: unknown; rawCommand?: unknown; @@ -184,6 +191,7 @@ export function resolveSystemRunCommand(params: { return resolveSystemRunCommandWithMode(params, false); } +/** Resolve request command fields while accepting the legacy shell-preview text. */ export function resolveSystemRunCommandRequest(params: { command?: unknown; rawCommand?: unknown; diff --git a/src/infra/system-run-normalize.ts b/src/infra/system-run-normalize.ts index 6b053347d77b..eca4de31b10b 100644 --- a/src/infra/system-run-normalize.ts +++ b/src/infra/system-run-normalize.ts @@ -1,10 +1,12 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +/** Normalizes unknown system-run metadata to a trimmed non-empty string. */ export function normalizeNonEmptyString(value: unknown): string | null { return typeof value === "string" ? (normalizeOptionalString(value) ?? null) : null; } +/** Coerces array entries to allow-list strings while rejecting non-array inputs. */ export function normalizeStringArray(value: unknown): string[] { return Array.isArray(value) ? mapAllowFromEntries(value) : []; } diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index fce4f7dfd2b7..a3684bd1c2a9 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -2,6 +2,7 @@ import { isIpInCidr } from "@openclaw/net-policy/ip"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { listExternalInterfaceAddresses, readNetworkInterfaces } from "./network-interfaces.js"; +/** Tailnet addresses discovered on external local interfaces. */ type TailnetAddresses = { ipv4: string[]; ipv6: string[]; @@ -10,6 +11,7 @@ type TailnetAddresses = { const TAILNET_IPV4_CIDR = "100.64.0.0/10"; const TAILNET_IPV6_CIDR = "fd7a:115c:a1e0::/48"; +/** Returns true when an address is inside Tailscale's CGNAT IPv4 range. */ export function isTailnetIPv4(address: string): boolean { // Tailscale IPv4 range: 100.64.0.0/10 // https://tailscale.com/kb/1015/100.x-addresses @@ -22,6 +24,7 @@ function isTailnetIPv6(address: string): boolean { return isIpInCidr(address, TAILNET_IPV6_CIDR); } +/** Lists unique Tailscale IPv4/IPv6 addresses from local external interfaces. */ export function listTailnetAddresses(): TailnetAddresses { const ipv4: string[] = []; const ipv6: string[] = []; @@ -38,10 +41,12 @@ export function listTailnetAddresses(): TailnetAddresses { return { ipv4: uniqueStrings(ipv4), ipv6: uniqueStrings(ipv6) }; } +/** Returns the first discovered Tailscale IPv4 address, if any. */ export function pickPrimaryTailnetIPv4(): string | undefined { return listTailnetAddresses().ipv4[0]; } +/** Returns the first discovered Tailscale IPv6 address, if any. */ export function pickPrimaryTailnetIPv6(): string | undefined { return listTailnetAddresses().ipv6[0]; } diff --git a/src/infra/tcp-port.ts b/src/infra/tcp-port.ts index d8a4d4902634..0c7df1963762 100644 --- a/src/infra/tcp-port.ts +++ b/src/infra/tcp-port.ts @@ -1,7 +1,9 @@ import { parseStrictPositiveInteger } from "./parse-finite-number.js"; +// TCP port parsing is strict because config and CLI inputs both use this helper. export const MAX_TCP_PORT = 65_535; +/** Parse a positive TCP port or return null for absent/invalid input. */ export function parseTcpPort(raw: unknown): number | null { if (raw === undefined || raw === null) { return null; diff --git a/src/infra/temp-download.ts b/src/infra/temp-download.ts index 71f371232e24..ad1f31aa8503 100644 --- a/src/infra/temp-download.ts +++ b/src/infra/temp-download.ts @@ -9,6 +9,8 @@ const logger = createSubsystemLogger("infra:temp-download"); export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; +// Download targets expose both a default path and a name-safe file builder so +// callers can keep all transient files inside the same workspace. type TempDownloadTarget = { dir: string; path: string; @@ -42,6 +44,7 @@ export function sanitizeTempFileName(fileName: string): string { return normalized || "download.bin"; } +/** Build a stable temp path shape while keeping caller-controlled text filename-safe. */ export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -102,6 +105,7 @@ export async function createTempDownloadTarget(params: { }; } +/** Run with a private temp download path and always attempt workspace cleanup. */ export async function withTempDownloadPath( params: { prefix: string; diff --git a/src/infra/tls/fingerprint.ts b/src/infra/tls/fingerprint.ts index 85cdd14b1c86..ca13ea0f61d5 100644 --- a/src/infra/tls/fingerprint.ts +++ b/src/infra/tls/fingerprint.ts @@ -1,5 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Gateway TLS fingerprints are stored as lowercase hex without labels or +// separators so config comparisons stay format-insensitive. export function normalizeFingerprint(input: string): string { const trimmed = input.trim(); const withoutPrefix = trimmed.replace(/^sha-?256\s*:?\s*/i, ""); diff --git a/src/infra/tls/gateway.ts b/src/infra/tls/gateway.ts index bb04dd5ab3ca..bbcda126c259 100644 --- a/src/infra/tls/gateway.ts +++ b/src/infra/tls/gateway.ts @@ -12,6 +12,8 @@ import { normalizeFingerprint } from "./fingerprint.js"; const execFileAsync = promisify(execFile); +// Gateway TLS runtime carries loaded cert material plus the normalized SHA-256 +// fingerprint advertised to clients. export type GatewayTlsRuntime = { enabled: boolean; required: boolean; @@ -40,6 +42,8 @@ async function generateSelfSignedCert(params: { "openssl not found in trusted system directories. Install it in an OS-managed location.", ); } + // Use execFile with a trusted system binary; certificate paths are arguments, + // not shell text. await execFileAsync(opensslBin, [ "req", "-x509", @@ -63,6 +67,7 @@ async function generateSelfSignedCert(params: { ); } +/** Load or generate gateway TLS material and return server-ready TLS options. */ export async function loadGatewayTlsRuntime( cfg: GatewayTlsConfig | undefined, log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index 8f6b3b93f89f..dc8ea55ec044 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { tmpdir as getOsTmpDir } from "node:os"; import path from "node:path"; +/** Preferred shared OpenClaw temp root on POSIX systems when ownership and permissions are safe. */ export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; type MaybeNodeError = { code?: string }; @@ -13,6 +14,7 @@ type SecureDirStat = { uid?: number; }; +/** Injectable filesystem/platform hooks for resolving the preferred temp root in tests. */ export type ResolvePreferredOpenClawTmpDirOptions = { accessSync?: (path: string, mode?: number) => void; chmodSync?: (path: string, mode: number) => void; @@ -33,6 +35,7 @@ function isNodeErrorWithCode(err: unknown, code: string): err is MaybeNodeError ); } +/** Resolves a safe OpenClaw temp root, falling back to user-scoped os.tmpdir paths when needed. */ export function resolvePreferredOpenClawTmpDir( options: ResolvePreferredOpenClawTmpDirOptions = {}, ): string { @@ -131,6 +134,8 @@ export function resolvePreferredOpenClawTmpDir( if (tryRepairWritableBits(fallbackPath)) { return fallbackPath; } + // Never continue with a symlinked, wrong-owner, or world-writable temp root; + // callers create executable/media artifacts under this path. throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`); } try { diff --git a/src/infra/transport-ready.ts b/src/infra/transport-ready.ts index f09de7188bd8..bea718fbbcd9 100644 --- a/src/infra/transport-ready.ts +++ b/src/infra/transport-ready.ts @@ -3,11 +3,13 @@ import { danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { sleepWithAbort } from "./backoff.js"; +/** Result returned by one transport readiness probe attempt. */ export type TransportReadyResult = { ok: boolean; error?: string | null; }; +/** Parameters for polling a channel transport until it can accept runtime work. */ export type WaitForTransportReadyParams = { label: string; timeoutMs: number; @@ -19,6 +21,12 @@ export type WaitForTransportReadyParams = { check: () => Promise; }; +/** + * Polls a channel transport readiness probe until it succeeds, times out, or aborts. + * + * Used by channel plugins that start external daemons or subscribe to local transports before + * processing inbound events, with bounded retry logging through the caller's runtime sink. + */ export async function waitForTransportReady(params: WaitForTransportReadyParams): Promise { const started = Date.now(); const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 0, 0); @@ -52,6 +60,8 @@ export async function waitForTransportReady(params: WaitForTransportReadyParams) } try { + // Abort is cooperative: `sleepWithAbort` may throw on abort, but callers treat abort as + // a quiet stop rather than a transport failure. await sleepWithAbort(pollIntervalMs, params.abortSignal); } catch (err) { if (params.abortSignal?.aborted) { diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index d71b82002a54..afc3f6f54b38 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -1,7 +1,9 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { parseComparableSemver } from "./semver-compare.js"; +/** Release stream used to choose registry tags and update policy defaults. */ export type UpdateChannel = "stable" | "beta" | "dev"; +/** Evidence source that decided the effective update channel. */ export type UpdateChannelSource = | "config" | "git-tag" @@ -9,10 +11,14 @@ export type UpdateChannelSource = | "installed-version" | "default"; +/** Default channel for npm/package installs when no config or version signal overrides it. */ export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; +/** Default channel for source installs where branch metadata is unavailable. */ export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev"; +/** Git branch that represents the development update stream. */ export const DEV_BRANCH = "main"; +/** Normalizes config or CLI channel input to a supported update channel. */ export function normalizeUpdateChannel(value?: string | null): UpdateChannel | null { const normalized = normalizeOptionalLowercaseString(value); if (!normalized) { @@ -24,6 +30,7 @@ export function normalizeUpdateChannel(value?: string | null): UpdateChannel | n return null; } +/** Maps an OpenClaw update channel to the npm dist-tag used for package lookups. */ export function channelToNpmTag(channel: UpdateChannel): string { if (channel === "beta") { return "beta"; @@ -34,10 +41,12 @@ export function channelToNpmTag(channel: UpdateChannel): string { return "latest"; } +/** Returns whether a version/tag explicitly targets the beta stream. */ export function isBetaTag(tag: string): boolean { return /(?:^|[.-])beta(?:[.-]|$)/i.test(tag); } +/** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */ export function isPrereleaseTag(tag: string): boolean { const parsed = parseComparableSemver(tag, { normalizeLegacyDotBeta: true }); if (parsed) { @@ -48,10 +57,12 @@ export function isPrereleaseTag(tag: string): boolean { ); } +/** Returns whether a tag should be treated as a stable release candidate for updates. */ export function isStableTag(tag: string): boolean { return !isPrereleaseTag(tag); } +/** Resolves registry update channel for package checks, preserving beta installs by default. */ export function resolveRegistryUpdateChannel(params: { configChannel?: UpdateChannel | null; currentVersion?: string | null; @@ -67,6 +78,7 @@ export function resolveRegistryUpdateChannel(params: { return params.configChannel ?? DEFAULT_PACKAGE_CHANNEL; } +/** Resolves the effective channel and the signal that selected it. */ export function resolveEffectiveUpdateChannel(params: { configChannel?: UpdateChannel | null; currentVersion?: string | null; @@ -108,6 +120,7 @@ export function resolveEffectiveUpdateChannel(params: { return { channel: DEFAULT_PACKAGE_CHANNEL, source: "default" }; } +/** Formats an operator-facing channel label that includes the deciding source. */ export function formatUpdateChannelLabel(params: { channel: UpdateChannel; source: UpdateChannelSource; @@ -131,6 +144,7 @@ export function formatUpdateChannelLabel(params: { return `${params.channel} (default)`; } +/** Resolves channel metadata plus display label for status and update UIs. */ export function resolveUpdateChannelDisplay(params: { configChannel?: UpdateChannel | null; currentVersion?: string | null; diff --git a/src/infra/update-control-plane-sentinel.ts b/src/infra/update-control-plane-sentinel.ts index 1e30d59d39b8..697bd80b2a80 100644 --- a/src/infra/update-control-plane-sentinel.ts +++ b/src/infra/update-control-plane-sentinel.ts @@ -11,6 +11,8 @@ import { } from "./update-restart-sentinel-payload.js"; import type { UpdateRunResult } from "./update-runner.js"; +// Control-plane update sentinel helpers preserve update metadata while a +// managed service handoff waits for restart health to complete. export const CONTROL_PLANE_UPDATE_SENTINEL_META_ENV = "OPENCLAW_CONTROL_PLANE_UPDATE_SENTINEL_META"; export const CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; export const CONTROL_PLANE_UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending"; @@ -25,6 +27,7 @@ export type ControlPlaneUpdateSentinelMetaFile = { meta: UpdateRestartSentinelMeta; }; +/** Convert an update result into the restart-health-pending sentinel result. */ export function buildControlPlaneUpdateRestartHealthPendingResult( result: UpdateRunResult, ): UpdateRunResult { @@ -40,6 +43,7 @@ export function buildControlPlaneUpdateRestartHealthPendingResult( }; } +/** Return true when an update sentinel represents an in-progress control-plane restart. */ export function isPendingControlPlaneUpdateRestartSentinel( payload: RestartSentinelPayload, ): boolean { @@ -89,6 +93,7 @@ function normalizeMeta(value: unknown): UpdateRestartSentinelMeta | null { }; } +/** Read update sentinel routing metadata from the configured handoff file. */ export async function readControlPlaneUpdateSentinelMeta( env: NodeJS.ProcessEnv = process.env, ): Promise { @@ -108,6 +113,7 @@ export async function readControlPlaneUpdateSentinelMeta( } } +/** Write an update restart sentinel with control-plane routing metadata. */ export async function writeControlPlaneUpdateRestartSentinel(params: { result: UpdateRunResult; meta: UpdateRestartSentinelMeta; @@ -120,6 +126,7 @@ export async function writeControlPlaneUpdateRestartSentinel(params: { ); } +/** Mark the pending update restart sentinel as failed. */ export async function markControlPlaneUpdateRestartSentinelFailure( reason: string, ): Promise { diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 9a951fc29130..74da0fd4fa80 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -19,8 +19,10 @@ import { readPackageVersion } from "./package-json.js"; import { applyPathPrepend } from "./path-prepend.js"; import { parseSemver } from "./runtime-guard.js"; +/** Supported package managers for OpenClaw global install and update flows. */ export type GlobalInstallManager = "npm" | "pnpm" | "bun"; +/** Runs package-manager commands with timeout and environment control. */ export type CommandRunner = ( argv: string[], options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv }, @@ -31,6 +33,10 @@ type ResolvedGlobalInstallCommand = { command: string; }; +/** + * Resolved package-manager command plus the root paths used for install, + * verification, and staged package swaps. + */ export type ResolvedGlobalInstallTarget = ResolvedGlobalInstallCommand & { globalRoot: string | null; packageRoot: string | null; @@ -40,6 +46,7 @@ export type ResolvedGlobalInstallTarget = ResolvedGlobalInstallCommand & { const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; const GLOBAL_RENAME_PREFIX = "."; +/** npm-compatible spec used when the user asks to install the moving main branch. */ export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main"; const COREPACK_ENABLE_DOWNLOAD_PROMPT_DEFAULT = "0"; const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const; @@ -51,6 +58,7 @@ const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([ "dist/extensions/qa-matrix", ]); +/** npm prefix layout paths needed to install, stage, and expose global bins. */ export type NpmGlobalPrefixLayout = { prefix: string; globalRoot: string; @@ -69,10 +77,15 @@ function normalizePackageVersionForComparison(value: string | null | undefined): return trimmed.replace(/^[vV](?=\d)/, ""); } +/** Returns true when a user target requests the moving main-branch package spec. */ export function isMainPackageTarget(value: string): boolean { return normalizeLowercaseStringOrEmpty(normalizePackageTarget(value)) === "main"; } +/** + * Returns true for targets that should pass through as package-manager specs + * rather than being treated as registry dist-tags. + */ export function isExplicitPackageInstallSpec(value: string): boolean { const trimmed = normalizePackageTarget(value); if (!trimmed) { @@ -103,6 +116,11 @@ function isPnpmOpenClawSourceInstallSpec(spec: string): boolean { ); } +/** + * Extracts a pinned installed version from package specs like `openclaw@1.2.3`. + * Moving tags, URLs, git refs, and aliases return null because they cannot be + * compared reliably after install. + */ export function resolveExpectedInstalledVersionFromSpec( packageName: string, spec: string, @@ -125,6 +143,10 @@ export function resolveExpectedInstalledVersionFromSpec( return normalizePackageVersionForComparison(rawVersion); } +/** + * Verifies that a global package root looks like a packaged OpenClaw install + * and, when supplied, matches the expected concrete version. + */ export async function collectInstalledGlobalPackageErrors(params: { packageRoot: string; expectedVersion?: string | null; @@ -297,6 +319,10 @@ async function collectInstalledPathErrors(params: { return errors; } +/** + * Returns true when a target can be resolved through npm registry metadata. + * Explicit tarball, URL, git, and main-branch specs bypass registry lookup. + */ export function canResolveRegistryVersionForPackageTarget(value: string): boolean { const trimmed = normalizePackageTarget(value); if (!trimmed) { @@ -346,6 +372,10 @@ function applyCorepackDownloadPromptEnv(env: Record) { } } +/** + * Converts a user tag or explicit package target into the package-manager spec + * used by global install commands. + */ export function resolveGlobalInstallSpec(params: { packageName: string; tag: string; @@ -367,6 +397,11 @@ export function resolveGlobalInstallSpec(params: { return `${params.packageName}@${target}`; } +/** + * Builds the package-manager environment used for global installs. + * It keeps caller env values, adds platform-specific install defaults, and + * disables npm/corepack prompts that would otherwise hang unattended updates. + */ export async function createGlobalInstallEnv( env?: NodeJS.ProcessEnv, ): Promise { @@ -421,6 +456,10 @@ function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null { return null; } +/** + * Infers npm prefix, package root, and bin paths from an npm global root. + * Direct `node_modules` roots are accepted only when the caller opts into them. + */ export function resolveNpmGlobalPrefixLayoutFromGlobalRoot( globalRoot?: string | null, options: { allowDirectNodeModulesRoot?: boolean } = {}, @@ -459,6 +498,10 @@ export function resolveNpmGlobalPrefixLayoutFromGlobalRoot( return null; } +/** + * Derives npm's global package and bin directories from a prefix root. + * Used for staged installs where OpenClaw creates the prefix itself. + */ export function resolveNpmGlobalPrefixLayoutFromPrefix(prefix: string): NpmGlobalPrefixLayout { const resolvedPrefix = path.resolve(prefix); if (process.platform === "win32") { @@ -540,6 +583,10 @@ function inferPnpmGlobalRootFromPackageRoot(pkgRoot?: string | null): string | n return resolvePnpmGlobalDirFromGlobalRoot(globalRoot) ? globalRoot : null; } +/** + * Resolves pnpm's global-dir from its active `node_modules` root. + * Versioned pnpm layouts put packages under `//node_modules`. + */ export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): string | null { const trimmed = globalRoot?.trim(); if (!trimmed) { @@ -578,6 +625,10 @@ function resolvePreferredGlobalManagerCommand( return resolvePreferredNpmCommand(pkgRoot) ?? manager; } +/** + * Resolves the package-manager command to execute for a global install. + * npm may use the npm binary beside an existing package root when available. + */ export function resolveGlobalInstallCommand( manager: GlobalInstallManager, pkgRoot?: string | null, @@ -608,6 +659,10 @@ function resolveInstallCommandForManager( : resolveGlobalInstallCommand(manager, pkgRoot); } +/** + * Reads the global `node_modules` root for a package manager command. + * Bun uses its deterministic install root because it has no `root -g` command. + */ export async function resolveGlobalRoot( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, runCommand: CommandRunner, @@ -627,6 +682,7 @@ export async function resolveGlobalRoot( return root || null; } +/** Resolves the OpenClaw package root under a package manager's global root. */ export async function resolveGlobalPackageRoot( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, runCommand: CommandRunner, @@ -640,6 +696,10 @@ export async function resolveGlobalPackageRoot( return path.join(root, PRIMARY_PACKAGE_NAME); } +/** + * Resolves the effective global install target, honoring an existing package + * root when requested and detecting pnpm or bun layouts before command probes. + */ export async function resolveGlobalInstallTarget(params: { manager: GlobalInstallManager | ResolvedGlobalInstallCommand; runCommand: CommandRunner; @@ -689,6 +749,10 @@ export async function resolveGlobalInstallTarget(params: { }; } +/** + * Identifies which global package manager owns an existing package root. + * Command probes are checked first, then pnpm/bun layout fingerprints. + */ export async function detectGlobalInstallManagerForRoot( runCommand: CommandRunner, pkgRoot: string, @@ -744,6 +808,10 @@ export async function detectGlobalInstallManagerForRoot( return null; } +/** + * Detects an installed global OpenClaw package by probing package-manager roots + * when no trusted package root is already available. + */ export async function detectGlobalInstallManagerByPresence( runCommand: CommandRunner, timeoutMs: number, @@ -769,6 +837,10 @@ export async function detectGlobalInstallManagerByPresence( return null; } +/** + * Builds the primary package-manager argv for a global OpenClaw install. + * npm receives quiet/freshness-bypass flags; pnpm source installs allow builds. + */ export function globalInstallArgs( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, spec: string, @@ -802,6 +874,10 @@ export function globalInstallArgs( ]; } +/** + * Builds npm's retry argv without optional dependencies. + * Non-npm managers have no equivalent fallback and return null. + */ export function globalInstallFallbackArgs( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, spec: string, @@ -826,6 +902,7 @@ export function globalInstallFallbackArgs( ]; } +/** Removes leftover hidden global package directories from interrupted renames. */ export async function cleanupGlobalRenameDirs(params: { globalRoot: string; packageName: string; diff --git a/src/infra/update-package-manager.ts b/src/infra/update-package-manager.ts index 582ca37b0cac..1d0434383de1 100644 --- a/src/infra/update-package-manager.ts +++ b/src/infra/update-package-manager.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; import { applyPathPrepend } from "./path-prepend.js"; +// Update package-manager resolution chooses the package manager for update +// builds and can bootstrap pnpm when a managed checkout requires it. type BuildManager = "pnpm" | "bun" | "npm"; type UpdatePackageManagerRequirement = "allow-fallback" | "require-preferred"; @@ -149,6 +151,7 @@ async function bootstrapPnpmViaNpm(params: { } } +/** Resolve the package manager and environment to use for an update build. */ export async function resolveUpdateBuildManager( runCommand: PackageManagerCommandRunner, root: string, @@ -213,6 +216,7 @@ export async function resolveUpdateBuildManager( return { kind: "resolved", manager: "npm", preferred, fallback: preferred !== "npm" }; } +/** Build argv for running a package-manager script. */ export function managerScriptArgs(manager: BuildManager, script: string, args: string[] = []) { if (manager === "pnpm") { return ["pnpm", script, ...args]; @@ -226,6 +230,7 @@ export function managerScriptArgs(manager: BuildManager, script: string, args: s return ["npm", "run", script]; } +/** Build argv for installing dependencies with a package manager. */ export function managerInstallArgs(manager: BuildManager, opts?: { compatFallback?: boolean }) { if (manager === "pnpm") { return ["pnpm", "install"]; @@ -239,6 +244,7 @@ export function managerInstallArgs(manager: BuildManager, opts?: { compatFallbac return ["npm", "install"]; } +/** Build argv for installing dependencies while skipping lifecycle scripts. */ export function managerInstallIgnoreScriptsArgs(manager: BuildManager): string[] | null { if (manager === "pnpm") { return ["pnpm", "install", "--ignore-scripts"]; diff --git a/src/infra/update-restart-sentinel-payload.ts b/src/infra/update-restart-sentinel-payload.ts index d31ebca16ea8..55723970cc54 100644 --- a/src/infra/update-restart-sentinel-payload.ts +++ b/src/infra/update-restart-sentinel-payload.ts @@ -5,6 +5,9 @@ import { } from "./restart-sentinel.js"; import type { UpdateRunResult } from "./update-runner.js"; +// Update restart sentinel payloads carry update result details across a process +// restart so the next gateway can report completion or failure. +/** Metadata needed to route update restart continuation messages. */ export type UpdateRestartSentinelMeta = { sessionKey?: string; deliveryContext?: { @@ -18,6 +21,7 @@ export type UpdateRestartSentinelMeta = { continuationMessage?: string | null; }; +/** Build the restart sentinel payload written after update runs. */ export function buildUpdateRestartSentinelPayload(params: { result: UpdateRunResult; meta: UpdateRestartSentinelMeta; diff --git a/src/infra/voicewake-routing.ts b/src/infra/voicewake-routing.ts index 63046f13986c..b6e6519c9f45 100644 --- a/src/infra/voicewake-routing.ts +++ b/src/infra/voicewake-routing.ts @@ -9,6 +9,8 @@ import { } from "../routing/session-key.js"; import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js"; +// Voice wake routing maps normalized wake phrases to an agent, session key, or +// current session target and persists the mapping under state settings. type VoiceWakeRouteTarget = | { mode: "current"; agentId?: undefined; sessionKey?: undefined } | { agentId: string; sessionKey?: undefined; mode?: undefined } @@ -41,6 +43,7 @@ function resolvePath(baseDir?: string) { return path.join(root, "settings", "voicewake-routing.json"); } +/** Normalize a voice wake trigger phrase for matching and duplicate checks. */ export function normalizeVoiceWakeTriggerWord(value: string): string { return value .toLowerCase() @@ -154,6 +157,7 @@ function validateRouteTargetInput( }; } +/** Validate user-provided voice wake routing config before persistence. */ export function validateVoiceWakeRoutingConfigInput( input: unknown, ): { ok: true } | { ok: false; message: string } { @@ -221,6 +225,8 @@ export function validateVoiceWakeRoutingConfigInput( } return { ok: true }; } + +/** Normalize persisted or user-provided voice wake routing config. */ export function normalizeVoiceWakeRoutingConfig(input: unknown): VoiceWakeRoutingConfig { if (!input || typeof input !== "object") { return { ...DEFAULT_ROUTING }; @@ -251,6 +257,7 @@ export function normalizeVoiceWakeRoutingConfig(input: unknown): VoiceWakeRoutin const withLock = createAsyncLock(); +/** Load persisted voice wake routing config from state. */ export async function loadVoiceWakeRoutingConfig( baseDir?: string, ): Promise { @@ -262,6 +269,7 @@ export async function loadVoiceWakeRoutingConfig( return normalizeVoiceWakeRoutingConfig(existing); } +/** Persist normalized voice wake routing config. */ export async function setVoiceWakeRoutingConfig( config: unknown, baseDir?: string, @@ -295,6 +303,7 @@ function resolveVoiceWakeRouteTarget( return { mode: "current" }; } +/** Resolve the route target for a normalized wake trigger. */ export function resolveVoiceWakeRouteByTrigger(params: { trigger: string | undefined; config: VoiceWakeRoutingConfig; diff --git a/src/infra/voicewake.ts b/src/infra/voicewake.ts index faf713246707..e458a607e09a 100644 --- a/src/infra/voicewake.ts +++ b/src/infra/voicewake.ts @@ -3,6 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { resolveStateDir } from "../config/paths.js"; import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js"; +// Voice wake config stores trigger words used by local voice integrations. type VoiceWakeConfig = { triggers: string[]; updatedAtMs: number; @@ -24,10 +25,12 @@ function sanitizeTriggers(triggers: string[] | undefined | null): string[] { const withLock = createAsyncLock(); +/** Return the built-in voice wake trigger list. */ export function defaultVoiceWakeTriggers() { return [...DEFAULT_TRIGGERS]; } +/** Load persisted voice wake triggers, falling back to defaults. */ export async function loadVoiceWakeConfig(baseDir?: string): Promise { const filePath = resolvePath(baseDir); const existing = await tryReadJson(filePath); @@ -43,6 +46,7 @@ export async function loadVoiceWakeConfig(baseDir?: string): Promise(warningFilterKey, () => ({ installed: false, @@ -77,6 +80,8 @@ export function installProcessWarningFilter(): void { if (shouldIgnoreWarning(normalizeWarningArgs(args))) { return; } + // Node does not emit Error + options warnings through the same path after wrapping; preserve + // visibility by re-emitting a normalized warning object for unsuppressed cases. if ( args[0] instanceof Error && args[1] && diff --git a/src/infra/windows-encoding.ts b/src/infra/windows-encoding.ts index 62afe62de0fd..6e495795fbcb 100644 --- a/src/infra/windows-encoding.ts +++ b/src/infra/windows-encoding.ts @@ -13,6 +13,7 @@ const WINDOWS_CODEPAGE_ENCODING_MAP: Record = { let cachedWindowsConsoleEncoding: string | null | undefined; +/** Extracts a Windows console code page number from localized `chcp` output. */ export function parseWindowsCodePage(raw: string): number | null { if (!raw) { return null; @@ -28,6 +29,7 @@ export function parseWindowsCodePage(raw: string): number | null { return codePage; } +/** Resolves and caches the current Windows console encoding for subprocess output. */ export function resolveWindowsConsoleEncoding(): string | null { if (process.platform !== "win32") { return null; @@ -51,6 +53,7 @@ export function resolveWindowsConsoleEncoding(): string | null { return cachedWindowsConsoleEncoding; } +/** Decodes one complete subprocess output buffer, preferring valid UTF-8 before legacy code pages. */ export function decodeWindowsOutputBuffer(params: { buffer: Buffer; platform?: NodeJS.Platform; @@ -77,6 +80,7 @@ export function decodeWindowsOutputBuffer(params: { } } +/** Creates a streaming decoder for subprocess output chunks that may split multibyte characters. */ export function createWindowsOutputDecoder(params?: { platform?: NodeJS.Platform; windowsEncoding?: string | null; @@ -106,6 +110,8 @@ export function createWindowsOutputDecoder(params?: { if (useLegacyDecoder) { return legacyDecoder.decode(buffer, { stream: true }); } + // Stay on strict UTF-8 until it fails; replay any pending lead bytes through the legacy + // decoder so split GBK/Big5/etc. characters are not lost at the fallback boundary. const replayBuffer = pendingUtf8Bytes.length > 0 ? Buffer.concat([pendingUtf8Bytes, buffer]) : buffer; try { diff --git a/src/infra/ws.ts b/src/infra/ws.ts index 441672e78de0..07345d9d38cd 100644 --- a/src/infra/ws.ts +++ b/src/infra/ws.ts @@ -1,6 +1,8 @@ import { Buffer } from "node:buffer"; import type WebSocket from "ws"; +// WebSocket.RawData can arrive as strings, buffers, ArrayBuffers, or buffer +// fragments depending on ws internals and caller options. export function rawDataToString( data: WebSocket.RawData, encoding: BufferEncoding = "utf8", diff --git a/src/infra/wsl.ts b/src/infra/wsl.ts index 67953961fc94..916478dfdf43 100644 --- a/src/infra/wsl.ts +++ b/src/infra/wsl.ts @@ -4,10 +4,12 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st let wslCached: boolean | null = null; +/** Clears the cached async WSL detection result between isolated tests. */ export function resetWSLStateForTests(): void { wslCached = null; } +/** Detects WSL from environment variables without touching the filesystem. */ export function isWSLEnv(): boolean { if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) { return true; @@ -16,8 +18,7 @@ export function isWSLEnv(): boolean { } /** - * Synchronously check if running in WSL. - * Checks env vars first, then /proc/version. + * Synchronously detects WSL from env vars first, then `/proc/version`. */ export function isWSLSync(): boolean { if (process.platform !== "linux") { @@ -35,7 +36,7 @@ export function isWSLSync(): boolean { } /** - * Synchronously check if running in WSL2. + * Synchronously detects WSL2 from kernel-version markers after WSL detection. */ export function isWSL2Sync(): boolean { if (!isWSLSync()) { @@ -49,6 +50,7 @@ export function isWSL2Sync(): boolean { } } +/** Asynchronously detects WSL from env vars and `/proc/sys/kernel/osrelease`, with process cache. */ export async function isWSL(): Promise { if (wslCached !== null) { return wslCached; diff --git a/src/link-understanding/apply.runtime.ts b/src/link-understanding/apply.runtime.ts index e559ee456d6b..8eb665ce28cf 100644 --- a/src/link-understanding/apply.runtime.ts +++ b/src/link-understanding/apply.runtime.ts @@ -1 +1,3 @@ +// Lazy runtime facade for link-understanding application logic. Kept separate +// so callers can avoid loading the full apply path until runtime execution. export { applyLinkUnderstanding } from "./apply.js"; diff --git a/src/link-understanding/apply.ts b/src/link-understanding/apply.ts index f0cd15de2484..736fd0510ac5 100644 --- a/src/link-understanding/apply.ts +++ b/src/link-understanding/apply.ts @@ -9,6 +9,7 @@ type ApplyLinkUnderstandingResult = { urls: string[]; }; +/** Runs link understanding and folds successful outputs into the inbound context. */ export async function applyLinkUnderstanding(params: { ctx: MsgContext; cfg: OpenClawConfig; diff --git a/src/link-understanding/defaults.ts b/src/link-understanding/defaults.ts index 1b35621efd02..2ee818911a71 100644 --- a/src/link-understanding/defaults.ts +++ b/src/link-understanding/defaults.ts @@ -1,2 +1,5 @@ +/** Default per-link fetch and CLI timeout when link tool config omits one. */ export const DEFAULT_LINK_TIMEOUT_SECONDS = 30; + +/** Default cap for links extracted from one inbound message. */ export const DEFAULT_MAX_LINKS = 3; diff --git a/src/link-understanding/detect.ts b/src/link-understanding/detect.ts index 365685626e2a..7d5bb77727a7 100644 --- a/src/link-understanding/detect.ts +++ b/src/link-understanding/detect.ts @@ -31,6 +31,10 @@ function isAllowedUrl(raw: string): boolean { } } +/** + * Extracts unique, SSRF-filtered bare HTTP(S) links from inbound text. + * Markdown links are ignored so display-only citations do not trigger fetches. + */ export function extractLinksFromMessage(message: string, opts?: { maxLinks?: number }): string[] { const source = message?.trim(); if (!source) { diff --git a/src/link-understanding/format.ts b/src/link-understanding/format.ts index 0efe209cf49f..7d1934ee8c55 100644 --- a/src/link-understanding/format.ts +++ b/src/link-understanding/format.ts @@ -1,5 +1,6 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +/** Appends normalized link-understanding outputs to the agent-visible body. */ export function formatLinkUnderstandingBody(params: { body?: string; outputs: string[] }): string { const outputs = normalizeStringEntries(params.outputs); if (outputs.length === 0) { diff --git a/src/link-understanding/runner.ts b/src/link-understanding/runner.ts index 8d7811ce7db7..54a0a2a4f11d 100644 --- a/src/link-understanding/runner.ts +++ b/src/link-understanding/runner.ts @@ -117,6 +117,7 @@ async function runCliEntry(params: { const args = params.entry.args ?? []; const timeoutMs = resolveTimeoutMsFromConfig({ config: params.config, entry: params.entry }); if (isUrlFetcherCommand(command) && args.some(isLinkUrlTemplate)) { + // curl/wget URL templates mark the entry as a fetcher; guarded fetch already supplied content. return params.content; } @@ -184,6 +185,10 @@ async function runLinkEntries(params: { return null; } +/** + * Fetches detected links through the SSRF guard and runs configured CLI processors. + * Returns detected URLs even when processors are absent so callers can report discovery. + */ export async function runLinkUnderstanding(params: { cfg: OpenClawConfig; ctx: MsgContext; diff --git a/src/llm/api-registry.ts b/src/llm/api-registry.ts index 10902365f86f..5a4b02da74b8 100644 --- a/src/llm/api-registry.ts +++ b/src/llm/api-registry.ts @@ -1 +1,2 @@ +// Core facade for the shared LLM runtime API provider registry. export * from "../../packages/llm-runtime/src/api-registry.js"; diff --git a/src/llm/model-registry.ts b/src/llm/model-registry.ts index fc41e4cb4768..50783e37c68b 100644 --- a/src/llm/model-registry.ts +++ b/src/llm/model-registry.ts @@ -1,5 +1,6 @@ import type { Model } from "./types.js"; +/** Registry abstraction used by model pickers and provider availability checks. */ export type ModelRegistry = { getAll(): Model[]; getAvailable(): Model[]; diff --git a/src/llm/model-utils.ts b/src/llm/model-utils.ts index be3b041ab50d..81644b7b3682 100644 --- a/src/llm/model-utils.ts +++ b/src/llm/model-utils.ts @@ -1,5 +1,6 @@ import type { Api, Model, ModelThinkingLevel, Usage } from "./types.js"; +/** Calculates and stores model cost fields from token usage and per-million pricing. */ export function calculateCost(model: Model, usage: Usage): Usage["cost"] { usage.cost.input = (model.cost.input / 1000000) * usage.input; usage.cost.output = (model.cost.output / 1000000) * usage.output; @@ -20,6 +21,7 @@ const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = [ "max", ]; +/** Returns thinking levels exposed by a reasoning-capable model. */ export function getSupportedThinkingLevels( model: Model, ): ModelThinkingLevel[] { @@ -39,6 +41,7 @@ export function getSupportedThinkingLevels( }); } +/** Clamps a requested thinking level to the closest supported level for a model. */ export function clampThinkingLevel( model: Model, level: ModelThinkingLevel, @@ -53,6 +56,7 @@ export function clampThinkingLevel( return availableLevels[0] ?? "off"; } + // Prefer the next stronger available level, then walk down if the request was above the model cap. for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { const candidate = EXTENDED_THINKING_LEVELS[i]; if (availableLevels.includes(candidate)) { @@ -68,6 +72,7 @@ export function clampThinkingLevel( return availableLevels[0] ?? "off"; } +/** Compares model identity by provider and id. */ export function modelsAreEqual( a: Model | null | undefined, b: Model | null | undefined, diff --git a/src/llm/oauth.ts b/src/llm/oauth.ts index d768a0fe6f3d..3f39238be608 100644 --- a/src/llm/oauth.ts +++ b/src/llm/oauth.ts @@ -1 +1,2 @@ +// Public OAuth facade for built-in provider login/refresh helpers. export * from "./utils/oauth/index.js"; diff --git a/src/llm/providers/azure-deployment-map.ts b/src/llm/providers/azure-deployment-map.ts index c4c8c226f044..13484c066d30 100644 --- a/src/llm/providers/azure-deployment-map.ts +++ b/src/llm/providers/azure-deployment-map.ts @@ -1,3 +1,4 @@ +/** Parses AZURE_OPENAI_DEPLOYMENT_MAP-style model=deployment entries. */ export function parseAzureDeploymentNameMap(value: string | undefined): Map { const map = new Map(); if (!value) { @@ -22,6 +23,7 @@ export function parseAzureDeploymentNameMap(value: string | undefined): Map(); +/** Converts tools to deterministic OpenAI Responses function tool definitions. */ export function convertResponsesTools( tools: Tool[], options?: ConvertResponsesToolsOptions, ): OpenAITool[] { const strictSetting = resolveResponsesStrictToolSetting(options); const strict = resolveResponsesStrictToolFlag(tools, strictSetting, options?.model); + // Sort tools before request construction so prompt-cache bytes stay deterministic. return sortResponsesToolsByName(tools).map((tool) => { const result: ResponsesFunctionTool = { type: "function", @@ -100,6 +104,7 @@ function shouldLogStrictToolDowngradeDiagnostic( diagnostics: ReturnType, model: Model, ): boolean { + // Strict downgrade diagnostics can repeat per turn; hash details and cap memory. const key = createHash("sha256") .update( JSON.stringify({ diff --git a/src/llm/providers/register-builtins.ts b/src/llm/providers/register-builtins.ts index 2ab119ec6b29..1a660cc8c4a7 100644 --- a/src/llm/providers/register-builtins.ts +++ b/src/llm/providers/register-builtins.ts @@ -19,6 +19,7 @@ import type { OpenAICodexResponsesOptions } from "./openai-chatgpt-responses.js" import type { OpenAICompletionsOptions } from "./openai-completions.js"; import type { OpenAIResponsesOptions } from "./openai-responses.js"; +// Lazy built-in provider registration keeps the main LLM stream facade cheap to import. interface LazyProviderModule< TApi extends Api, TOptions extends StreamOptions, @@ -79,6 +80,7 @@ interface OpenAIResponsesProviderModule { streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; } +/** Source id used for built-in API provider registrations. */ export const BUILT_IN_API_PROVIDER_SOURCE_ID = "core:built-in"; let anthropicProviderModulePromise: @@ -150,6 +152,7 @@ function createLazyLoadErrorMessage( }; } +// Provider modules load on first use, but callers still receive a stream synchronously. function createLazyStream< TApi extends Api, TOptions extends StreamOptions, @@ -166,6 +169,7 @@ function createLazyStream< forwardStream(outer, inner); }) .catch((error: unknown) => { + // Surface lazy-load failures as normal assistant error messages for stream consumers. const message = createLazyLoadErrorMessage(model, error); outer.push({ type: "error", reason: "error", error: message }); outer.end(message); @@ -333,6 +337,7 @@ export const streamSimpleOpenAIResponses = createLazySimpleStream( loadOpenAIResponsesProviderModule, ); +/** Registers all built-in API providers into the shared runtime registry. */ export function registerBuiltInApiProviders(): void { registerApiProvider( { @@ -407,6 +412,7 @@ export function registerBuiltInApiProviders(): void { ); } +/** Restores the built-in provider registry state for tests. */ export function resetApiProviders(): void { unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID); registerBuiltInApiProviders(); diff --git a/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts b/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts index 1666a539e571..9191e23e7a6f 100644 --- a/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts +++ b/src/llm/providers/stream-wrappers/anthropic-cache-control-payload.ts @@ -1,3 +1,4 @@ +// Public facade for Anthropic cache-control payload helpers used by stream wrappers. export { applyAnthropicEphemeralCacheControlMarkers, resolveAnthropicEphemeralCacheControl, diff --git a/src/llm/providers/stream-wrappers/google.ts b/src/llm/providers/stream-wrappers/google.ts index 9e42a47cef95..5f2592443150 100644 --- a/src/llm/providers/stream-wrappers/google.ts +++ b/src/llm/providers/stream-wrappers/google.ts @@ -1,3 +1,4 @@ +// Public facade for Google thinking payload wrappers shared with plugin providers. export { createGoogleThinkingPayloadWrapper, sanitizeGoogleThinkingPayload, diff --git a/src/llm/providers/stream-wrappers/moonshot.ts b/src/llm/providers/stream-wrappers/moonshot.ts index 129217d33eab..c0fd99aee9ec 100644 --- a/src/llm/providers/stream-wrappers/moonshot.ts +++ b/src/llm/providers/stream-wrappers/moonshot.ts @@ -9,6 +9,7 @@ export { resolveMoonshotThinkingType, } from "./moonshot-thinking.js"; +/** Detects SiliconFlow Pro models that require thinking=null instead of thinking="off". */ export function shouldApplySiliconFlowThinkingOffCompat(params: { provider: string; modelId: string; @@ -21,10 +22,12 @@ export function shouldApplySiliconFlowThinkingOffCompat(params: { ); } +/** Wraps Moonshot-compatible requests to rewrite SiliconFlow thinking-off payloads. */ export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => { + // SiliconFlow rejects the string "off" for these models but accepts null. if (payloadObj.thinking === "off") { payloadObj.thinking = null; } diff --git a/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts index 24d5f9ced70d..dc77dbb3b945 100644 --- a/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts +++ b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts @@ -1,7 +1,9 @@ import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +/** Reasoning effort values accepted by OpenAI-compatible providers. */ export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; +/** Maps OpenClaw thinking levels onto provider reasoning-effort labels. */ export function mapThinkingLevelToReasoningEffort(thinkingLevel: ThinkLevel): ReasoningEffort { if (thinkingLevel === "off") { return "none"; diff --git a/src/llm/providers/stream-wrappers/stream-payload-utils.ts b/src/llm/providers/stream-wrappers/stream-payload-utils.ts index 5468be6fd191..3f585c2f8af8 100644 --- a/src/llm/providers/stream-wrappers/stream-payload-utils.ts +++ b/src/llm/providers/stream-wrappers/stream-payload-utils.ts @@ -1,5 +1,6 @@ import type { StreamFn } from "../../../agents/runtime/index.js"; +/** Wraps a stream function and lets callers mutate outgoing provider payload objects. */ export function streamWithPayloadPatch( underlying: StreamFn, model: Parameters[0], @@ -11,6 +12,7 @@ export function streamWithPayloadPatch( return underlying(model, context, { ...options, onPayload: (payload) => { + // Payload hooks receive mutable provider request objects before the underlying sender uses them. if (payload && typeof payload === "object") { patchPayload(payload as Record); } diff --git a/src/llm/session-resources.ts b/src/llm/session-resources.ts index 2868ad816cb4..a76f59f91b92 100644 --- a/src/llm/session-resources.ts +++ b/src/llm/session-resources.ts @@ -1,7 +1,10 @@ +/** Cleanup callback for resources tied to an LLM session or all sessions. */ export type SessionResourceCleanup = (sessionId?: string) => void; +// Process-local registry of cleanup hooks owned by LLM providers/transports. const sessionResourceCleanups = new Set(); +/** Registers a session-resource cleanup hook and returns an unregister function. */ export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): () => void { sessionResourceCleanups.add(cleanup); return () => { @@ -9,6 +12,7 @@ export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): }; } +/** Runs all registered cleanup hooks, aggregating failures after every hook has run. */ export function cleanupSessionResources(sessionId?: string): void { const errors: unknown[] = []; for (const cleanup of sessionResourceCleanups) { diff --git a/src/llm/stream.ts b/src/llm/stream.ts index f73c73bff50f..6990f4e8abde 100644 --- a/src/llm/stream.ts +++ b/src/llm/stream.ts @@ -1,5 +1,6 @@ import { registerBuiltInApiProviders } from "./providers/register-builtins.js"; +// Register built-ins as a side effect before re-exporting the shared runtime stream API. registerBuiltInApiProviders(); export { diff --git a/src/llm/types.ts b/src/llm/types.ts index 8cb69fa32843..7c85826c1e34 100644 --- a/src/llm/types.ts +++ b/src/llm/types.ts @@ -1 +1,2 @@ +// Core facade for shared LLM type contracts. export * from "../../packages/llm-core/src/types.js"; diff --git a/src/llm/utils/diagnostics.ts b/src/llm/utils/diagnostics.ts index 85b4780dfb3a..5ed25f670b76 100644 --- a/src/llm/utils/diagnostics.ts +++ b/src/llm/utils/diagnostics.ts @@ -1 +1,2 @@ +// Core facade for shared LLM diagnostic helpers. export * from "../../../packages/llm-core/src/utils/diagnostics.js"; diff --git a/src/llm/utils/event-stream.ts b/src/llm/utils/event-stream.ts index 537525947926..11d1b75311dc 100644 --- a/src/llm/utils/event-stream.ts +++ b/src/llm/utils/event-stream.ts @@ -1 +1,2 @@ +// Core facade for shared assistant-message event stream utilities. export * from "../../../packages/llm-core/src/utils/event-stream.js"; diff --git a/src/llm/utils/headers.ts b/src/llm/utils/headers.ts index 521f03c48f08..8202d96b0558 100644 --- a/src/llm/utils/headers.ts +++ b/src/llm/utils/headers.ts @@ -1,3 +1,4 @@ +/** Converts a Headers object to a plain record for provider request handling. */ export function headersToRecord(headers: Headers): Record { const result: Record = {}; for (const [key, value] of headers.entries()) { diff --git a/src/llm/utils/node-http-proxy.ts b/src/llm/utils/node-http-proxy.ts index c73c14efc6d9..bb97554a18e2 100644 --- a/src/llm/utils/node-http-proxy.ts +++ b/src/llm/utils/node-http-proxy.ts @@ -6,6 +6,7 @@ import { UNSUPPORTED_PROXY_PROTOCOL_MESSAGE, } from "../../infra/net/node-proxy-agent.js"; +/** HTTP(S) agent pair for Node fetch/client integrations that accept explicit agents. */ export interface NodeHttpProxyAgents { httpAgent: HttpAgent; httpsAgent: HttpsAgent; @@ -13,10 +14,12 @@ export interface NodeHttpProxyAgents { export { UNSUPPORTED_PROXY_PROTOCOL_MESSAGE }; +/** Resolves the environment proxy URL that applies to a target URL. */ export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { return resolveEnvNodeProxyUrlForTarget(targetUrl); } +/** Builds fixed HTTP and HTTPS proxy agents for a target URL, when env proxy config applies. */ export function createHttpProxyAgentsForTarget( targetUrl: string | URL, ): NodeHttpProxyAgents | undefined { diff --git a/src/llm/utils/oauth/abort.ts b/src/llm/utils/oauth/abort.ts index 3f75f876f70d..37f83526294c 100644 --- a/src/llm/utils/oauth/abort.ts +++ b/src/llm/utils/oauth/abort.ts @@ -1,3 +1,4 @@ +// OAuth abort/cancellation helpers re-exported for provider login flows. export { buildOAuthRequestSignal, createOAuthLoginCancelledError, diff --git a/src/llm/utils/oauth/oauth-page.ts b/src/llm/utils/oauth/oauth-page.ts index 3978488e40b4..2d6dd2161208 100644 --- a/src/llm/utils/oauth/oauth-page.ts +++ b/src/llm/utils/oauth/oauth-page.ts @@ -1 +1,2 @@ +// OAuth callback HTML fragments shared by provider login flows. export { oauthErrorHtml, oauthSuccessHtml } from "../../../plugin-sdk/provider-oauth-runtime.js"; diff --git a/src/llm/utils/oauth/openai-chatgpt.ts b/src/llm/utils/oauth/openai-chatgpt.ts index 59752c897cb2..094bcc2df8e8 100644 --- a/src/llm/utils/oauth/openai-chatgpt.ts +++ b/src/llm/utils/oauth/openai-chatgpt.ts @@ -4,6 +4,7 @@ import type { WizardPrompter } from "../../../wizard/prompts.js"; import { throwIfOAuthLoginAborted, withOAuthLoginAbort } from "./abort.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; +// OAuth adapter for the bundled OpenAI/ChatGPT provider surface. const OPENAI_CODEX_PROVIDER_ID = "openai"; type OpenAICodexOAuthFacade = { @@ -27,6 +28,7 @@ function createLegacyRuntime(callbacks: OAuthLoginCallbacks): RuntimeEnv { }; } +// Bridges generic OAuth callbacks into the wizard prompter expected by the provider login flow. function createLegacyPrompter(callbacks: OAuthLoginCallbacks): WizardPrompter { const progress = { update: (message: string) => callbacks.onProgress?.(message), @@ -68,6 +70,7 @@ async function refreshViaProviderRuntime(refreshToken: string): Promise = { ...refreshed }; @@ -76,6 +79,7 @@ async function refreshViaProviderRuntime(refreshToken: string): Promise { throwIfOAuthLoginAborted(callbacks.signal); const { loginOpenAICodexOAuth } = @@ -104,10 +108,12 @@ export async function loginOpenAICodex(callbacks: OAuthLoginCallbacks): Promise< return credentials; } +/** Refreshes a ChatGPT/Codex OAuth token through the provider runtime or bundled facade. */ export async function refreshOpenAICodexToken(refreshToken: string): Promise { return await refreshViaProviderRuntime(refreshToken); } +/** OAuth provider descriptor for ChatGPT subscription-backed OpenAI access. */ export const openaiCodexOAuthProvider: OAuthProviderInterface = { id: OPENAI_CODEX_PROVIDER_ID, name: "ChatGPT Plus/Pro (Codex Subscription)", diff --git a/src/llm/utils/oauth/pkce.ts b/src/llm/utils/oauth/pkce.ts index 5172635e4cb3..5018daa95c8c 100644 --- a/src/llm/utils/oauth/pkce.ts +++ b/src/llm/utils/oauth/pkce.ts @@ -1 +1,2 @@ +// PKCE/state helpers shared by OAuth provider login flows. export { generateOAuthState, generatePKCE } from "../../../plugin-sdk/provider-oauth-runtime.js"; diff --git a/src/llm/utils/oauth/types.ts b/src/llm/utils/oauth/types.ts index d415e7e0248a..984c139d7313 100644 --- a/src/llm/utils/oauth/types.ts +++ b/src/llm/utils/oauth/types.ts @@ -1,3 +1,4 @@ +// OAuth type facade for core and provider login integrations. export type { OAuthAuthInfo, OAuthCredentials, diff --git a/src/logging/config.ts b/src/logging/config.ts index ea3936380b4e..f7e5e3951a89 100644 --- a/src/logging/config.ts +++ b/src/logging/config.ts @@ -5,6 +5,7 @@ import { getCommandPathWithRootOptions } from "../cli/argv.js"; import { resolveConfigPath } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Lightweight logging-config reader used before the full config runtime is safe to load. type LoggingConfig = OpenClawConfig["logging"]; let cachedLoggingConfig: @@ -14,11 +15,13 @@ let cachedLoggingConfig: } | undefined; +/** Avoids config reads that can mutate or validate config while schema/config commands run. */ export function shouldSkipMutatingLoggingConfigRead(argv: string[] = process.argv): boolean { const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); return primary === "config" && (secondary === "schema" || secondary === "validate"); } +/** Reads the logging block from config, caching by resolved config path. */ export function readLoggingConfig(): LoggingConfig | undefined { if (shouldSkipMutatingLoggingConfigRead()) { return undefined; @@ -31,6 +34,7 @@ export function readLoggingConfig(): LoggingConfig | undefined { if (!fs.existsSync(configPath)) { return undefined; } + // JSON5 mirrors the main config parser while keeping this early logger path dependency-light. const parsed = JSON5.parse(fs.readFileSync(configPath, "utf8")); const logging = isObjectRecord(parsed) ? parsed.logging : undefined; const resolved = isObjectRecord(logging) ? (logging as LoggingConfig) : undefined; diff --git a/src/logging/diagnostic-memory.ts b/src/logging/diagnostic-memory.ts index 7d3bc647cf46..927eead606b5 100644 --- a/src/logging/diagnostic-memory.ts +++ b/src/logging/diagnostic-memory.ts @@ -6,6 +6,7 @@ import { import { writeDiagnosticMemoryPressureBundleSync } from "./diagnostic-stability-bundle.js"; import { createSubsystemLogger } from "./subsystem.js"; +// Diagnostic memory sampler with threshold/growth pressure detection and repeat suppression. const MB = 1024 * 1024; const DEFAULT_RSS_WARNING_BYTES = 1536 * MB; const DEFAULT_RSS_CRITICAL_BYTES = 3072 * MB; @@ -44,6 +45,7 @@ const state: DiagnosticMemoryState = { lastPressureAtByKey: new Map(), }; +// Convert Node's runtime shape into the diagnostic event contract. function normalizeMemoryUsage(memory: NodeJS.MemoryUsage): DiagnosticMemoryUsage { return { rssBytes: memory.rss, @@ -153,6 +155,7 @@ function shouldEmitPressure( ): boolean { const key = `${pressure.level}:${pressure.reason}`; const lastAt = state.lastPressureAtByKey.get(key); + // Pressure events can repeat during sustained memory spikes; throttle per level/reason pair. if (lastAt !== undefined && now - lastAt < repeatMs) { return false; } @@ -223,6 +226,7 @@ export function emitDiagnosticMemorySample(options?: { const writeCriticalBundle = options?.writeCriticalBundle === true; logMemoryPressure({ pressure, writeCriticalBundle }); if (pressure.level === "critical" && writeCriticalBundle) { + // Critical snapshots are opt-in because bundle writes can add IO during memory pressure. const sessionStorePaths = options?.sessionStorePaths ?? options?.resolveSessionStorePaths?.(); const result = writeDiagnosticMemoryPressureBundleSync({ pressure, @@ -246,6 +250,7 @@ export function emitDiagnosticMemorySample(options?: { return memory; } +/** Clears process-local memory diagnostic state for isolated tests. */ export function resetDiagnosticMemoryForTest(): void { state.lastSample = null; state.lastPressureAtByKey.clear(); diff --git a/src/logging/diagnostic-payload.ts b/src/logging/diagnostic-payload.ts index 09f3a0dda58d..a06ce80a549e 100644 --- a/src/logging/diagnostic-payload.ts +++ b/src/logging/diagnostic-payload.ts @@ -1,6 +1,7 @@ import { emitInternalDiagnosticEvent as emitDiagnosticEvent } from "../infra/diagnostic-events.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +// Diagnostic helpers for oversized payload decisions across channels/providers. type LargePayloadBase = { surface: string; bytes?: number; @@ -11,6 +12,7 @@ type LargePayloadBase = { reason?: string; }; +/** Emits a normalized diagnostic event for rejected, truncated, or chunked payloads. */ export function logLargePayload( params: LargePayloadBase & { action: "rejected" | "truncated" | "chunked"; @@ -22,6 +24,7 @@ export function logLargePayload( }); } +/** Convenience wrapper for payloads rejected before downstream processing. */ export function logRejectedLargePayload(params: LargePayloadBase): void { logLargePayload({ action: "rejected", @@ -29,6 +32,7 @@ export function logRejectedLargePayload(params: LargePayloadBase): void { }); } +/** Parses an HTTP Content-Length header without accepting malformed numeric input. */ export function parseContentLengthHeader(raw: string | string[] | undefined): number | undefined { const value = Array.isArray(raw) ? raw[0] : raw; if (typeof value !== "string") { diff --git a/src/logging/diagnostic-phase.ts b/src/logging/diagnostic-phase.ts index 818462fe59ae..f25a582452d2 100644 --- a/src/logging/diagnostic-phase.ts +++ b/src/logging/diagnostic-phase.ts @@ -6,6 +6,7 @@ import { type DiagnosticPhaseSnapshot, } from "../infra/diagnostic-events.js"; +// Tracks nested diagnostic phases for recent-phase snapshots and optional event emission. const RECENT_PHASE_CAPACITY = 40; type ActiveDiagnosticPhase = { @@ -53,6 +54,7 @@ export function getRecentDiagnosticPhases(limit = 8): DiagnosticPhaseSnapshot[] return recentPhases.slice(-resolved).map((phase) => Object.assign({}, phase)); } +/** Records a completed phase in memory and emits it when diagnostics are enabled. */ export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { pushRecentPhase(snapshot); if (!areDiagnosticsEnabledForProcess()) { @@ -64,6 +66,7 @@ export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { }); } +/** Runs work inside a measured diagnostic phase with wall-clock and CPU metrics. */ export async function withDiagnosticPhase( name: string, run: () => Promise | T, @@ -80,6 +83,7 @@ export async function withDiagnosticPhase( try { return await run(); } finally { + // Remove by identity so nested or overlapping phases do not corrupt the active stack. const endedAt = Date.now(); const durationMs = roundMetric(performance.now() - active.startedWallMs, 1); const cpu = process.cpuUsage(active.cpuStarted); @@ -101,6 +105,7 @@ export async function withDiagnosticPhase( } } +/** Clears phase history and active stack for isolated tests. */ export function resetDiagnosticPhasesForTest(): void { activePhaseStack = []; recentPhases = []; diff --git a/src/logging/diagnostic-runtime.ts b/src/logging/diagnostic-runtime.ts index 93a9f17d3929..66f59c1daee6 100644 --- a/src/logging/diagnostic-runtime.ts +++ b/src/logging/diagnostic-runtime.ts @@ -4,23 +4,29 @@ import { } from "../infra/diagnostic-events.js"; import { createSubsystemLogger } from "./subsystem.js"; +// Shared diagnostic logger and queue-activity event helpers. const diag = createSubsystemLogger("diagnostic"); let lastActivityAt = 0; +/** Root diagnostic subsystem logger. */ export const diagnosticLogger = diag; +/** Marks that diagnostics emitted useful activity. */ export function markDiagnosticActivity(): void { lastActivityAt = Date.now(); } +/** Returns the last diagnostic activity timestamp for watchdog-style checks. */ export function getLastDiagnosticActivityAt(): number { return lastActivityAt; } +/** Clears diagnostic activity state for tests. */ export function resetDiagnosticActivityForTest(): void { lastActivityAt = 0; } +/** Logs and emits a diagnostic event when work enters a serialized lane. */ export function logLaneEnqueue(lane: string, queueSize: number): void { if (!areDiagnosticsEnabledForProcess()) { return; @@ -34,6 +40,7 @@ export function logLaneEnqueue(lane: string, queueSize: number): void { markDiagnosticActivity(); } +/** Logs and emits a diagnostic event when work leaves a serialized lane. */ export function logLaneDequeue(lane: string, waitMs: number, queueSize: number): void { if (!areDiagnosticsEnabledForProcess()) { return; diff --git a/src/logging/diagnostic-session-state.ts b/src/logging/diagnostic-session-state.ts index 6dafdd06ef1d..2f7dc6ba51cd 100644 --- a/src/logging/diagnostic-session-state.ts +++ b/src/logging/diagnostic-session-state.ts @@ -1,5 +1,7 @@ +// Process-local session-state tracker used by diagnostic stuck-session detection. export type SessionStateValue = "idle" | "processing" | "waiting"; +/** Mutable diagnostic state for one session key or id. */ export type SessionState = { sessionId?: string; sessionKey?: string; @@ -16,6 +18,7 @@ export type SessionState = { commandPollCounts?: Map; }; +/** Compact record of a recent tool call used for loop diagnostics. */ export type ToolCallRecord = { toolName: string; argsHash: string; @@ -26,12 +29,14 @@ export type ToolCallRecord = { timestamp: number; }; +/** Partial session identity accepted by diagnostic helpers. */ export type SessionRef = { sessionId?: string; sessionKey?: string; sessionFile?: string; }; +/** Shared in-memory diagnostic session state map. */ export const diagnosticSessionStates = new Map(); const SESSION_STATE_TTL_MS = 30 * 60 * 1000; @@ -40,6 +45,7 @@ const SESSION_STATE_MAX_ENTRIES = 2000; let lastSessionPruneAt = 0; +/** Prunes stale idle session states and caps the process-local state map. */ export function pruneDiagnosticSessionStates(now = Date.now(), force = false): void { const shouldPruneForSize = diagnosticSessionStates.size > SESSION_STATE_MAX_ENTRIES; if (!force && !shouldPruneForSize && now - lastSessionPruneAt < SESSION_STATE_PRUNE_INTERVAL_MS) { @@ -109,6 +115,7 @@ function mergeSessionState(target: SessionState, source: SessionState): void { } target.generation = Math.max(target.generation ?? 0, source.generation ?? 0); target.lastActivity = Math.max(target.lastActivity, source.lastActivity); + // Queue depth is additive when session id/key aliases collapse into one diagnostic entry. target.queueDepth += source.queueDepth; target.activeQueuedTurn ||= source.activeQueuedTurn; target.lastStuckWarnAgeMs = @@ -139,6 +146,7 @@ function mergeSessionState(target: SessionState, source: SessionState): void { } } +/** Gets or creates diagnostic state, merging aliases that share a session id. */ export function getDiagnosticSessionState(ref: SessionRef): SessionState { pruneDiagnosticSessionStates(); const key = resolveSessionKey(ref); @@ -147,6 +155,7 @@ export function getDiagnosticSessionState(ref: SessionRef): SessionState { const existing = direct ?? sessionIdEntry?.[1]; if (existing) { if (direct && sessionIdEntry && sessionIdEntry[1] !== direct) { + // A run may learn its stable session key after an id-only state exists; merge instead of losing counters. mergeSessionState(direct, sessionIdEntry[1]); diagnosticSessionStates.delete(sessionIdEntry[0]); } else if (!direct && ref.sessionKey && sessionIdEntry) { @@ -178,6 +187,7 @@ export function getDiagnosticSessionState(ref: SessionRef): SessionState { return created; } +/** Looks up diagnostic state without creating a new entry. */ export function peekDiagnosticSessionState(ref: SessionRef): SessionState | undefined { const key = resolveSessionKey(ref); return ( @@ -186,15 +196,18 @@ export function peekDiagnosticSessionState(ref: SessionRef): SessionState | unde ); } +/** Returns the current state count for pruning tests. */ export function getDiagnosticSessionStateCountForTest(): number { return diagnosticSessionStates.size; } +/** Clears all process-local diagnostic session state for tests. */ export function resetDiagnosticSessionStateForTest(): void { diagnosticSessionStates.clear(); lastSessionPruneAt = 0; } +/** Checks whether a generation/state snapshot still matches current diagnostic state. */ export function isDiagnosticSessionStateCurrent(params: { sessionId?: string; sessionKey?: string; diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index 01b3a5c3cea5..b483a614e79a 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -4,6 +4,7 @@ import { type DiagnosticMemoryUsage, } from "../infra/diagnostic-events.js"; +// Ring-buffer recorder for stability diagnostics and support-bundle snapshots. const DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY = 1000; const DEFAULT_DIAGNOSTIC_STABILITY_LIMIT = 50; export const MAX_DIAGNOSTIC_STABILITY_LIMIT = DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY; @@ -11,6 +12,7 @@ const LIVENESS_EVENT_LOOP_DELAY_WARN_MS = 1_000; const SAFE_REASON_CODE = /^[A-Za-z0-9_.:-]{1,120}$/u; +/** Sanitized diagnostic event record retained in the stability ring buffer. */ export type DiagnosticStabilityEventRecord = { seq: number; ts: number; @@ -91,6 +93,7 @@ export type DiagnosticStabilityEventRecord = { }; }; +/** Point-in-time stability snapshot with records and derived summaries. */ export type DiagnosticStabilitySnapshot = { generatedAt: string; capacity: number; @@ -688,6 +691,7 @@ function normalizeLimit(limit: unknown, defaultLimit = DEFAULT_DIAGNOSTIC_STABIL return parsed; } +/** Normalizes user-facing snapshot query options. */ export function normalizeDiagnosticStabilityQuery( input: DiagnosticStabilityQueryInput = {}, options?: { defaultLimit?: number }, @@ -699,6 +703,7 @@ export function normalizeDiagnosticStabilityQuery( }; } +/** Starts the process-wide diagnostic event recorder if it is not already active. */ export function startDiagnosticStabilityRecorder(): void { const state = getDiagnosticStabilityState(); if (state.unsubscribe) { @@ -709,12 +714,14 @@ export function startDiagnosticStabilityRecorder(): void { }); } +/** Stops the process-wide diagnostic event recorder. */ export function stopDiagnosticStabilityRecorder(): void { const state = getDiagnosticStabilityState(); state.unsubscribe?.(); state.unsubscribe = null; } +/** Returns a sanitized stability snapshot from the process-wide ring buffer. */ export function getDiagnosticStabilitySnapshot(options?: { limit?: number; type?: string; @@ -734,6 +741,7 @@ export function getDiagnosticStabilitySnapshot(options?: { }; } +/** Applies filtering/limits to an existing snapshot without mutating its source records. */ export function selectDiagnosticStabilitySnapshot( snapshot: DiagnosticStabilitySnapshot, options?: { @@ -753,6 +761,7 @@ export function selectDiagnosticStabilitySnapshot( }; } +/** Resets recorder state and subscriptions for isolated tests. */ export function resetDiagnosticStabilityRecorderForTest(): void { const state = getDiagnosticStabilityState(); state.unsubscribe?.(); diff --git a/src/logging/diagnostic-stuck-session-recovery.runtime.ts b/src/logging/diagnostic-stuck-session-recovery.runtime.ts index e3f6a3a85a38..0a0944ac6f4b 100644 --- a/src/logging/diagnostic-stuck-session-recovery.runtime.ts +++ b/src/logging/diagnostic-stuck-session-recovery.runtime.ts @@ -23,10 +23,12 @@ import { } from "./diagnostic-session-recovery.js"; import { isDiagnosticSessionStateCurrent } from "./diagnostic-session-state.js"; +// Runtime repair path for diagnostic sessions that appear stuck in processing/waiting states. const STUCK_SESSION_ABORT_SETTLE_MS = 15_000; const STUCK_SESSION_PROGRESS_STALE_MS = 5 * 60_000; const recoveriesInFlight = new Set(); +/** Request parameters accepted by the stuck-session recovery runtime. */ export type StuckSessionRecoveryParams = StuckSessionRecoveryRequest; function resolveStaleActiveProgressAbortMs(params: StuckSessionRecoveryParams): number { @@ -94,6 +96,7 @@ export async function recoverStuckDiagnosticSession( recoveriesInFlight.add(key); try { + // Abort only the generation/state that triggered recovery; stale warnings become observe-only. if ( !isDiagnosticSessionStateCurrent({ sessionId: params.sessionId, @@ -166,6 +169,7 @@ export async function recoverStuckDiagnosticSession( `stuck session recovery reclaiming stale active run: ${formatRecoveryContext(params, { activeSessionId })}`, ); } + // Active embedded runs own their cleanup; recovery asks them to abort and drain first. const result = await abortAndDrainEmbeddedAgentRun({ sessionId: activeSessionId, sessionKey: params.sessionKey, @@ -317,6 +321,7 @@ export async function recoverStuckDiagnosticSession( } } +/** Test hooks for clearing in-flight recovery guards. */ export const testing = { resetRecoveriesInFlight(): void { recoveriesInFlight.clear(); diff --git a/src/logging/diagnostic-support-bundle.ts b/src/logging/diagnostic-support-bundle.ts index 4687834f3370..eec2943db85f 100644 --- a/src/logging/diagnostic-support-bundle.ts +++ b/src/logging/diagnostic-support-bundle.ts @@ -2,12 +2,14 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { isPathInside } from "../infra/path-guards.js"; +// File builders and writers for redacted diagnostic support bundles. export type DiagnosticSupportBundleFile = { path: string; mediaType: string; content: string; }; +/** Manifest entry for one written support bundle file. */ export type DiagnosticSupportBundleContent = { path: string; mediaType: string; @@ -18,6 +20,7 @@ function supportBundleByteLength(content: string): number { return Buffer.byteLength(content, "utf8"); } +/** Creates a JSON support-bundle file with a safe relative path. */ export function jsonSupportBundleFile( pathName: string, value: unknown, @@ -29,6 +32,7 @@ export function jsonSupportBundleFile( }; } +/** Creates an NDJSON support-bundle file with a safe relative path. */ export function jsonlSupportBundleFile( pathName: string, lines: readonly string[], @@ -40,6 +44,7 @@ export function jsonlSupportBundleFile( }; } +/** Creates a UTF-8 text support-bundle file with a safe relative path. */ export function textSupportBundleFile( pathName: string, content: string, @@ -51,6 +56,7 @@ export function textSupportBundleFile( }; } +/** Summarizes support-bundle files for the bundle manifest. */ export function supportBundleContents( files: readonly DiagnosticSupportBundleFile[], ): DiagnosticSupportBundleContent[] { @@ -82,6 +88,7 @@ function resolveSupportBundleFilePath(outputDir: string, pathName: string): stri const safePath = assertSafeBundleRelativePath(pathName); const resolvedBase = path.resolve(outputDir); const resolvedFile = path.resolve(resolvedBase, safePath); + // Re-check after path.resolve so crafted relative paths cannot escape the output directory. if (resolvedFile === resolvedBase || !isPathInside(resolvedBase, resolvedFile)) { throw new Error(`Bundle file path escaped output directory: ${pathName}`); } @@ -101,6 +108,7 @@ async function writeSupportBundleFile( }); } +/** Writes support-bundle files to a new private directory. */ export async function writeSupportBundleDirectory(params: { outputDir: string; files: readonly DiagnosticSupportBundleFile[]; @@ -112,6 +120,7 @@ export async function writeSupportBundleDirectory(params: { return supportBundleContents(params.files); } +/** Writes support-bundle files to a private zip archive and returns its byte size. */ export async function writeSupportBundleZip(params: { outputPath: string; files: readonly DiagnosticSupportBundleFile[]; diff --git a/src/logging/diagnostic-support-log-redaction.ts b/src/logging/diagnostic-support-log-redaction.ts index edfaf233f916..9d1f42701ecb 100644 --- a/src/logging/diagnostic-support-log-redaction.ts +++ b/src/logging/diagnostic-support-log-redaction.ts @@ -5,6 +5,7 @@ import { type SupportRedactionContext, } from "./diagnostic-support-redaction.js"; +// Sanitizes JSON log records before they enter support bundles. const LOG_STRING_FIELD_RE = /^(?:action|channel|code|component|endpoint|event|handshake|kind|level|localAddr|logger|method|model|module|msg|name|outcome|phase|pluginId|provider|reason|remoteAddr|requestId|runId|service|source|status|subsystem|surface|target|time|traceId|type)$/iu; const LOG_SCALAR_FIELD_RE = @@ -30,6 +31,7 @@ function createLogRecord(): Record { return Object.create(null) as Record; } +/** Parses and sanitizes one log line into safe support-bundle metadata. */ export function sanitizeSupportLogRecord( line: string, redaction: SupportRedactionContext, @@ -114,6 +116,7 @@ function addLogTapeArgFields( .filter(([key]) => LOGTAPE_ARG_FIELD_RE.test(key)) .toSorted(([left], [right]) => Number(left) - Number(right)); + // LogTape stores message args as numeric keys; only structured safe fields survive. for (const [, value] of args) { const record = typeof value === "string" ? parseJsonRecord(value) : asOptionalRecord(value); if (record) { diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 3c28e6ba777a..339ec15b1f9e 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -5,6 +5,7 @@ import { isSecretRefShape } from "../config/redact-snapshot.secret-ref.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { redactSensitiveText } from "./redact.js"; +// Redaction helpers for support bundles; preserve operational shape while removing private data. const SECRET_SUPPORT_FIELD_RE = /(?:authorization|cookie|credential|key|password|passwd|secret|token)/iu; const PAYLOAD_SUPPORT_FIELD_RE = @@ -35,6 +36,7 @@ const MAX_SUPPORT_OBJECT_ENTRIES = 1000; const DEFAULT_TRUNCATION_SUFFIX = "..."; const TRUNCATED_SUPPORT_FIELD = ""; +/** Context needed to redact paths and environment-derived private prefixes. */ export type SupportRedactionContext = { env: NodeJS.ProcessEnv; stateDir: string; @@ -364,6 +366,7 @@ function sanitizeCommandArguments(args: unknown[], redaction: SupportRedactionCo }); } +/** Sanitizes general diagnostic snapshots while keeping bounded object/array structure. */ export function sanitizeSupportSnapshotValue( value: unknown, redaction: SupportRedactionContext, @@ -385,6 +388,7 @@ export function sanitizeSupportSnapshotValue( if (Array.isArray(value)) { const { count, items } = limitedSupportArray(value); if (key === "programArguments") { + // Command arguments get flag-aware redaction so "--token value" redacts the following item. return supportArrayResult(sanitizeCommandArguments(items, redaction), count); } return supportArrayResult( @@ -410,6 +414,7 @@ export function sanitizeSupportSnapshotValue( return sanitized; } +/** Sanitizes config-shaped values with stricter private field handling. */ export function sanitizeSupportConfigValue( value: unknown, redaction: SupportRedactionContext, diff --git a/src/logging/env-log-level.ts b/src/logging/env-log-level.ts index 290aa036a8dd..cdc6d4d7f431 100644 --- a/src/logging/env-log-level.ts +++ b/src/logging/env-log-level.ts @@ -2,6 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { ALLOWED_LOG_LEVELS, type LogLevel, tryParseLogLevel } from "./levels.js"; import { loggingState } from "./state.js"; +/** Resolves OPENCLAW_LOG_LEVEL once per value, warning only when the invalid value changes. */ export function resolveEnvLogLevelOverride(): LogLevel | undefined { const trimmed = normalizeOptionalString(process.env.OPENCLAW_LOG_LEVEL) ?? ""; if (!trimmed) { diff --git a/src/logging/log-file-path.ts b/src/logging/log-file-path.ts index 5ed549739662..8179e8f47b54 100644 --- a/src/logging/log-file-path.ts +++ b/src/logging/log-file-path.ts @@ -5,6 +5,7 @@ import { resolvePreferredOpenClawTmpDir, } from "../infra/tmp-openclaw-dir.js"; +// Default logger path uses the preferred tmp directory when Node fs is available. const LOG_PREFIX = "openclaw"; const LOG_SUFFIX = ".log"; @@ -36,6 +37,7 @@ function resolveDefaultRollingLogFile(date = new Date()): string { return path.join(logDir, `${LOG_PREFIX}-${formatLocalDate(date)}${LOG_SUFFIX}`); } +/** Resolves the configured log file or today's rolling default log path. */ export function resolveConfiguredLogFilePath(config?: OpenClawConfig | null): string { return config?.logging?.file ?? resolveDefaultRollingLogFile(); } diff --git a/src/logging/log-tail.ts b/src/logging/log-tail.ts index 01fa8a1c4965..8d0e86a354d1 100644 --- a/src/logging/log-tail.ts +++ b/src/logging/log-tail.ts @@ -4,12 +4,14 @@ import { getResolvedLoggerSettings } from "../logging.js"; import { clamp } from "../utils.js"; import { redactSensitiveLines, resolveRedactOptions } from "./redact.js"; +// Tail reader for the active log file, with cursor reset and line redaction. const DEFAULT_LIMIT = 500; const DEFAULT_MAX_BYTES = 250_000; const MAX_LIMIT = 5000; const MAX_BYTES = 1_000_000; const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/; +/** Payload returned to log-tail callers with cursor and truncation metadata. */ export type LogTailPayload = { file: string; cursor: number; @@ -23,6 +25,7 @@ function isRollingLogFile(file: string): boolean { return ROLLING_LOG_RE.test(path.basename(file)); } +/** Resolves a rolling daily log path to the newest existing rolling log when needed. */ export async function resolveLogFile(file: string): Promise { const stat = await fs.stat(file).catch(() => null); if (stat) { @@ -83,12 +86,14 @@ async function readLogSlice(params: { if (cursor != null) { if (cursor > size) { + // File rotated or shrank since the previous cursor; restart near the end. reset = true; start = Math.max(0, size - maxBytes); truncated = start > 0; } else { start = cursor; if (size - start > maxBytes) { + // Cursor is valid but too stale; cap reads and tell the caller state was reset. reset = true; truncated = true; start = Math.max(0, size - maxBytes); @@ -124,6 +129,7 @@ async function readLogSlice(params: { const text = buffer.toString("utf8", 0, readResult.bytesRead); let lines = text.split("\n"); if (start > 0 && prefix !== "\n") { + // Drop the first partial line when starting in the middle of a file. lines = lines.slice(1); } if (lines.length > 0 && lines[lines.length - 1] === "") { @@ -147,6 +153,7 @@ async function readLogSlice(params: { } } +/** Reads and redacts the configured log tail with bounded bytes and line count. */ export async function readConfiguredLogTail(params?: { cursor?: number; limit?: number; diff --git a/src/logging/log-test-helpers.ts b/src/logging/log-test-helpers.ts index 59400f54c994..979b9df8d05b 100644 --- a/src/logging/log-test-helpers.ts +++ b/src/logging/log-test-helpers.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +/** Creates per-test log paths under a suite temp root and cleans them up after the suite. */ export function createSuiteLogPathTracker(prefix: string) { const rootTracker = createSuiteTempRootTracker({ prefix }); let logRoot = ""; diff --git a/src/logging/node-require.ts b/src/logging/node-require.ts index 779701b5b70e..32a785a4b7e3 100644 --- a/src/logging/node-require.ts +++ b/src/logging/node-require.ts @@ -1,3 +1,4 @@ +/** Resolves createRequire from process.getBuiltinModule without static CommonJS imports. */ export function resolveNodeRequireFromMeta(metaUrl: string): NodeJS.Require | null { const getBuiltinModule = ( process as NodeJS.Process & { diff --git a/src/logging/parse-log-line.ts b/src/logging/parse-log-line.ts index c4b100c4b1ae..97fcc587ecde 100644 --- a/src/logging/parse-log-line.ts +++ b/src/logging/parse-log-line.ts @@ -1,5 +1,6 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +// Parser for JSON LogTape lines emitted by the OpenClaw logger. type ParsedLogLine = { time?: string; level?: string; @@ -40,6 +41,7 @@ function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } { } } +/** Parses a raw log line into compact metadata and message text, or null for non-JSON lines. */ export function parseLogLine(raw: string): ParsedLogLine | null { try { const parsed = JSON.parse(raw) as Record; diff --git a/src/logging/redact-bounded.ts b/src/logging/redact-bounded.ts index 1451d1340d3e..81daad32d836 100644 --- a/src/logging/redact-bounded.ts +++ b/src/logging/redact-bounded.ts @@ -1,3 +1,4 @@ +// Bounded regex replacement prevents large support/log strings from monopolizing the event loop. const REDACT_REGEX_CHUNK_THRESHOLD = 32_768; const REDACT_REGEX_CHUNK_SIZE = 16_384; @@ -6,6 +7,7 @@ type BoundedRedactOptions = { chunkSize?: number; }; +/** Applies a regex replacement in chunks once input crosses the redaction size threshold. */ export function replacePatternBounded( text: string, pattern: RegExp, @@ -19,6 +21,7 @@ export function replacePatternBounded( } let output = ""; + // Chunking may miss matches spanning chunk boundaries; use only for token-like redaction patterns. for (let index = 0; index < text.length; index += chunkSize) { output += text.slice(index, index + chunkSize).replace(pattern, replacer); } diff --git a/src/logging/redact-identifier.ts b/src/logging/redact-identifier.ts index fbe2432c6d6d..37c72d4d5178 100644 --- a/src/logging/redact-identifier.ts +++ b/src/logging/redact-identifier.ts @@ -1,11 +1,13 @@ import crypto from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Returns a stable sha256 hex prefix for non-secret identifier correlation. */ export function sha256HexPrefix(value: string, len = 12): string { const safeLen = Number.isFinite(len) ? Math.max(1, Math.floor(len)) : 12; return crypto.createHash("sha256").update(value).digest("hex").slice(0, safeLen); } +/** Redacts an identifier to a stable hash label, or "-" for missing values. */ export function redactIdentifier(value: string | undefined, opts?: { len?: number }): string { const trimmed = normalizeOptionalString(value); if (!trimmed) { diff --git a/src/logging/state.ts b/src/logging/state.ts index 3f620b75044f..40ec1c62468b 100644 --- a/src/logging/state.ts +++ b/src/logging/state.ts @@ -1,3 +1,4 @@ +// Process-local logging state shared by logger, console capture, and test reset helpers. export const loggingState = { cachedLogger: null as unknown, cachedSettings: null as unknown, diff --git a/src/logging/test-helpers/console-snapshot.ts b/src/logging/test-helpers/console-snapshot.ts index d6b1f1ee36f8..f59678dade94 100644 --- a/src/logging/test-helpers/console-snapshot.ts +++ b/src/logging/test-helpers/console-snapshot.ts @@ -1,3 +1,4 @@ +/** Snapshot of global console methods that tests can restore after capture patches. */ export type ConsoleSnapshot = { log: typeof console.log; info: typeof console.info; @@ -7,6 +8,7 @@ export type ConsoleSnapshot = { trace: typeof console.trace; }; +/** Captures current global console methods. */ export function captureConsoleSnapshot(): ConsoleSnapshot { return { log: console.log, @@ -18,6 +20,7 @@ export function captureConsoleSnapshot(): ConsoleSnapshot { }; } +/** Restores global console methods from a prior snapshot. */ export function restoreConsoleSnapshot(snapshot: ConsoleSnapshot): void { console.log = snapshot.log; console.info = snapshot.info; diff --git a/src/logging/test-helpers/diagnostic-log-capture.ts b/src/logging/test-helpers/diagnostic-log-capture.ts index 288c6e867ff9..1c45a0fd2868 100644 --- a/src/logging/test-helpers/diagnostic-log-capture.ts +++ b/src/logging/test-helpers/diagnostic-log-capture.ts @@ -3,8 +3,10 @@ import { type DiagnosticEventPayload, } from "../../infra/diagnostic-events.js"; +/** Captured diagnostic event shape for emitted log records. */ export type CapturedDiagnosticLogRecord = Extract; +/** Flushes asynchronous diagnostic log record delivery. */ export async function flushDiagnosticLogRecords(): Promise { for (let index = 0; index < 3; index += 1) { await new Promise((resolve) => { @@ -13,6 +15,7 @@ export async function flushDiagnosticLogRecords(): Promise { } } +/** Captures diagnostic log records until cleanup is called. */ export function createDiagnosticLogRecordCapture() { const records: CapturedDiagnosticLogRecord[] = []; const unsubscribe = onInternalDiagnosticEvent((event) => { diff --git a/src/logging/test-helpers/warn-log-capture.ts b/src/logging/test-helpers/warn-log-capture.ts index 1a0aac70207e..f660f35ca44d 100644 --- a/src/logging/test-helpers/warn-log-capture.ts +++ b/src/logging/test-helpers/warn-log-capture.ts @@ -3,6 +3,7 @@ import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js" import { resetLogger, setLoggerOverride } from "../logger.js"; import { createDiagnosticLogRecordCapture } from "./diagnostic-log-capture.js"; +/** Captures warn-level diagnostic log records under an isolated temporary log path. */ export function createWarnLogCapture(prefix: string) { const capture = createDiagnosticLogRecordCapture(); setLoggerOverride({ diff --git a/src/logging/types.ts b/src/logging/types.ts index bb16bce76359..77a5e219f3f5 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -1,7 +1,9 @@ import type { LogLevel } from "./levels.js"; +// Shared logger settings contracts for file and console transports. export type ConsoleStyle = "pretty" | "compact" | "json"; +/** User-configurable logger settings after config/env normalization. */ export type LoggerSettings = { level?: LogLevel; file?: string; diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index 2c325865b612..f300a3afa39b 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -24,6 +24,12 @@ import type { } from "./channel-shared.js"; import { matchEventFilter, normalizeApprovalId, toConversation, toText } from "./channel-shared.js"; +/** + * Runtime bridge between MCP tools and the OpenClaw Gateway channel APIs. + * + * The bridge owns readiness, event cursoring, pending approval state, and the + * narrow request methods that channel MCP tools expose to external clients. + */ type PendingWaiter = { filter: WaitFilter; resolve: (value: QueueEvent | null) => void; @@ -46,6 +52,7 @@ const PENDING_CLAUDE_PERMISSION_TTL_MS = 60 * 60 * 1_000; const PENDING_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1_000; const PENDING_SWEEP_INTERVAL_MS = 5 * 60 * 1_000; +/** Connects the MCP server surface to a Gateway client and queues channel events for polling. */ export class OpenClawChannelBridge { private gateway: GatewayClient | null = null; private readonly verbose: boolean; @@ -84,10 +91,12 @@ export class OpenClawChannelBridge { }); } + /** Attach the MCP server used for outbound protocol notifications. */ setServer(server: McpServer): void { this.server = server; } + /** Start the Gateway connection and resolve only after session subscription succeeds. */ async start(): Promise { if (this.started) { await this.readyPromise; @@ -163,10 +172,12 @@ export class OpenClawChannelBridge { await this.readyPromise; } + /** Wait until the bridge has subscribed to Gateway session events. */ async waitUntilReady(): Promise { await this.readyPromise; } + /** Stop Gateway IO and release waiters so MCP shutdown cannot hang on pending polls. */ async close(): Promise { if (this.closed) { return; @@ -191,6 +202,7 @@ export class OpenClawChannelBridge { await gateway?.stopAndWait().catch(() => undefined); } + /** List Gateway sessions that have enough routing metadata to be channel conversations. */ async listConversations(params?: { limit?: number; search?: string; @@ -216,6 +228,7 @@ export class OpenClawChannelBridge { ); } + /** Resolve one conversation by its stable session key. */ async getConversation(sessionKey: string): Promise { const normalizedSessionKey = sessionKey.trim(); if (!normalizedSessionKey) { @@ -230,6 +243,7 @@ export class OpenClawChannelBridge { return response.session ? toConversation(response.session) : null; } + /** Read recent history through the Gateway session API. */ async readMessages( sessionKey: string, limit = 20, @@ -242,6 +256,7 @@ export class OpenClawChannelBridge { return response.messages ?? []; } + /** Send a reply using the same channel route stored on the conversation. */ async sendMessage(params: { sessionKey: string; text: string; @@ -261,6 +276,7 @@ export class OpenClawChannelBridge { }); } + /** Return locally tracked approval requests that are still open. */ listPendingApprovals(): PendingApproval[] { this.sweepPendingExpired(); return [...this.pendingApprovals.values()] @@ -270,6 +286,7 @@ export class OpenClawChannelBridge { }); } + /** Forward an MCP approval decision to the matching Gateway approval resolver. */ async respondToApproval(params: { kind: ApprovalKind; id: string; @@ -287,12 +304,14 @@ export class OpenClawChannelBridge { }); } + /** Poll queued events after a cursor without consuming them. */ pollEvents(filter: WaitFilter, limit = 20): { events: QueueEvent[]; nextCursor: number } { const events = this.queue.filter((event) => matchEventFilter(event, filter)).slice(0, limit); const nextCursor = events.at(-1)?.cursor ?? filter.afterCursor; return { events, nextCursor }; } + /** Wait for the next matching event, resolving null on timeout or bridge close. */ async waitForEvent(filter: WaitFilter, timeoutMs = 30_000): Promise { const existing = this.queue.find((event) => matchEventFilter(event, filter)); if (existing) { @@ -316,6 +335,7 @@ export class OpenClawChannelBridge { }); } + /** Accept a Claude channel permission notification and expose it through event polling. */ async handleClaudePermissionRequest(params: { requestId: string; toolName: string; @@ -398,6 +418,7 @@ export class OpenClawChannelBridge { private enqueue(event: QueueEvent): void { this.queue.push(event); + // Retain enough history for cursor polling without letting a long MCP session grow unbounded. while (this.queue.length > QUEUE_LIMIT) { this.queue.shift(); } @@ -443,10 +464,12 @@ export class OpenClawChannelBridge { this.pendingSweepInterval = setInterval(() => { this.sweepPendingExpired(); }, PENDING_SWEEP_INTERVAL_MS); + // Pending approval cleanup must not keep a stdio MCP process alive after its client exits. this.pendingSweepInterval.unref(); } private sweepPendingExpired(now: number = Date.now()): void { + // Claude permissions have no Gateway resolution event, so they expire by local observation time. for (const [id, createdAtMs] of this.pendingClaudePermissions) { if (now - createdAtMs >= PENDING_CLAUDE_PERMISSION_TTL_MS) { this.pendingClaudePermissions.delete(id); @@ -601,6 +624,7 @@ export class OpenClawChannelBridge { } } +/** Decide whether startup should wait for a retryable Gateway connect failure to recover. */ export function shouldRetryInitialMcpGatewayConnect(error: Error): boolean { if ( error.name === "GatewayClientRequestError" && diff --git a/src/mcp/channel-server.ts b/src/mcp/channel-server.ts index 78624a77a28b..ae3774b3ad8a 100644 --- a/src/mcp/channel-server.ts +++ b/src/mcp/channel-server.ts @@ -6,8 +6,15 @@ import { OpenClawChannelBridge } from "./channel-bridge.js"; import { ClaudePermissionRequestSchema, type ClaudeChannelMode } from "./channel-shared.js"; import { getChannelMcpCapabilities, registerChannelMcpTools } from "./channel-tools.js"; +/** + * MCP stdio server assembly for OpenClaw channel conversations. + * + * This module wires config, the Gateway bridge, protocol notifications, and + * registered tools into a lifecycle that callers can either embed or serve. + */ export { OpenClawChannelBridge } from "./channel-bridge.js"; +/** Options accepted by the channel MCP server factory and stdio entry point. */ export type OpenClawMcpServeOptions = { gatewayUrl?: string; gatewayToken?: string; @@ -25,6 +32,7 @@ async function resolveMcpConfig(config: OpenClawConfig | undefined): Promise { const { server, start, close } = await createOpenClawChannelMcpServer(opts); const transport = new StdioServerTransport(); @@ -89,6 +98,7 @@ export async function serveOpenClawChannelMcp(opts: OpenClawMcpServeOptions = {} process.stdin.off("close", shutdown); process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); + // The MCP SDK exposes transport close as a mutable handler rather than an EventEmitter API. transport["onclose"] = undefined; close().then(resolveClosed, resolveClosed); }; diff --git a/src/mcp/channel-shared.ts b/src/mcp/channel-shared.ts index 77a8c4b983e3..96db586ffe6b 100644 --- a/src/mcp/channel-shared.ts +++ b/src/mcp/channel-shared.ts @@ -4,8 +4,16 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; +/** + * Shared channel MCP contracts and normalization helpers. + * + * These shapes are intentionally smaller than raw Gateway payloads so MCP tools + * can return stable structured content without exposing every session detail. + */ +/** Controls whether the MCP server advertises Claude channel extensions. */ export type ClaudeChannelMode = "off" | "on" | "auto"; +/** Conversation route information required to read and reply through a channel session. */ export type ConversationDescriptor = { sessionKey: string; channel: string; @@ -44,18 +52,22 @@ type SessionRow = { updatedAt?: number | null; }; +/** Minimal Gateway response shape used by conversation listing. */ export type SessionListResult = { sessions?: SessionRow[]; }; +/** Minimal Gateway response shape used by conversation lookup. */ export type SessionDescribeResult = { session?: SessionRow | null; }; +/** Minimal Gateway response shape used by message reads. */ export type ChatHistoryResult = { messages?: Array<{ id?: string; role?: string; content?: unknown; [key: string]: unknown }>; }; +/** Gateway session.message payload fields consumed by the MCP event bridge. */ export type SessionMessagePayload = { sessionKey?: string; messageId?: string; @@ -68,9 +80,12 @@ export type SessionMessagePayload = { [key: string]: unknown; }; +/** Gateway approval family exposed through MCP. */ export type ApprovalKind = "exec" | "plugin"; +/** Decision values accepted by Gateway approval resolvers. */ export type ApprovalDecision = "allow-once" | "allow-always" | "deny"; +/** Approval request tracked locally while waiting for an MCP client decision. */ export type PendingApproval = { kind: ApprovalKind; id: string; @@ -79,6 +94,7 @@ export type PendingApproval = { expiresAtMs?: number; }; +/** Cursor-addressed event returned by MCP event polling and waiting tools. */ export type QueueEvent = | { cursor: number; @@ -110,17 +126,20 @@ export type QueueEvent = raw: Record; }; +/** Claude channel permission notification payload before it is assigned an event cursor. */ export type ClaudePermissionRequest = { toolName: string; description: string; inputPreview: string; }; +/** Cursor and optional session filter used by event polling and waiting. */ export type WaitFilter = { afterCursor: number; sessionKey?: string; }; +/** Raw MCP notification schema emitted by Claude channel clients for permission prompts. */ export const ClaudePermissionRequestSchema = z.object({ method: z.literal("notifications/claude/channel/permission_request"), params: z.object({ @@ -133,6 +152,7 @@ export const ClaudePermissionRequestSchema = z.object({ export { toText }; +/** Resolve the visible message id, including OpenClaw metadata attached to raw entries. */ export function resolveMessageId(entry: Record): string | undefined { return ( toText(entry.id) ?? @@ -142,6 +162,7 @@ export function resolveMessageId(entry: Record): string | undef ); } +/** Build the text summary format expected by simple MCP tool results. */ export function summarizeResult( label: string, count: number, @@ -151,6 +172,7 @@ export function summarizeResult( }; } +/** Build a text summary plus pretty JSON payload for MCP clients without structured rendering. */ export function summarizeStructuredResult( label: string, count: number, @@ -170,6 +192,7 @@ function resolveConversationChannel(row: SessionRow): string | undefined { ); } +/** Convert a Gateway session row into a reply-capable conversation descriptor. */ export function toConversation(row: SessionRow): ConversationDescriptor | null { const channel = resolveConversationChannel(row); const to = toText(row.deliveryContext?.to) ?? toText(row.lastTo); @@ -193,6 +216,7 @@ export function toConversation(row: SessionRow): ConversationDescriptor | null { }; } +/** Check whether a queued event should be visible to a poll or wait call. */ export function matchEventFilter(event: QueueEvent, filter: WaitFilter): boolean { if (event.cursor <= filter.afterCursor) { return false; @@ -203,6 +227,7 @@ export function matchEventFilter(event: QueueEvent, filter: WaitFilter): boolean return "sessionKey" in event && event.sessionKey === filter.sessionKey; } +/** Return non-text content blocks from a raw message payload. */ export function extractAttachmentsFromMessage(message: unknown): unknown[] { if (!message || typeof message !== "object") { return []; @@ -219,6 +244,7 @@ export function extractAttachmentsFromMessage(message: unknown): unknown[] { }); } +/** Normalize approval identifiers before local tracking or resolution. */ export function normalizeApprovalId(value: unknown): string | undefined { const id = toText(value); return id ? id.trim() : undefined; diff --git a/src/mcp/channel-tools.ts b/src/mcp/channel-tools.ts index 21cf720ec94a..d36f72a8952c 100644 --- a/src/mcp/channel-tools.ts +++ b/src/mcp/channel-tools.ts @@ -9,6 +9,13 @@ import { toText, } from "./channel-shared.js"; +/** + * MCP tool registration for channel conversation access. + * + * Tool handlers stay thin: schemas validate public inputs and the bridge owns + * Gateway readiness, routing, event queueing, and approval resolution. + */ +/** Return protocol capabilities advertised when Claude channel mode is enabled. */ export function getChannelMcpCapabilities(claudeChannelMode: "off" | "on" | "auto") { if (claudeChannelMode === "off") { return undefined; @@ -21,6 +28,7 @@ export function getChannelMcpCapabilities(claudeChannelMode: "off" | "on" | "aut }; } +/** Register all channel MCP tools against a server instance. */ export function registerChannelMcpTools(server: McpServer, bridge: OpenClawChannelBridge): void { server.tool( "conversations_list", diff --git a/src/media-generation/live-test-helpers.ts b/src/media-generation/live-test-helpers.ts index 4863e14970ce..53f63299239a 100644 --- a/src/media-generation/live-test-helpers.ts +++ b/src/media-generation/live-test-helpers.ts @@ -1,6 +1,8 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +// Helpers shared by live media-generation tests. They keep provider/model/env +// parsing deterministic without exposing raw API keys in test output. type LiveProviderModelConfig = | string | { @@ -9,6 +11,7 @@ type LiveProviderModelConfig = } | undefined; +/** Redacts live API keys while preserving enough shape for diagnostics. */ export function redactLiveApiKey(value: string | undefined): string { const trimmed = value?.trim(); if (!trimmed) { @@ -20,6 +23,7 @@ export function redactLiveApiKey(value: string | undefined): string { return `${trimmed.slice(0, 8)}...${trimmed.slice(-4)}`; } +/** Parses comma-separated live-test filters; null means "all". */ export function parseLiveCsvFilter( raw?: string, options: { lowercase?: boolean } = {}, @@ -37,6 +41,7 @@ export function parseLiveCsvFilter( return values.length > 0 ? new Set(values) : null; } +/** Parses provider/model refs keyed by normalized provider id. */ export function parseProviderModelMap(raw?: string): Map { const entries = new Map(); for (const token of raw?.split(",") ?? []) { @@ -57,6 +62,7 @@ export function parseProviderModelMap(raw?: string): Map { return entries; } +/** Collects primary/fallback provider model refs from live-test config. */ export function resolveConfiguredLiveProviderModels( configured: LiveProviderModelConfig, ): Map { @@ -87,6 +93,7 @@ export function resolveConfiguredLiveProviderModels( return resolved; } +/** Returns an empty auth store only when live env keys may be used directly. */ export function resolveLiveAuthStore(params: { requireProfileKeys: boolean; hasLiveKeys: boolean; diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 23af1349e36e..0885521fb910 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -16,6 +16,9 @@ import type { AgentModelConfig } from "../config/types.agents-shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getProviderEnvVars as getDefaultProviderEnvVars } from "../secrets/provider-env-vars.js"; + +// Shared media-generation runtime helpers for provider fallback, request +// timeout normalization, model selection, and capability value normalization. export type { MediaGenerationNormalizationMetadataInput, MediaNormalizationEntry, @@ -27,6 +30,8 @@ export type ParsedProviderModelRef = { provider: string; model: string; }; + +/** Records one provider/model failure in the common fallback-attempt shape. */ export function recordCapabilityCandidateFailure(params: { attempts: FallbackAttempt[]; provider: string; @@ -54,6 +59,7 @@ export function resolveMediaProviderDefaultTimeoutMs( : undefined; } +/** Resolves a request timeout, preferring per-request over provider defaults. */ export function resolveMediaProviderRequestTimeoutMs(params: { timeoutMs?: number; providerDefaultTimeoutMs?: number; @@ -161,12 +167,15 @@ function resolveAutoCapabilityFallbackRefs(params: { ...providerIds.filter(matchesDefaultProvider), ...providerIds.filter((providerId) => !matchesDefaultProvider(providerId)), ]; + // Keep the user's default text provider first when it also has media support; + // then add the remaining configured media providers deterministically. return orderedProviders.flatMap((providerId) => { const entry = providerDefaults.get(providerId); return entry ? [entry.ref] : []; }); } +/** Builds ordered provider/model candidates for one media capability request. */ export function resolveCapabilityModelCandidates(params: { cfg: OpenClawConfig; modelConfig: AgentModelConfig | undefined; @@ -214,6 +223,8 @@ export function resolveCapabilityModelCandidates(params: { return resolveCandidate(params.modelOverride, { useProviderMetadata: true }); })(); if (override) { + // Explicit model overrides are authoritative and should not be expanded into + // auto provider fallback candidates. return [override]; } @@ -321,6 +332,7 @@ function greatestCommonDivisor(a: number, b: number): number { return left || 1; } +/** Derives a reduced aspect ratio string from a WIDTHxHEIGHT size. */ export function deriveAspectRatioFromSize(size?: string): string | undefined { const parsed = parseSizeValue(size); if (!parsed) { @@ -330,6 +342,7 @@ export function deriveAspectRatioFromSize(size?: string): string | undefined { return `${parsed.width / divisor}:${parsed.height / divisor}`; } +/** Chooses the closest supported aspect ratio for a request. */ export function resolveClosestAspectRatio(params: { requestedAspectRatio?: string; requestedSize?: string; @@ -369,6 +382,7 @@ export function resolveClosestAspectRatio(params: { return bestValue; } +/** Chooses the closest supported size by aspect ratio and area. */ export function resolveClosestSize(params: { requestedSize?: string; requestedAspectRatio?: string; @@ -409,6 +423,7 @@ export function resolveClosestSize(params: { return bestValue; } +/** Chooses the closest supported resolution by numeric rank or custom order. */ export function resolveClosestResolution(params: { requestedResolution?: TResolution; supportedResolutions?: readonly TResolution[]; @@ -490,6 +505,7 @@ function parseResolutionRank( }; } +/** Rounds duration and clamps it to a provider maximum when supplied. */ export function normalizeDurationToClosestMax( durationSeconds?: number, maxDurationSeconds?: number, @@ -508,6 +524,7 @@ export function normalizeDurationToClosestMax( return Math.min(rounded, Math.max(1, Math.round(maxDurationSeconds))); } +/** Builds user-visible metadata describing provider normalization decisions. */ export function buildMediaGenerationNormalizationMetadata(params: { normalization?: MediaGenerationNormalizationMetadataInput; requestedSizeForDerivedAspectRatio?: string; @@ -557,6 +574,7 @@ export function buildMediaGenerationNormalizationMetadata(params: { return metadata; } +/** Throws a summarized error after all provider/model candidates fail. */ export function throwCapabilityGenerationFailure(params: { capabilityLabel: string; attempts: FallbackAttempt[]; @@ -612,6 +630,7 @@ function isAbortLikeFallbackAttempt(attempt: FallbackAttempt): boolean { ); } +/** Formats setup guidance when no model is configured for a media capability. */ export function buildNoCapabilityModelConfiguredMessage(params: { capabilityLabel: string; modelConfigKey: string; diff --git a/src/media-understanding/active-model.types.ts b/src/media-understanding/active-model.types.ts index 474c089ac871..be9426009c52 100644 --- a/src/media-understanding/active-model.types.ts +++ b/src/media-understanding/active-model.types.ts @@ -1 +1,2 @@ +// Public facade for the shared active media model contract used by core and plugins. export * from "../../packages/media-understanding-common/src/active-model.js"; diff --git a/src/media-understanding/apply.runtime.ts b/src/media-understanding/apply.runtime.ts index 75621bd84664..5df1dd11f865 100644 --- a/src/media-understanding/apply.runtime.ts +++ b/src/media-understanding/apply.runtime.ts @@ -1 +1,2 @@ +// Lazy runtime entry used by command surfaces that only need media application. export { applyMediaUnderstanding } from "./apply.js"; diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index 74b668e38d46..6aa63dc7fd3d 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -70,6 +70,7 @@ function getDefaultLocalPathRoots(): readonly string[] { return defaultLocalPathRoots; } +/** Local/remote access policy used by the lazy media-understanding attachment cache. */ export type MediaAttachmentCacheOptions = { localPathRoots?: readonly string[]; includeDefaultLocalPathRoots?: boolean; @@ -77,6 +78,12 @@ export type MediaAttachmentCacheOptions = { workspaceDir?: string; }; +/** + * Lazy resolver for media-understanding attachments. + * + * The cache prefers allowed local paths, falls back to remote URLs when a local path is blocked + * or missing, and owns any temporary files created for providers that require a filesystem path. + */ export class MediaAttachmentCache { private readonly entries = new Map(); private readonly attachments: MediaAttachment[]; @@ -98,6 +105,7 @@ export class MediaAttachmentCache { } } + /** Returns attachment bytes, MIME hint, filename, and size within the requested byte limit. */ async getBuffer(params: { attachmentIndex: number; maxBytes: number; @@ -210,6 +218,7 @@ export class MediaAttachmentCache { } } + /** Returns a local path for providers that cannot accept buffers, creating a temp file if needed. */ async getPath(params: { attachmentIndex: number; maxBytes?: number; @@ -271,6 +280,7 @@ export class MediaAttachmentCache { return { path: tmpPath, cleanup: entry.tempCleanup }; } + /** Removes temporary files created by `getPath`; callers should run this after provider use. */ async cleanup(): Promise { const cleanups: Promise[] = []; for (const entry of this.entries.values()) { diff --git a/src/media-understanding/attachments.normalize.ts b/src/media-understanding/attachments.normalize.ts index 5622197ada7f..4f0211d6016c 100644 --- a/src/media-understanding/attachments.normalize.ts +++ b/src/media-understanding/attachments.normalize.ts @@ -4,6 +4,7 @@ import type { MsgContext } from "../auto-reply/templating.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; import type { MediaAttachment } from "./types.js"; +/** Normalizes a local attachment path while rejecting remote file URLs and Windows UNC paths. */ export function normalizeAttachmentPath(raw?: string | null): string | undefined { const value = normalizeOptionalString(raw); if (!value) { @@ -24,6 +25,7 @@ export function normalizeAttachmentPath(raw?: string | null): string | undefined return value; } +/** Flattens legacy single-value and array media fields into indexed attachment records. */ export function normalizeAttachments(ctx: MsgContext): MediaAttachment[] { const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined; const urlsFromArray = Array.isArray(ctx.MediaUrls) ? ctx.MediaUrls : undefined; @@ -84,6 +86,7 @@ export function normalizeAttachments(ctx: MsgContext): MediaAttachment[] { ]; } +/** Classifies an attachment by MIME first, then by filename/URL extension fallback. */ export function resolveAttachmentKind( attachment: MediaAttachment, ): "image" | "audio" | "video" | "document" | "unknown" { @@ -108,14 +111,17 @@ export function resolveAttachmentKind( return "unknown"; } +/** Returns true when the attachment is classified as video media. */ export function isVideoAttachment(attachment: MediaAttachment): boolean { return resolveAttachmentKind(attachment) === "video"; } +/** Returns true when the attachment is classified as audio media. */ export function isAudioAttachment(attachment: MediaAttachment): boolean { return resolveAttachmentKind(attachment) === "audio"; } +/** Returns true when the attachment is classified as image media. */ export function isImageAttachment(attachment: MediaAttachment): boolean { return resolveAttachmentKind(attachment) === "image"; } diff --git a/src/media-understanding/attachments.select.ts b/src/media-understanding/attachments.select.ts index 4d5a694fac66..7986e216dde4 100644 --- a/src/media-understanding/attachments.select.ts +++ b/src/media-understanding/attachments.select.ts @@ -55,6 +55,7 @@ function isAttachmentRecord(value: unknown): value is MediaAttachment { return true; } +/** Selects attachments for a media-understanding capability under configured ordering limits. */ export function selectAttachments(params: { capability: MediaUnderstandingCapability; attachments: MediaAttachment[]; @@ -63,7 +64,7 @@ export function selectAttachments(params: { const { capability, attachments, policy } = params; const input = Array.isArray(attachments) ? attachments.filter(isAttachmentRecord) : []; const matches = input.filter((item) => { - // Skip already-transcribed audio attachments from preflight + // Preflight audio has already been consumed; rerunning STT would duplicate transcript output. if (capability === "audio" && item.alreadyTranscribed) { return false; } diff --git a/src/media-understanding/attachments.ts b/src/media-understanding/attachments.ts index 3d4264448eee..8c06a0b3fedb 100644 --- a/src/media-understanding/attachments.ts +++ b/src/media-understanding/attachments.ts @@ -1,3 +1,4 @@ +// Public attachment facade for normalization, selection, and local media caching helpers. export { isAudioAttachment, normalizeAttachments, diff --git a/src/media-understanding/audio-preflight.ts b/src/media-understanding/audio-preflight.ts index ef9794931d95..7a923a1bb1f5 100644 --- a/src/media-understanding/audio-preflight.ts +++ b/src/media-understanding/audio-preflight.ts @@ -22,7 +22,6 @@ export async function transcribeFirstAudio(params: { }): Promise { const { ctx, cfg } = params; - // Check if audio transcription is enabled in config const audioConfig = cfg.tools?.media?.audio; if (audioConfig?.enabled === false) { return undefined; @@ -33,7 +32,6 @@ export async function transcribeFirstAudio(params: { return undefined; } - // Find first audio attachment const firstAudio = attachments.find( (att) => att && isAudioAttachment(att) && !att.alreadyTranscribed, ); @@ -69,7 +67,7 @@ export async function transcribeFirstAudio(params: { }); } - // Mark this attachment as transcribed to avoid double-processing + // Mark this attachment as transcribed so the main media pass does not duplicate STT output. firstAudio.alreadyTranscribed = true; if (shouldLogVerbose()) { @@ -80,7 +78,7 @@ export async function transcribeFirstAudio(params: { return transcript; } catch (err) { - // Log but don't throw - let the message proceed with text-only mention check + // Preflight cannot block message handling; mention checks can still run on text-only input. if (shouldLogVerbose()) { logVerbose(`audio-preflight: transcription failed: ${String(err)}`); } diff --git a/src/media-understanding/audio-transcription-runner.ts b/src/media-understanding/audio-transcription-runner.ts index 8033f4b8dc37..a63e63b22ed1 100644 --- a/src/media-understanding/audio-transcription-runner.ts +++ b/src/media-understanding/audio-transcription-runner.ts @@ -9,6 +9,7 @@ import { } from "./runner.js"; import type { MediaAttachment, MediaUnderstandingProvider } from "./types.js"; +/** Runs the configured audio-understanding pipeline and returns the first transcript output. */ export async function runAudioTranscription(params: { ctx: MsgContext; cfg: OpenClawConfig; diff --git a/src/media-understanding/audio.test-helpers.ts b/src/media-understanding/audio.test-helpers.ts index aef58dafc5bf..f72b01a25d9e 100644 --- a/src/media-understanding/audio.test-helpers.ts +++ b/src/media-understanding/audio.test-helpers.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, vi } from "vitest"; import * as ssrf from "../infra/net/ssrf.js"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +// Test helpers for media audio providers that need SSRF-safe DNS and request capture. function resolveRequestUrl(input: RequestInfo | URL): string { if (typeof input === "string") { return input; @@ -13,6 +14,7 @@ function resolveRequestUrl(input: RequestInfo | URL): string { return input.url; } +/** Installs deterministic DNS pinning hooks for audio provider tests. */ export function installPinnedHostnameTestHooks(): void { const resolvePinnedHostname = ssrf.resolvePinnedHostname; const resolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; @@ -21,6 +23,7 @@ export function installPinnedHostnameTestHooks(): void { let resolvePinnedHostnameSpy: MockInstance | null = null; let resolvePinnedHostnameWithPolicySpy: MockInstance | null = null; + // Keep the real policy code under test, but make DNS resolution stable and non-networked. beforeEach(() => { lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); resolvePinnedHostnameSpy = vi @@ -42,6 +45,7 @@ export function installPinnedHostnameTestHooks(): void { }); } +/** Creates a fetch mock that records the outbound Authorization header. */ export function createAuthCaptureJsonFetch(responseBody: unknown) { let seenAuth: string | null = null; const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -58,6 +62,7 @@ export function createAuthCaptureJsonFetch(responseBody: unknown) { }; } +/** Creates a fetch mock that records the outbound URL and init payload. */ export function createRequestCaptureJsonFetch(responseBody: unknown) { let seenUrl: string | null = null; let seenInit: RequestInit | undefined; diff --git a/src/media-understanding/concurrency.ts b/src/media-understanding/concurrency.ts index 8449358d58b9..8bfa295d23a8 100644 --- a/src/media-understanding/concurrency.ts +++ b/src/media-understanding/concurrency.ts @@ -1,6 +1,7 @@ import { logVerbose, shouldLogVerbose } from "../globals.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; +/** Runs media tasks under a fixed concurrency limit while preserving successful results. */ export async function runWithConcurrency( tasks: Array<() => Promise>, limit: number, @@ -8,6 +9,7 @@ export async function runWithConcurrency( const { results } = await runTasksWithConcurrency({ tasks, limit, + // Media understanding tries every eligible entry; verbose mode keeps per-entry failures visible. onTaskError(err) { if (shouldLogVerbose()) { logVerbose(`Media understanding task failed: ${String(err)}`); diff --git a/src/media-understanding/config-provider-models.ts b/src/media-understanding/config-provider-models.ts index 03a3b725e803..d56ce61bbe1c 100644 --- a/src/media-understanding/config-provider-models.ts +++ b/src/media-understanding/config-provider-models.ts @@ -14,6 +14,7 @@ function hasImageCapableModel(providerCfg: ConfigProvider): boolean { ); } +/** Finds configured model providers that can be auto-registered for image understanding. */ export function resolveImageCapableConfigProviderIds(cfg?: OpenClawConfig): string[] { const configProviders = cfg?.models?.providers; if (!configProviders || typeof configProviders !== "object") { diff --git a/src/media-understanding/defaults.constants.ts b/src/media-understanding/defaults.constants.ts index c9195eed9d72..319545cc6974 100644 --- a/src/media-understanding/defaults.constants.ts +++ b/src/media-understanding/defaults.constants.ts @@ -1 +1,2 @@ +// Core facade for shared media-understanding defaults and size thresholds. export * from "../../packages/media-understanding-common/src/defaults.js"; diff --git a/src/media-understanding/defaults.ts b/src/media-understanding/defaults.ts index 7fc5080c0230..95bcb7a6474e 100644 --- a/src/media-understanding/defaults.ts +++ b/src/media-understanding/defaults.ts @@ -136,6 +136,7 @@ function insertConfiguredImageProviders(params: { return uniqueStrings(merged); } +/** Resolves the default provider model for a media capability from config or manifest metadata. */ export function resolveDefaultMediaModel(params: { providerId: string; capability: MediaUnderstandingCapability; @@ -168,6 +169,7 @@ export function resolveDefaultMediaModel(params: { return undefined; } +/** Resolves auto-discovery provider order for a media capability using manifest priorities. */ export function resolveAutoMediaKeyProviders(params: { capability: MediaUnderstandingCapability; cfg?: OpenClawConfig; @@ -206,6 +208,7 @@ export function resolveAutoMediaKeyProviders(params: { }); } +/** Returns whether provider metadata declares native PDF document input support. */ export function providerSupportsNativePdfDocument(params: { providerId: string; cfg?: OpenClawConfig; @@ -218,6 +221,7 @@ export function providerSupportsNativePdfDocument(params: { return provider?.nativeDocumentInputs?.includes("pdf") ?? false; } +/** Resolves provider-specific document model hints, preserving explicit unsupported markers. */ export function resolveDocumentMediaModel(params: { providerId: string; document: "pdf"; diff --git a/src/media-understanding/echo-transcript.ts b/src/media-understanding/echo-transcript.ts index 1924ae416698..c454e58d5ba2 100644 --- a/src/media-understanding/echo-transcript.ts +++ b/src/media-understanding/echo-transcript.ts @@ -11,16 +11,14 @@ function loadMessageRuntime() { return messageRuntimePromise; } +/** Default operator-visible transcript echo format for preflight audio transcription. */ export const DEFAULT_ECHO_TRANSCRIPT_FORMAT = '📝 "{transcript}"'; function formatEchoTranscript(transcript: string, format: string): string { return format.replace("{transcript}", transcript); } -/** - * Sends the transcript echo back to the originating chat. - * Best-effort: logs on failure, never throws. - */ +/** Sends a best-effort transcript echo back to the originating deliverable chat. */ export async function sendTranscriptEcho(params: { ctx: MsgContext; cfg: OpenClawConfig; diff --git a/src/media-understanding/entry-capabilities.ts b/src/media-understanding/entry-capabilities.ts index 6349b5568d16..cca37a613bad 100644 --- a/src/media-understanding/entry-capabilities.ts +++ b/src/media-understanding/entry-capabilities.ts @@ -15,6 +15,7 @@ function resolveEntryType(entry: MediaUnderstandingModelConfig): "provider" | "c return entry.type ?? (entry.command ? "cli" : "provider"); } +/** Returns valid explicit capability tags from a media model entry. */ export function resolveConfiguredMediaEntryCapabilities( entry: MediaUnderstandingModelConfig, ): MediaUnderstandingCapability[] | undefined { @@ -25,6 +26,7 @@ export function resolveConfiguredMediaEntryCapabilities( return capabilities.length > 0 ? capabilities : undefined; } +/** Resolves the capability set for an entry, inferring shared provider entries from metadata. */ export function resolveEffectiveMediaEntryCapabilities(params: { entry: MediaUnderstandingModelConfig; source: "shared" | "capability"; @@ -47,6 +49,7 @@ export function resolveEffectiveMediaEntryCapabilities(params: { return params.providerRegistry.get(providerId)?.capabilities; } +/** Tests whether an entry should be considered for a requested media capability. */ export function matchesMediaEntryCapability(params: { entry: MediaUnderstandingModelConfig; source: "shared" | "capability"; diff --git a/src/media-understanding/errors.ts b/src/media-understanding/errors.ts index 3dd7a39cd581..5ce50bd2f45b 100644 --- a/src/media-understanding/errors.ts +++ b/src/media-understanding/errors.ts @@ -1 +1,2 @@ +// Core facade for shared media-understanding error classes and guards. export * from "../../packages/media-understanding-common/src/errors.js"; diff --git a/src/media-understanding/format.ts b/src/media-understanding/format.ts index 07b9eef2b172..f2a822c07874 100644 --- a/src/media-understanding/format.ts +++ b/src/media-understanding/format.ts @@ -1 +1,2 @@ +// Core facade for shared media-understanding formatting helpers. export * from "../../packages/media-understanding-common/src/format.js"; diff --git a/src/media-understanding/fs.ts b/src/media-understanding/fs.ts index 0d11b5880abf..4d721a4d4c14 100644 --- a/src/media-understanding/fs.ts +++ b/src/media-understanding/fs.ts @@ -1,5 +1,6 @@ import { pathExists } from "../infra/fs-safe.js"; +/** Safely checks optional media file paths without throwing on empty input. */ export async function fileExists(filePath?: string | null): Promise { return filePath ? await pathExists(filePath) : false; } diff --git a/src/media-understanding/image-input-normalize.ts b/src/media-understanding/image-input-normalize.ts index 45c2046afd6f..cb08ea7a8a66 100644 --- a/src/media-understanding/image-input-normalize.ts +++ b/src/media-understanding/image-input-normalize.ts @@ -13,6 +13,7 @@ function isHeicInput(params: { mime?: string; fileName?: string }): boolean { return Boolean(fileName && HEIC_EXT_RE.test(fileName)); } +/** Normalizes image bytes before provider execution, converting HEIC/HEIF inputs to JPEG. */ export async function normalizeImageDescriptionInput(params: { buffer: Buffer; fileName?: string; @@ -23,6 +24,7 @@ export async function normalizeImageDescriptionInput(params: { return { buffer: params.buffer, mime: params.mime }; } const sourceMime = normalizeMimeType(params.mime) ?? "image/heic"; + // Reuse input-file extraction so HEIC conversion follows the same MIME and size guards. const image = await extractImageContentFromSource( { type: "base64", diff --git a/src/media-understanding/image-runtime.ts b/src/media-understanding/image-runtime.ts index b0c5c82c8f6e..9bd5393083a1 100644 --- a/src/media-understanding/image-runtime.ts +++ b/src/media-understanding/image-runtime.ts @@ -1,15 +1,20 @@ import { createLazyRuntimeMethodBinder, createLazyRuntimeModule } from "../shared/lazy-runtime.js"; +// Lazy image-runtime facade; avoids loading provider code until image understanding is used. const loadImageRuntime = createLazyRuntimeModule(() => import("./image.js")); const bindImageRuntime = createLazyRuntimeMethodBinder(loadImageRuntime); +/** Describes one image through the configured media runtime. */ export const describeImageWithModel = bindImageRuntime((runtime) => runtime.describeImageWithModel); +/** Describes multiple images through the configured media runtime. */ export const describeImagesWithModel = bindImageRuntime( (runtime) => runtime.describeImagesWithModel, ); +/** Describes one image after applying the runtime payload transform. */ export const describeImageWithModelPayloadTransform = bindImageRuntime( (runtime) => runtime.describeImageWithModelPayloadTransform, ); +/** Describes multiple images after applying the runtime payload transform. */ export const describeImagesWithModelPayloadTransform = bindImageRuntime( (runtime) => runtime.describeImagesWithModelPayloadTransform, ); diff --git a/src/media-understanding/manifest-metadata.ts b/src/media-understanding/manifest-metadata.ts index 48bfe85863f6..c29bc3d3486c 100644 --- a/src/media-understanding/manifest-metadata.ts +++ b/src/media-understanding/manifest-metadata.ts @@ -3,6 +3,7 @@ import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligi import { normalizeMediaProviderId } from "./provider-id.js"; import type { MediaUnderstandingProvider } from "./types.js"; +/** Builds a media provider registry from trusted manifest metadata without loading plugin code. */ export function buildMediaUnderstandingManifestMetadataRegistry( cfg?: OpenClawConfig, workspaceDir?: string, @@ -14,6 +15,7 @@ export function buildMediaUnderstandingManifestMetadataRegistry( ...(workspaceDir ? { workspaceDir } : {}), }); for (const plugin of snapshot.plugins) { + // Metadata only counts when the manifest also declares the provider contract. const declaredProviders = new Set( (plugin.contracts?.mediaUnderstandingProviders ?? []).map((providerId) => normalizeMediaProviderId(providerId), diff --git a/src/media-understanding/openai-compatible-audio.ts b/src/media-understanding/openai-compatible-audio.ts index 0bb9e678a8f3..55fbe8c29376 100644 --- a/src/media-understanding/openai-compatible-audio.ts +++ b/src/media-understanding/openai-compatible-audio.ts @@ -14,16 +14,19 @@ type OpenAiCompatibleAudioParams = AudioTranscriptionRequest & { provider?: string; }; +// Shared implementation for OpenAI-style /audio/transcriptions providers. function resolveModel(model: string | undefined, fallback: string): string { const trimmed = model?.trim(); return trimmed || fallback; } +/** Sends an OpenAI-compatible audio transcription request and returns validated text output. */ export async function transcribeOpenAiCompatibleAudio( params: OpenAiCompatibleAudioParams, ): Promise { const fetchFn = params.fetchFn ?? fetch; const apiKey = params.auth?.kind === "api-key" ? params.auth.apiKey : params.apiKey; + // Explicit auth:none suppresses bearer headers even if legacy apiKey params are present. const defaultHeaders = params.auth?.kind === "none" || !apiKey ? undefined @@ -45,6 +48,7 @@ export async function transcribeOpenAiCompatibleAudio( const url = `${baseUrl}/audio/transcriptions`; const model = resolveModel(params.model, params.defaultModel); + // Keep multipart construction centralized so provider tests cover filename and MIME behavior. const form = buildAudioTranscriptionFormData({ buffer: params.buffer, fileName: params.fileName, diff --git a/src/media-understanding/openai-compatible-video.ts b/src/media-understanding/openai-compatible-video.ts index 392b02b7391b..57a3e0c8d948 100644 --- a/src/media-understanding/openai-compatible-video.ts +++ b/src/media-understanding/openai-compatible-video.ts @@ -1 +1,2 @@ +// Core facade for shared OpenAI-compatible video request helpers. export * from "../../packages/media-understanding-common/src/openai-compatible-video.js"; diff --git a/src/media-understanding/output-extract.ts b/src/media-understanding/output-extract.ts index 6ae5da38a65e..a5110be3faa0 100644 --- a/src/media-understanding/output-extract.ts +++ b/src/media-understanding/output-extract.ts @@ -1 +1,2 @@ +// Core facade for shared media-understanding output extraction helpers. export * from "../../packages/media-understanding-common/src/output-extract.js"; diff --git a/src/media-understanding/provider-capability-registry.ts b/src/media-understanding/provider-capability-registry.ts index 1cd07601d82f..db27d9697dc3 100644 --- a/src/media-understanding/provider-capability-registry.ts +++ b/src/media-understanding/provider-capability-registry.ts @@ -15,6 +15,7 @@ function mergeProviderCapabilities( }); } +/** Builds provider capability metadata used to filter shared media model entries. */ export function buildMediaUnderstandingCapabilityRegistry( cfg?: OpenClawConfig, ): MediaUnderstandingCapabilityRegistry { @@ -28,6 +29,7 @@ export function buildMediaUnderstandingCapabilityRegistry( } for (const normalizedKey of resolveImageCapableConfigProviderIds(cfg)) { + // Plugin declarations own provider capability truth; config auto-registration only fills gaps. if (!registry.has(normalizedKey)) { mergeProviderCapabilities(registry, { id: normalizedKey, diff --git a/src/media-understanding/provider-id.ts b/src/media-understanding/provider-id.ts index 9f40c5fe1b47..1b47154b9133 100644 --- a/src/media-understanding/provider-id.ts +++ b/src/media-understanding/provider-id.ts @@ -1 +1,2 @@ +// Core facade for shared media provider id normalization. export * from "../../packages/media-understanding-common/src/provider-id.js"; diff --git a/src/media-understanding/provider-registry.ts b/src/media-understanding/provider-registry.ts index 3bff00978320..c32577cd770c 100644 --- a/src/media-understanding/provider-registry.ts +++ b/src/media-understanding/provider-registry.ts @@ -44,6 +44,7 @@ function hydrateModelBackedMediaProvider( export { normalizeMediaExecutionProviderId, normalizeMediaProviderId } from "./provider-id.js"; +/** Builds the media-understanding provider registry from plugin capabilities and config providers. */ export function buildMediaUnderstandingRegistry( overrides?: Record, cfg?: OpenClawConfig, @@ -74,6 +75,7 @@ export function buildMediaUnderstandingRegistry( return registry; } +/** Looks up a media-understanding provider using the same id normalization as registry builds. */ export function getMediaUnderstandingProvider( id: string, registry: Map, diff --git a/src/media-understanding/provider-supports.ts b/src/media-understanding/provider-supports.ts index 76806301beb1..13ede1682e27 100644 --- a/src/media-understanding/provider-supports.ts +++ b/src/media-understanding/provider-supports.ts @@ -1 +1,2 @@ +// Core facade for shared provider capability support checks. export * from "../../packages/media-understanding-common/src/provider-supports.js"; diff --git a/src/media-understanding/resolve.ts b/src/media-understanding/resolve.ts index 340262a01b75..071017ccef6e 100644 --- a/src/media-understanding/resolve.ts +++ b/src/media-understanding/resolve.ts @@ -21,9 +21,11 @@ import { normalizeMediaProviderId } from "./provider-id.js"; import { normalizeMediaUnderstandingChatType, resolveMediaUnderstandingScope } from "./scope.js"; import type { MediaUnderstandingCapability } from "./types.js"; +/** Default per-provider media-understanding runtime timeout in milliseconds. */ export const DEFAULT_MEDIA_RUNTIME_TIMEOUT_MS = 30_000; const MIN_MEDIA_TIMEOUT_MS = 1000; +/** Converts configured timeout seconds into a timer-safe millisecond deadline. */ export function resolveTimeoutMs(seconds: number | undefined, fallbackSeconds: number): number { const value = typeof seconds === "number" && Number.isFinite(seconds) ? seconds : fallbackSeconds; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { @@ -37,10 +39,12 @@ export function resolveTimeoutMs(seconds: number | undefined, fallbackSeconds: n ); } +/** Clamps an already-millisecond runtime timeout to the shared timer bounds. */ export function resolveMediaRuntimeTimeoutMs(timeoutMs: number | undefined): number { return resolveTimerTimeoutMs(timeoutMs, DEFAULT_MEDIA_RUNTIME_TIMEOUT_MS); } +/** Resolves the provider prompt and appends length guidance for non-audio outputs. */ export function resolvePrompt( capability: MediaUnderstandingCapability, prompt?: string, @@ -53,6 +57,7 @@ export function resolvePrompt( return `${base} Respond in at most ${maxChars} characters.`; } +/** Resolves the effective max response characters for a model entry and capability. */ export function resolveMaxChars(params: { capability: MediaUnderstandingCapability; entry: MediaUnderstandingModelConfig; @@ -68,6 +73,7 @@ export function resolveMaxChars(params: { return DEFAULT_MAX_CHARS_BY_CAPABILITY[capability]; } +/** Resolves the effective input byte cap for a model entry and capability. */ export function resolveMaxBytes(params: { capability: MediaUnderstandingCapability; entry: MediaUnderstandingModelConfig; @@ -84,6 +90,7 @@ export function resolveMaxBytes(params: { return DEFAULT_MAX_BYTES[params.capability]; } +/** Maps the message context to an allow/deny decision for configured media scope rules. */ export function resolveScopeDecision(params: { scope?: MediaUnderstandingScopeConfig; ctx: MsgContext; @@ -96,6 +103,7 @@ export function resolveScopeDecision(params: { }); } +/** Resolves configured model entries that can handle the requested media capability. */ export function resolveModelEntries(params: { cfg: OpenClawConfig; capability: MediaUnderstandingCapability; @@ -135,6 +143,7 @@ export function resolveModelEntries(params: { .map(({ entry }) => entry); } +/** Resolves the bounded media-understanding task concurrency from config. */ export function resolveConcurrency(cfg: OpenClawConfig): number { const configured = cfg.tools?.media?.concurrency; if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { @@ -143,6 +152,7 @@ export function resolveConcurrency(cfg: OpenClawConfig): number { return DEFAULT_MEDIA_CONCURRENCY; } +/** Adds the active chat model as a provider fallback when enabled media has no explicit entries. */ export function resolveEntriesWithActiveFallback(params: { cfg: OpenClawConfig; capability: MediaUnderstandingCapability; diff --git a/src/media-understanding/runner.attachments.ts b/src/media-understanding/runner.attachments.ts index b64804b8cfcc..c4b90921ecf0 100644 --- a/src/media-understanding/runner.attachments.ts +++ b/src/media-understanding/runner.attachments.ts @@ -6,10 +6,12 @@ import { } from "./attachments.js"; import type { MediaAttachment } from "./types.js"; +/** Normalizes message context media fields for the media-understanding runner. */ export function normalizeMediaAttachments(ctx: MsgContext): MediaAttachment[] { return normalizeAttachments(ctx); } +/** Creates the lazy attachment cache used by image, audio, video, and document providers. */ export function createMediaAttachmentCache( attachments: MediaAttachment[], options?: MediaAttachmentCacheOptions, diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 33cbeb056637..cb157d3b22a7 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -364,6 +364,7 @@ function resolveProviderQuery(params: { return Object.keys(query).length > 0 ? query : undefined; } +/** Builds the normalized decision record for one provider or CLI model attempt. */ export function buildModelDecision(params: { entry: MediaUnderstandingModelConfig; entryType: "provider" | "cli"; @@ -577,6 +578,7 @@ async function resolveProviderExecutionContext(params: { return { auth, baseUrl, headers, request }; } +/** Formats a compact operator-facing summary of a media-understanding decision. */ export function formatDecisionSummary(decision: MediaUnderstandingDecision): string { const attachments = Array.isArray(decision.attachments) ? decision.attachments : []; const total = attachments.length; @@ -593,6 +595,7 @@ export function formatDecisionSummary(decision: MediaUnderstandingDecision): str return `${decision.capability}: ${decision.outcome}${countLabel}${viaLabel}${reasonLabel}`; } +/** Returns the first non-empty attempt reason, optionally filtered by outcome. */ export function findDecisionReason( decision: MediaUnderstandingDecision, outcome?: MediaUnderstandingModelDecision["outcome"], @@ -613,6 +616,7 @@ export function findDecisionReason( return undefined; } +/** Trims provider/runtime error prefixes into a stable human-readable reason. */ export function normalizeDecisionReason(reason?: string): string | undefined { const trimmed = typeof reason === "string" ? reason.trim() : ""; if (!trimmed) { @@ -622,6 +626,7 @@ export function normalizeDecisionReason(reason?: string): string | undefined { return normalized || undefined; } +/** Produces the short reason token used in status and decision summary output. */ export function summarizeDecisionReason(reason?: string): string | undefined { const normalized = normalizeDecisionReason(reason); if (!normalized) { @@ -640,6 +645,7 @@ function assertMinAudioSize(params: { size: number; attachmentIndex: number }): ); } +/** Executes one provider-backed media-understanding entry for one attachment. */ export async function runProviderEntry(params: { capability: MediaUnderstandingCapability; entry: MediaUnderstandingModelConfig; @@ -860,6 +866,7 @@ export async function runProviderEntry(params: { }; } +/** Executes one CLI-backed media-understanding entry for one attachment. */ export async function runCliEntry(params: { capability: MediaUnderstandingCapability; entry: MediaUnderstandingModelConfig; diff --git a/src/media-understanding/runner.test-mocks.ts b/src/media-understanding/runner.test-mocks.ts index 7ff1b702e6e9..b6a784869541 100644 --- a/src/media-understanding/runner.test-mocks.ts +++ b/src/media-understanding/runner.test-mocks.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; +/** Builds the auth resolver mock module used by media runner tests. */ export function createAvailableModelAuthMockModule() { class ProviderAuthError extends Error { constructor( @@ -12,6 +13,7 @@ export function createAvailableModelAuthMockModule() { } } + // Keep the mock shape aligned with available-model-auth runtime imports. return { hasAvailableAuthForProvider: vi.fn(() => true), resolveApiKeyForProvider: vi.fn(async () => ({ @@ -37,6 +39,7 @@ export function createAvailableModelAuthMockModule() { }; } +/** Builds a plugin capability provider mock with no runtime providers. */ export function createEmptyCapabilityProviderMockModule() { return { resolvePluginCapabilityProviders: () => [], diff --git a/src/media-understanding/runner.test-utils.ts b/src/media-understanding/runner.test-utils.ts index 46722b535e69..391f7e64edb2 100644 --- a/src/media-understanding/runner.test-utils.ts +++ b/src/media-understanding/runner.test-utils.ts @@ -5,12 +5,14 @@ import { withEnvAsync } from "../test-utils/env.js"; import { MIN_AUDIO_FILE_BYTES } from "./defaults.constants.js"; import { createMediaAttachmentCache, normalizeMediaAttachments } from "./runner.attachments.js"; +// Temp-file fixtures for media runner tests; keep cache roots scoped to generated files. type MediaFixtureParams = { ctx: { MediaPath: string; MediaType: string }; media: ReturnType; cache: ReturnType; }; +/** Creates a temporary media file, cache, and normalized context for a test callback. */ export async function withMediaFixture( params: { filePrefix: string; @@ -33,6 +35,7 @@ export async function withMediaFixture( }); try { + // Avoid accidentally finding host audio/video tools during unit tests. await withEnvAsync({ PATH: "" }, async () => { await run({ ctx, media, cache }); }); @@ -42,6 +45,7 @@ export async function withMediaFixture( } } +/** Creates a safe WAV fixture above the minimum audio-byte threshold. */ export async function withAudioFixture( filePrefix: string, run: (params: MediaFixtureParams) => Promise, @@ -57,12 +61,14 @@ export async function withAudioFixture( ); } +/** Allocates a deterministic audio buffer large enough to skip tiny-file guards. */ export function createSafeAudioFixtureBuffer(size?: number, fill = 0xab): Buffer { const minSafeSize = MIN_AUDIO_FILE_BYTES + 1; const finalSize = Math.max(size ?? minSafeSize, minSafeSize); return Buffer.alloc(finalSize, fill); } +/** Creates a small MP4-labeled fixture for video runner tests. */ export async function withVideoFixture( filePrefix: string, run: (params: MediaFixtureParams) => Promise, diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts index 2730f7276254..ac7d975df9ae 100644 --- a/src/media-understanding/runtime.ts +++ b/src/media-understanding/runtime.ts @@ -63,6 +63,8 @@ function buildFileContext(params: { chatType?: string; }; }) { + // Runtime file calls reuse message-context media plumbing so scope, local roots, and + // remote URL handling stay identical to normal channel-triggered media understanding. const scopeFields = { ...(params.scopeContext?.sessionKey ? { SessionKey: params.scopeContext.sessionKey } : {}), ...(params.scopeContext?.channel @@ -125,6 +127,7 @@ function hasStructuredImageInput(input: ExtractStructuredWithModelParams["input" return input.some((entry) => entry.type === "image"); } +/** Runs media understanding for one local file or remote URL and returns the first matching output. */ export async function runMediaUnderstandingFile( params: RunMediaUnderstandingFileParams, ): Promise { @@ -226,12 +229,14 @@ export async function runMediaUnderstandingFile( } } +/** Describes one image file or URL through the configured image-understanding pipeline. */ export async function describeImageFile( params: DescribeImageFileParams, ): Promise { return await runMediaUnderstandingFile({ ...params, capability: "image" }); } +/** Describes one image with an explicit provider/model, bypassing configured media model selection. */ export async function describeImageFileWithModel(params: DescribeImageFileWithModelParams) { const timeoutMs = resolveMediaRuntimeTimeoutMs(params.timeoutMs); const providerRegistry = buildProviderRegistry(undefined, params.cfg); @@ -304,6 +309,7 @@ async function readImageDescriptionInput(params: { } } +/** Runs provider-backed structured extraction for multimodal text/image input. */ export async function extractStructuredWithModel(params: ExtractStructuredWithModelParams) { const timeoutMs = resolveMediaRuntimeTimeoutMs(params.timeoutMs); if (!hasStructuredImageInput(params.input)) { @@ -333,12 +339,14 @@ export async function extractStructuredWithModel(params: ExtractStructuredWithMo }); } +/** Describes one video file or URL through the configured video-understanding pipeline. */ export async function describeVideoFile( params: DescribeVideoFileParams, ): Promise { return await runMediaUnderstandingFile({ ...params, capability: "video" }); } +/** Transcribes one audio file or URL through the configured audio-understanding pipeline. */ export async function transcribeAudioFile( params: TranscribeAudioFileParams, ): Promise { diff --git a/src/media-understanding/scope.ts b/src/media-understanding/scope.ts index 72e07dcb13e8..7b9fbfc3028e 100644 --- a/src/media-understanding/scope.ts +++ b/src/media-understanding/scope.ts @@ -15,10 +15,12 @@ function normalizeDecision(value?: string | null): MediaUnderstandingScopeDecisi return undefined; } +/** Normalizes channel/direct chat type aliases used by media-understanding scope rules. */ export function normalizeMediaUnderstandingChatType(raw?: string | null): string | undefined { return normalizeChatType(raw ?? undefined); } +/** Evaluates ordered media-understanding scope rules against channel, chat type, and session key. */ export function resolveMediaUnderstandingScope(params: { scope?: MediaUnderstandingScopeConfig; sessionKey?: string; diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index 1d7d60b26498..cf57b0fdfff7 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -43,6 +43,7 @@ const MAX_ERROR_CHARS = 300; const MAX_ERROR_RESPONSE_BYTES = 4096; const MAX_AUDIT_CONTEXT_CHARS = 80; +/** Resolves the multipart upload filename, mapping AAC inputs to provider-friendly `.m4a`. */ export function resolveAudioTranscriptionUploadFileName(fileName?: string, mime?: string): string { const trimmed = fileName?.trim(); const baseName = trimmed ? path.basename(trimmed) : "audio"; @@ -57,6 +58,7 @@ export function resolveAudioTranscriptionUploadFileName(fileName?: string, mime? return baseName; } +/** Builds provider-compatible multipart form data for audio transcription requests. */ export function buildAudioTranscriptionFormData(params: { buffer: Buffer; fileName?: string; @@ -78,12 +80,14 @@ export function buildAudioTranscriptionFormData(params: { return form; } +/** Shared absolute deadline state for long-running provider operations and polling loops. */ export type ProviderOperationDeadline = { deadlineAtMs?: number; label: string; timeoutMs?: number; }; +/** Static or per-call timeout resolver used by provider HTTP helpers. */ export type ProviderOperationTimeoutMs = number | (() => number); type GuardedProviderRequestParams = { @@ -100,6 +104,7 @@ type GuardedProviderRequestParams = { mode?: GuardedFetchMode; }; +/** Creates a timer-safe absolute operation deadline from an optional total timeout. */ export function createProviderOperationDeadline(params: { timeoutMs?: number; label: string; @@ -121,6 +126,7 @@ export function createProviderOperationDeadline(params: { }; } +/** Resolves a per-request timeout without exceeding the remaining operation deadline. */ export function resolveProviderOperationTimeoutMs(params: { deadline: ProviderOperationDeadline; defaultTimeoutMs: number; @@ -137,6 +143,7 @@ export function resolveProviderOperationTimeoutMs(params: { return Math.max(1, Math.min(defaultTimeoutMs, remainingMs)); } +/** Returns a lazy timeout resolver for code paths that retry or poll multiple HTTP calls. */ export function createProviderOperationTimeoutResolver(params: { deadline: ProviderOperationDeadline; defaultTimeoutMs: number; @@ -144,6 +151,7 @@ export function createProviderOperationTimeoutResolver(params: { return () => resolveProviderOperationTimeoutMs(params); } +/** Waits for the next poll interval while respecting the total provider operation deadline. */ export async function waitProviderOperationPollInterval(params: { deadline: ProviderOperationDeadline; pollIntervalMs: number; diff --git a/src/media-understanding/video.ts b/src/media-understanding/video.ts index c074ec5648dc..792be9f3d8d5 100644 --- a/src/media-understanding/video.ts +++ b/src/media-understanding/video.ts @@ -1 +1,2 @@ +// Core facade for shared media-understanding video helpers. export * from "../../packages/media-understanding-common/src/video.js"; diff --git a/src/media/audio-transcode.ts b/src/media/audio-transcode.ts index 11a9baec1f02..9d9dd671b614 100644 --- a/src/media/audio-transcode.ts +++ b/src/media/audio-transcode.ts @@ -42,6 +42,7 @@ function normalizeOutputFileName(value?: string): string { return DEFAULT_OUTPUT_FILE_NAME; } +/** Transcodes arbitrary audio input into mono Opus using a scoped temp workspace. */ export async function transcodeAudioBufferToOpus(params: { audioBuffer: Buffer; inputExtension?: string; @@ -100,6 +101,7 @@ export async function transcodeAudioBufferToOpus(params: { ); } +/** Outcome for lightweight container transcodes that may be unsupported or intentionally skipped. */ export type AudioContainerTranscodeOutcome = | { ok: true; buffer: Buffer } | { @@ -113,6 +115,7 @@ export type AudioContainerTranscodeOutcome = detail?: string; }; +/** Transcodes known audio container pairs, currently using macOS afconvert recipes where needed. */ export async function transcodeAudioBuffer(params: { audioBuffer: Buffer; sourceExtension: string; @@ -135,6 +138,7 @@ export async function transcodeAudioBuffer(params: { return { ok: false, reason: "platform-unsupported" }; } + // afconvert is macOS-only and writes native Messages-compatible voice containers. const tmp = tempWorkspaceSync({ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "tts-transcode-", diff --git a/src/media/audio.ts b/src/media/audio.ts index 54201d4a5d30..72dda1815d42 100644 --- a/src/media/audio.ts +++ b/src/media/audio.ts @@ -1,11 +1,10 @@ import { getFileExtension, normalizeMimeType } from "@openclaw/media-core/mime"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** File extensions accepted by channel voice-message upload paths. */ export const VOICE_MESSAGE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus", ".mp3", ".m4a"]); -/** - * MIME types compatible with voice messages. - */ +/** MIME types compatible with voice-message upload paths. */ export const VOICE_MESSAGE_MIME_TYPES = new Set([ "audio/ogg", "audio/opus", @@ -16,6 +15,7 @@ export const VOICE_MESSAGE_MIME_TYPES = new Set([ "audio/m4a", ]); +/** Checks whether MIME type or filename is compatible with voice-message delivery. */ export function isVoiceMessageCompatibleAudio(opts: { contentType?: string | null; fileName?: string | null; @@ -35,6 +35,7 @@ export function isVoiceMessageCompatibleAudio(opts: { return VOICE_MESSAGE_AUDIO_EXTENSIONS.has(ext); } +/** Backward-compatible alias for voice-message audio compatibility checks. */ export function isVoiceCompatibleAudio(opts: { contentType?: string | null; fileName?: string | null; diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts index 25037f340560..334efbf22a1e 100644 --- a/src/media/channel-inbound-roots.ts +++ b/src/media/channel-inbound-roots.ts @@ -27,6 +27,7 @@ function loadChannelMediaContractApi( } try { + // Media-root resolution must stay a narrow artifact load, not full channel bootstrap. const loaded = loadBundledPluginPublicArtifactModuleSync({ dirName: channelId, artifactBasename: "media-contract-api.js", @@ -62,6 +63,7 @@ function findChannelMediaContractApi( return loadChannelMediaContractApi(normalized, resolver); } +/** Resolves local inbound attachment roots from the channel named in a message context. */ export function resolveChannelInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; @@ -73,6 +75,7 @@ export function resolveChannelInboundAttachmentRoots(params: { }); } +/** Resolves local inbound attachment roots for callers that already know the channel id. */ export function resolveChannelInboundAttachmentRootsForChannel(params: { cfg: OpenClawConfig; channelId?: string | null; @@ -91,6 +94,7 @@ export function resolveChannelInboundAttachmentRootsForChannel(params: { return undefined; } +/** Resolves remote staging roots for inbound channel attachments without loading full channel code. */ export function resolveChannelRemoteInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; diff --git a/src/media/configured-max-bytes.ts b/src/media/configured-max-bytes.ts index ccf480aefa12..c0fb8df92927 100644 --- a/src/media/configured-max-bytes.ts +++ b/src/media/configured-max-bytes.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; const MB = 1024 * 1024; +/** Resolves the global generated-media byte cap from the user-facing MB config value. */ export function resolveConfiguredMediaMaxBytes(cfg?: OpenClawConfig): number | undefined { const configured = cfg?.agents?.defaults?.mediaMaxMb; if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { @@ -11,10 +12,12 @@ export function resolveConfiguredMediaMaxBytes(cfg?: OpenClawConfig): number | u return undefined; } +/** Returns the configured media cap, falling back to the media-core per-kind default. */ export function resolveGeneratedMediaMaxBytes(cfg: OpenClawConfig | undefined, kind: MediaKind) { return resolveConfiguredMediaMaxBytes(cfg) ?? maxBytesForKind(kind); } +/** Reads channel/account media caps from raw channel config without requiring typed account schemas. */ export function resolveChannelAccountMediaMaxMb(params: { cfg: OpenClawConfig; channel?: string | null; diff --git a/src/media/document-extractors.runtime.ts b/src/media/document-extractors.runtime.ts index fd3cc27e79bb..294e2dc9fab4 100644 --- a/src/media/document-extractors.runtime.ts +++ b/src/media/document-extractors.runtime.ts @@ -11,6 +11,7 @@ const documentExtractorLoader = createConfigScopedPromiseLoader((config?: OpenCl resolvePluginDocumentExtractors(config ? { config } : undefined), ); +/** Runs the first matching plugin document extractor and tags successful results with its extractor id. */ export async function extractDocumentContent( params: DocumentExtractionRequest & { config?: OpenClawConfig; @@ -18,6 +19,7 @@ export async function extractDocumentContent( ): Promise<(DocumentExtractionResult & { extractor: string }) | null> { const mimeType = normalizeLowercaseStringOrEmpty(params.mimeType); const extractors = await documentExtractorLoader.load(params.config); + // Keep config and loader-only fields out of plugin calls; extractors receive the SDK request shape. const request: DocumentExtractionRequest = { buffer: params.buffer, mimeType: params.mimeType, diff --git a/src/media/fetch.ts b/src/media/fetch.ts index e504477087c1..ddcd9b2804ba 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -19,22 +19,28 @@ import { redactSensitiveText } from "../logging/redact.js"; import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "./store.js"; +/** Default remote media fetch cap shared by buffer reads and store writes. */ export const DEFAULT_FETCH_MEDIA_MAX_BYTES = MAX_DOCUMENT_BYTES; +/** Remote media bytes plus metadata before they are persisted to the media store. */ type FetchMediaResult = { buffer: Buffer; contentType?: string; fileName?: string; }; +/** Saved media record enriched with the best remote filename candidate. */ export type SavedRemoteMedia = SavedMedia & { fileName?: string; }; +/** Closed error classes callers can use for retry and diagnostic policy. */ export type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed"; +/** Retry policy applied around the complete guarded fetch and body read/save operation. */ export type MediaFetchRetryOptions = RetryOptions; +/** Structured fetch error used for retry decisions and caller-facing diagnostics. */ export class MediaFetchError extends Error { readonly code: MediaFetchErrorCode; readonly status?: number; @@ -51,8 +57,10 @@ export class MediaFetchError extends Error { } } +/** Fetch-compatible injection point used by tests and guarded network callers. */ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +/** Alternate dispatcher/lookup pair tried inside a single guarded fetch attempt. */ export type FetchDispatcherAttempt = { dispatcherPolicy?: PinnedDispatcherPolicy; lookupFn?: LookupFn; @@ -86,6 +94,7 @@ type FetchMediaOptions = { trustExplicitProxyDns?: boolean; }; +/** Options for validating and saving an existing Response body into the media store. */ export type SaveResponseMediaOptions = { sourceUrl?: string; filePathHint?: string; @@ -96,6 +105,7 @@ export type SaveResponseMediaOptions = { originalFilename?: string; }; +/** Options for guarded URL fetches that are saved directly into the media store. */ export type SaveRemoteMediaOptions = FetchMediaOptions & { fallbackContentType?: string; subdir?: string; @@ -186,6 +196,7 @@ async function fetchGuardedMediaResponse( } = options; const sourceUrl = redactMediaUrl(url); + // Dispatcher attempts are fallback routes inside one logical guarded fetch operation. const attempts = dispatcherAttempts && dispatcherAttempts.length > 0 ? dispatcherAttempts @@ -371,6 +382,8 @@ function resolveResponseContentType(params: { } const headerContentType = params.headerContentType?.split(";")[0]?.trim().toLowerCase(); const fallbackContentType = params.fallbackContentType.split(";")[0]?.trim().toLowerCase(); + // Some platforms mislabel audio/video container uploads by top-level type. + // Preserve the caller hint when only that top-level prefix differs. if ( headerContentType?.startsWith("video/") && fallbackContentType?.startsWith("audio/") && @@ -553,6 +566,7 @@ async function withMediaFetchRetry( }); } +/** Validates and saves a caller-provided response without performing a new fetch. */ export async function saveResponseMedia( res: Response, options: SaveResponseMediaOptions = {}, @@ -579,6 +593,7 @@ export async function saveResponseMedia( }); } +/** Fetches media through SSRF guards and saves the body into the media store. */ export async function saveRemoteMedia(options: SaveRemoteMediaOptions): Promise { return await withMediaFetchRetry(options, () => saveRemoteMediaOnce(options)); } @@ -611,6 +626,7 @@ async function saveRemoteMediaOnce(options: SaveRemoteMediaOptions): Promise { return await withMediaFetchRetry(options, () => readRemoteMediaBufferOnce(options)); } diff --git a/src/media/ffmpeg-exec.ts b/src/media/ffmpeg-exec.ts index 7b6452e6cba3..4a38a995f06c 100644 --- a/src/media/ffmpeg-exec.ts +++ b/src/media/ffmpeg-exec.ts @@ -10,6 +10,7 @@ import { const execFileAsync = promisify(execFile); +/** Process limits and optional stdin payload for ffmpeg/ffprobe helper calls. */ export type MediaExecOptions = { timeoutMs?: number; maxBufferBytes?: number; @@ -41,6 +42,7 @@ function requireSystemBin(name: string): string { return resolved; } +/** Resolves ffmpeg from trusted system paths before command execution. */ export function resolveFfmpegBin(): string { return requireSystemBin("ffmpeg"); } @@ -49,6 +51,7 @@ function isBrokenPipeError(error: Error): boolean { return (error as NodeJS.ErrnoException).code === "EPIPE"; } +/** Runs ffprobe with optional stdin input, ignoring benign stdin EPIPE after successful output. */ export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise { const execOptions = resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options); if (options?.input == null) { @@ -76,6 +79,7 @@ export async function runFfprobe(args: string[], options?: MediaExecOptions): Pr }); } +/** Runs ffmpeg with bounded timeout and buffer settings. */ export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise { const { stdout } = await execFileAsync( resolveFfmpegBin(), @@ -85,6 +89,7 @@ export async function runFfmpeg(args: string[], options?: MediaExecOptions): Pro return stdout.toString(); } +/** Splits ffprobe CSV-ish output into normalized lowercase fields. */ export function parseFfprobeCsvFields(stdout: string, maxFields: number): string[] { return stdout .trim() @@ -100,6 +105,7 @@ function parseFfprobeSampleRateHz(value: string | undefined): number | null { return Number.isSafeInteger(sampleRate) && sampleRate > 0 ? sampleRate : null; } +/** Parses codec and positive sample rate from compact ffprobe stream output. */ export function parseFfprobeCodecAndSampleRate(stdout: string): { codec: string | null; sampleRateHz: number | null; diff --git a/src/media/ffmpeg-limits.ts b/src/media/ffmpeg-limits.ts index 937345fdd3c1..930e010eebfa 100644 --- a/src/media/ffmpeg-limits.ts +++ b/src/media/ffmpeg-limits.ts @@ -1,4 +1,8 @@ +/** Shared stdout/stderr buffer cap for ffmpeg and ffprobe child processes. */ export const MEDIA_FFMPEG_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +/** Default ffprobe timeout for lightweight metadata probes. */ export const MEDIA_FFPROBE_TIMEOUT_MS = 10_000; +/** Default ffmpeg timeout for bounded media conversion work. */ export const MEDIA_FFMPEG_TIMEOUT_MS = 45_000; +/** Maximum audio duration accepted by ffmpeg-backed media flows. */ export const MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS = 20 * 60; diff --git a/src/media/file-context.ts b/src/media/file-context.ts index 649c727a6417..5dc03526a249 100644 --- a/src/media/file-context.ts +++ b/src/media/file-context.ts @@ -25,6 +25,7 @@ function sanitizeFileName(value: string | null | undefined, fallbackName: string return sanitizeUntrustedFileName(normalized, fallbackName); } +/** Renders sanitized attachment text as a model-visible file block without allowing file-tag injection. */ export function renderFileContextBlock(params: { filename?: string | null; fallbackName?: string; diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 9bcbff9370ee..e7cceddbdaa5 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -13,6 +13,7 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export type { ImageMetadata, ImageProbe }; +/** OpenClaw-facing image backend availability error, preserving the failed operation and causes. */ export class ImageProcessorUnavailableError extends Error { readonly code = "IMAGE_PROCESSOR_UNAVAILABLE"; readonly operation: string; @@ -28,6 +29,7 @@ export class ImageProcessorUnavailableError extends Error { } } +/** JPEG resize request passed through the media-runtime/plugin SDK surface. */ export type ResizeToJpegParams = { buffer: Buffer; maxSide: number; @@ -35,6 +37,7 @@ export type ResizeToJpegParams = { withoutEnlargement?: boolean; }; +/** PNG resize request passed through the media-runtime/plugin SDK surface. */ export type ResizeToPngParams = { buffer: Buffer; maxSide: number; @@ -42,9 +45,12 @@ export type ResizeToPngParams = { withoutEnlargement?: boolean; }; +/** Ordered JPEG quality ladder used when shrinking generated or attached images. */ export const IMAGE_REDUCE_QUALITY_STEPS = [85, 75, 65, 55, 45, 35] as const; +/** Shared input/output pixel cap for Rastermill-backed image operations. */ export const MAX_IMAGE_INPUT_PIXELS = 25_000_000; +/** Creates a Rastermill processor with OpenClaw temp-dir, pixel-limit, and command trust policy. */ export function createImageProcessor() { return createRastermill({ execution: "auto", @@ -61,10 +67,12 @@ export function createImageProcessor() { }); } +/** Detects either OpenClaw's wrapper error or Rastermill's native unavailable error. */ export function isImageProcessorUnavailableError(err: unknown): boolean { return err instanceof ImageProcessorUnavailableError || isRastermillUnavailableError(err); } +/** Builds a descending, de-duplicated max-side search grid for iterative image resizing. */ export function buildImageResizeSideGrid(maxSide: number, sideStart: number): number[] { return [sideStart, 1800, 1600, 1400, 1200, 1000, 800] .map((value) => Math.min(maxSide, value)) @@ -72,10 +80,12 @@ export function buildImageResizeSideGrid(maxSide: number, sideStart: number): nu .toSorted((a, b) => b - a); } +/** Reads dimensions from image header bytes without invoking a full image decode. */ export function readImageMetadataFromHeader(buffer: Buffer): ImageMetadata | null { return readRastermillImageMetadataFromHeader(buffer); } +/** Reads image probe data from header bytes without invoking a full image decode. */ export function readImageProbeFromHeader(buffer: Buffer): ImageProbe | null { return readRastermillImageProbeFromHeader(buffer); } @@ -87,11 +97,13 @@ function wrapRastermillUnavailable(operation: string, error: unknown): never { throw error; } +/** Fully probes image dimensions through Rastermill when header-only metadata is insufficient. */ export async function getImageMetadata(buffer: Buffer): Promise { const info = await createImageProcessor().probe(buffer); return info ? { width: info.width, height: info.height } : null; } +/** Normalizes EXIF orientation when possible while leaving bytes unchanged if the backend is unavailable. */ export async function normalizeExifOrientation(buffer: Buffer): Promise { try { const rastermill = createImageProcessor(); @@ -111,6 +123,7 @@ export async function normalizeExifOrientation(buffer: Buffer): Promise } } +/** Resizes or encodes image bytes as JPEG through the shared image processor. */ export async function resizeToJpeg(params: ResizeToJpegParams): Promise { try { return ( @@ -128,6 +141,7 @@ export async function resizeToJpeg(params: ResizeToJpegParams): Promise } } +/** Converts HEIC/HEIF-like image bytes into JPEG through the shared image processor. */ export async function convertHeicToJpeg(buffer: Buffer): Promise { try { return (await createImageProcessor().encode(buffer, { format: "jpeg" })).data; @@ -136,10 +150,12 @@ export async function convertHeicToJpeg(buffer: Buffer): Promise { } } +/** Detects alpha support using a full transparency probe, falling back to trusted header metadata. */ export async function hasAlphaChannel(buffer: Buffer): Promise { try { return (await createImageProcessor().transparency(buffer)).hasAlphaChannel; } catch (error) { + // Some callers only need the header-declared alpha bit; keep that usable when decode fails. const headerHasAlpha = readRastermillImageProbeFromHeader(buffer)?.hasAlpha === true; if (isRastermillUnavailableError(error)) { return headerHasAlpha; @@ -155,6 +171,7 @@ export async function hasAlphaChannel(buffer: Buffer): Promise { } } +/** Resizes or encodes image bytes as PNG through the shared image processor. */ export async function resizeToPng(params: ResizeToPngParams): Promise { try { return ( @@ -174,6 +191,7 @@ export async function resizeToPng(params: ResizeToPngParams): Promise { } } +/** Optimizes PNG bytes under a target size and returns the chosen search parameters. */ export async function optimizeImageToPng( buffer: Buffer, maxBytes: number, diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 519c33a0ee2e..f10ddc32165d 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -13,20 +13,24 @@ import { logWarn } from "../logger.js"; import { convertHeicToJpeg } from "./media-services.js"; import { extractPdfContent, type PdfExtractedImage } from "./pdf-extract.js"; +/** Image payload shape reused for extracted PDF images and normalized input images. */ export type InputImageContent = PdfExtractedImage; +/** Text/images extracted from an input_file source after MIME-specific processing. */ export type InputFileExtractResult = { filename: string; text?: string; images?: InputImageContent[]; }; +/** PDF extraction limits applied before model-visible input_file content is produced. */ export type InputPdfLimits = { maxPages: number; maxPixels: number; minTextChars: number; }; +/** Resolved input_file limits with normalized MIME allowlist and PDF sub-limits. */ export type InputFileLimits = { allowUrl: boolean; urlAllowlist?: string[]; @@ -38,6 +42,7 @@ export type InputFileLimits = { pdf: InputPdfLimits; }; +/** Optional config shape accepted by input_file limit resolution. */ export type InputFileLimitsConfig = { allowUrl?: boolean; allowedMimes?: string[]; @@ -52,6 +57,7 @@ export type InputFileLimitsConfig = { }; }; +/** Resolved input_image limits with normalized MIME allowlist and URL fetch controls. */ export type InputImageLimits = { allowUrl: boolean; urlAllowlist?: string[]; @@ -61,6 +67,7 @@ export type InputImageLimits = { timeoutMs: number; }; +/** Supported input_image source variants before base64 decoding or guarded URL fetch. */ export type InputImageSource = | { type: "base64"; @@ -73,6 +80,7 @@ export type InputImageSource = mediaType?: string; }; +/** Supported input_file source variants before text/PDF extraction. */ export type InputFileSource = | { type: "base64"; @@ -87,12 +95,14 @@ export type InputFileSource = filename?: string; }; +/** Guarded URL fetch result before final MIME allowlist validation. */ export type InputFetchResult = { buffer: Buffer; mimeType: string; contentType?: string; }; +/** Default MIME allowlist for input_image sources. */ export const DEFAULT_INPUT_IMAGE_MIMES = [ "image/jpeg", "image/png", @@ -101,6 +111,7 @@ export const DEFAULT_INPUT_IMAGE_MIMES = [ "image/heic", "image/heif", ]; +/** Default MIME allowlist for input_file text/PDF extraction. */ export const DEFAULT_INPUT_FILE_MIMES = [ "text/plain", "text/markdown", @@ -109,13 +120,21 @@ export const DEFAULT_INPUT_FILE_MIMES = [ "application/json", "application/pdf", ]; +/** Default decoded-byte cap for input_image payloads. */ export const DEFAULT_INPUT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +/** Default decoded-byte cap for input_file payloads. */ export const DEFAULT_INPUT_FILE_MAX_BYTES = 5 * 1024 * 1024; +/** Default maximum model-visible characters emitted from input_file text. */ export const DEFAULT_INPUT_FILE_MAX_CHARS = 60_000; +/** Default redirect cap for guarded input source URL fetches. */ export const DEFAULT_INPUT_MAX_REDIRECTS = 3; +/** Default timeout for guarded input source URL fetches. */ export const DEFAULT_INPUT_TIMEOUT_MS = 10_000; +/** Default PDF page cap for input_file extraction. */ export const DEFAULT_INPUT_PDF_MAX_PAGES = 4; +/** Default PDF raster pixel cap for extracted input_file images. */ export const DEFAULT_INPUT_PDF_MAX_PIXELS = 4_000_000; +/** Default text threshold before PDF extraction keeps text-only output. */ export const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200; const NORMALIZED_INPUT_IMAGE_MIME = "image/jpeg"; const HEIC_INPUT_IMAGE_MIMES = new Set(["image/heic", "image/heif"]); @@ -133,11 +152,13 @@ function rejectOversizedBase64Payload(params: { } } +/** Normalizes a MIME value by stripping parameters and lowercasing the media type. */ export function normalizeMimeType(value: string | undefined): string | undefined { const [raw] = value?.split(";") ?? []; return normalizeOptionalLowercaseString(raw); } +/** Parses a Content-Type header into normalized MIME and optional charset values. */ export function parseContentType(value: string | undefined): { mimeType?: string; charset?: string; @@ -153,11 +174,13 @@ export function parseContentType(value: string | undefined): { return { mimeType, charset }; } +/** Converts configured MIME lists into a normalized allowlist, using fallback defaults when empty. */ export function normalizeMimeList(values: string[] | undefined, fallback: string[]): Set { const input = values && values.length > 0 ? values : fallback; return new Set(input.flatMap((value) => normalizeMimeType(value) ?? [])); } +/** Resolves input_file extraction limits from partial config and stable defaults. */ export function resolveInputFileLimits(config?: InputFileLimitsConfig): InputFileLimits { return { allowUrl: config?.allowUrl ?? true, @@ -174,6 +197,7 @@ export function resolveInputFileLimits(config?: InputFileLimitsConfig): InputFil }; } +/** Fetches an input source URL through SSRF, redirect, timeout, and byte-limit guards. */ export async function fetchWithGuard(params: { url: string; maxBytes: number; @@ -279,6 +303,7 @@ async function normalizeInputImage(params: { }; } + // Normalize HEIC/HEIF to JPEG because downstream model and channel surfaces expect common images. const normalizedBuffer = await convertHeicToJpeg(params.buffer); if (normalizedBuffer.byteLength > params.limits.maxBytes) { throw new Error( @@ -306,6 +331,7 @@ async function resolveInputFileMime(params: { return sniffedMime; } +/** Extracts and normalizes an input_image source from base64 or guarded URL input. */ export async function extractImageContentFromSource( source: InputImageSource, limits: InputImageLimits, @@ -354,6 +380,7 @@ export async function extractImageContentFromSource( throw new Error(`Unsupported input_image source type: ${(source as { type: string }).type}`); } +/** Extracts model-visible text and images from an input_file source after MIME validation. */ export async function extractFileContentFromSource(params: { source: InputFileSource; limits: InputFileLimits; diff --git a/src/media/load-options.ts b/src/media/load-options.ts index 0804bc761da1..07ff56aac07c 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -1,5 +1,7 @@ +/** Host callback used to read an already-authorized outbound media file. */ export type OutboundMediaReadFile = (filePath: string) => Promise; +/** Host-provided file access used when a runtime can read outbound media from local disk. */ export type OutboundMediaAccess = { localRoots?: readonly string[]; readFile?: OutboundMediaReadFile; @@ -7,6 +9,7 @@ export type OutboundMediaAccess = { workspaceDir?: string; }; +/** Legacy and current knobs accepted by outbound media loaders before normalization. */ export type OutboundMediaLoadParams = { maxBytes?: number; mediaAccess?: OutboundMediaAccess; @@ -21,6 +24,7 @@ export type OutboundMediaLoadParams = { workspaceDir?: string; }; +/** Normalized outbound media loader options consumed by fetch/local media helpers. */ export type OutboundMediaLoadOptions = { maxBytes?: number; localRoots?: readonly string[] | "any"; @@ -35,6 +39,7 @@ export type OutboundMediaLoadOptions = { workspaceDir?: string; }; +/** Normalizes empty root lists while preserving the explicit all-roots opt-in sentinel. */ export function resolveOutboundMediaLocalRoots( mediaLocalRoots?: readonly string[] | "any", ): readonly string[] | "any" | undefined { @@ -44,6 +49,7 @@ export function resolveOutboundMediaLocalRoots( return mediaLocalRoots && mediaLocalRoots.length > 0 ? mediaLocalRoots : undefined; } +/** Collapses legacy read/root parameters into the current host media access shape. */ export function resolveOutboundMediaAccess( params: { mediaAccess?: OutboundMediaAccess; @@ -67,6 +73,7 @@ export function resolveOutboundMediaAccess( }; } +/** Builds the canonical media load options shared by outbound attachment paths. */ export function buildOutboundMediaLoadOptions( params: OutboundMediaLoadParams = {}, ): OutboundMediaLoadOptions { @@ -80,6 +87,7 @@ export function buildOutboundMediaLoadOptions( const readFile = mediaAccess?.readFile ?? params.mediaReadFile; const localRoots = mediaAccess?.localRoots ?? explicitLocalRoots; if (readFile) { + // Host reads must declare a root boundary so local file access cannot silently widen. if (!localRoots) { throw new Error( 'Host media read requires explicit localRoots. Pass mediaAccess.localRoots or opt in with localRoots: "any".', diff --git a/src/media/local-media-access.ts b/src/media/local-media-access.ts index 5abc0e9dc0ac..60fe00cdd783 100644 --- a/src/media/local-media-access.ts +++ b/src/media/local-media-access.ts @@ -6,6 +6,7 @@ import { isPathInside } from "../infra/path-guards.js"; import { getDefaultMediaLocalRoots } from "./local-roots.js"; import { resolveInboundMediaReference } from "./media-reference.js"; +/** Machine-readable reasons local media path validation can fail. */ export type LocalMediaAccessErrorCode = | "path-not-allowed" | "invalid-root" @@ -16,6 +17,7 @@ export type LocalMediaAccessErrorCode = | "invalid-path" | "not-file"; +/** Error raised when a local media path escapes the configured allowlist. */ export class LocalMediaAccessError extends Error { code: LocalMediaAccessErrorCode; @@ -26,10 +28,12 @@ export class LocalMediaAccessError extends Error { } } +/** Returns the default root allowlist for local media reads. */ export function getDefaultLocalRoots(): readonly string[] { return getDefaultMediaLocalRoots(); } +/** Verifies that a local media path is managed inbound media or lives under allowed roots. */ export async function assertLocalMediaAllowed( mediaPath: string, localRoots: readonly string[] | "any" | undefined, @@ -64,6 +68,7 @@ export async function assertLocalMediaAllowed( } if (localRoots === undefined) { + // Unscoped default roots include workspace, but not sibling workspace-* agent sandboxes. const workspaceRoot = roots.find((root) => path.basename(root) === "workspace"); if (workspaceRoot) { const stateDir = path.dirname(workspaceRoot); diff --git a/src/media/local-roots.ts b/src/media/local-roots.ts index 60f4d525f736..a49bb96ab320 100644 --- a/src/media/local-roots.ts +++ b/src/media/local-roots.ts @@ -23,11 +23,14 @@ const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/; function resolveCachedPreferredTmpDir(): string { if (!cachedPreferredTmpDir) { + // Temp-root discovery can hit platform/env state; keep one process-local + // snapshot so media root lists stay stable during a run. cachedPreferredTmpDir = resolvePreferredOpenClawTmpDir(); } return cachedPreferredTmpDir; } +/** Builds the baseline local media root allowlist from state/config directories. */ export function buildMediaLocalRoots( stateDir: string, configDir: string, @@ -48,10 +51,12 @@ export function buildMediaLocalRoots( ); } +/** Returns the process default roots where local media reads may resolve generated/cache files. */ export function getDefaultMediaLocalRoots(): readonly string[] { return buildMediaLocalRoots(resolveStateDir(), resolveConfigDir()); } +/** Adds the active agent workspace to the default media roots without exposing all agent state. */ export function getAgentScopedMediaLocalRoots( cfg: OpenClawConfig, agentId?: string, @@ -93,6 +98,7 @@ function resolveLocalMediaPath(source: string): string | undefined { return undefined; } +/** Adds only concrete local source parent directories to an existing root allowlist. */ export function appendLocalMediaParentRoots( roots: readonly string[], mediaSources?: readonly string[], @@ -115,6 +121,7 @@ export function appendLocalMediaParentRoots( return appended; } +/** Resolves outbound media roots, expanding for local sources only when filesystem policy allows it. */ export function getAgentScopedMediaLocalRootsForSources(params: { cfg: OpenClawConfig; agentId?: string; diff --git a/src/media/media-reference.ts b/src/media/media-reference.ts index 2708e079fb7d..fe13c137df45 100644 --- a/src/media/media-reference.ts +++ b/src/media/media-reference.ts @@ -6,6 +6,7 @@ import { getMediaDir, resolveMediaBufferPath } from "./store.js"; type MediaReferenceErrorCode = "invalid-path" | "path-not-allowed"; +/** Error raised when a media reference cannot be mapped to an allowed local media file. */ export class MediaReferenceError extends Error { code: MediaReferenceErrorCode; @@ -28,6 +29,7 @@ type InboundMediaUri = { normalizedSource: string; }; +/** Strips legacy MEDIA: prefixes while preserving canonical media:// references. */ export function normalizeMediaReferenceSource(source: string): string { const trimmed = source.trim(); if (/^media:\/\//i.test(trimmed)) { @@ -46,6 +48,7 @@ type MediaReferenceSourceInfo = { looksLikeWindowsDrivePath: boolean; }; +/** Classifies media reference schemes before local resolution or sandbox rewriting. */ export function classifyMediaReferenceSource( source: string, options?: { allowDataUrl?: boolean }, @@ -109,6 +112,7 @@ async function resolvePathForContainment(candidate: string): Promise { } } +/** Parses canonical inbound media-store URIs and rejects nested or cross-bucket references. */ export function parseInboundMediaUri(source: string): InboundMediaUri | null { const normalizedSource = normalizeMediaReferenceSource(source); if (!/^media:\/\//i.test(normalizedSource)) { @@ -164,6 +168,7 @@ async function resolveInboundMediaUri( }; } +/** Rewrites inbound media-store URIs to sandbox-relative paths for staged agent inputs. */ export function resolveMediaReferenceSandboxPath( source: string, inboundDir = "media/inbound", @@ -179,6 +184,7 @@ export function resolveMediaReferenceSandboxPath( }; } +/** Resolves inbound media:// URIs or first-level inbound file paths to concrete store files. */ export async function resolveInboundMediaReference( source: string, ): Promise { @@ -200,6 +206,7 @@ export async function resolveInboundMediaReference( const rawInboundDir = path.resolve(getMediaDir(), "inbound"); const rawResolvedPath = path.resolve(localPath); const rawRel = path.relative(rawInboundDir, rawResolvedPath); + // Realpath fallback catches symlinks and moved state dirs before accepting direct paths. const rel = rawRel && !relativePathEscapesBase(rawRel) ? rawRel @@ -219,6 +226,7 @@ export async function resolveInboundMediaReference( }; } +/** Converts inbound media references for callers that need a direct local file path. */ export async function resolveMediaReferenceLocalPath(source: string): Promise { const normalizedSource = normalizeMediaReferenceSource(source); return (await resolveInboundMediaReference(normalizedSource))?.physicalPath ?? normalizedSource; diff --git a/src/media/media-services.ts b/src/media/media-services.ts index fb372b96f2f4..c52b16020cb9 100644 --- a/src/media/media-services.ts +++ b/src/media/media-services.ts @@ -1,3 +1,5 @@ +// Media service barrel for audio, image, video, and ffmpeg helpers used by +// runtime/tool surfaces. Keep heavy implementations behind their own modules. export * from "./audio-transcode.js"; export * from "./ffmpeg-exec.js"; export * from "./image-ops.js"; diff --git a/src/media/outbound-attachment.ts b/src/media/outbound-attachment.ts index fa9a93a1b662..9a007a7f7566 100644 --- a/src/media/outbound-attachment.ts +++ b/src/media/outbound-attachment.ts @@ -2,6 +2,7 @@ import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "./load- import { saveMediaBuffer } from "./store.js"; import { loadWebMedia } from "./web-media.js"; +/** Loads a remote/local media URL and stages it into the outbound media store. */ export async function resolveOutboundAttachmentFromUrl( mediaUrl: string, maxBytes: number, @@ -20,6 +21,7 @@ export async function resolveOutboundAttachmentFromUrl( mediaReadFile: options?.readFile, }), ); + // Preserve source file names so outbound attachments keep useful names after UUID staging. const saved = await saveMediaBuffer( media.buffer, media.contentType ?? undefined, diff --git a/src/media/parse.ts b/src/media/parse.ts index 0b0f28643f70..9db508c5e40a 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -1,5 +1,3 @@ -// Shared helpers for parsing MEDIA tokens from command/stdout text. - import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, @@ -13,9 +11,10 @@ import { import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; import { parseAudioTag } from "./audio-tags.js"; -// Allow optional wrapping backticks and punctuation after the token; capture the core token. +/** Captures legacy MEDIA: attachment directives from model/tool output. */ export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi; +/** Ordered output segment emitted after visible text and extracted media are separated. */ export type ParsedMediaOutputSegment = | { type: "text"; @@ -26,12 +25,14 @@ export type ParsedMediaOutputSegment = url: string; }; +/** Controls which non-MEDIA syntaxes may be lifted into media attachments. */ export type SplitMediaFromOutputOptions = { extractMarkdownImages?: boolean; extractMediaDirectives?: boolean; }; -export function normalizeMediaSource(src: string) { +/** Converts file URLs into plain local paths before downstream media validation. */ +export function normalizeMediaSource(src: string): string { return src.startsWith("file://") ? src.replace("file://", "") : src; } @@ -476,6 +477,7 @@ function isInsideFence(fenceSpans: Array<{ start: number; end: number }>, offset return fenceSpans.some((span) => offset >= span.start && offset < span.end); } +/** Splits tool/stdout text into visible text, media attachments, voice tags, and ordered segments. */ export function splitMediaFromOutput( raw: string, options: SplitMediaFromOutputOptions = {}, @@ -522,13 +524,13 @@ export function splitMediaFromOutput( const hasFenceMarkers = mayContainFenceMarkers(trimmedRaw); const fenceSpans = hasFenceMarkers ? parseFenceSpans(trimmedRaw) : []; - // Collect tokens line by line so we can strip them cleanly. + // Line-wise parsing preserves visible text while letting MEDIA-only lines disappear cleanly. const lines = trimmedRaw.split("\n"); const keptLines: string[] = []; let lineOffset = 0; // Track character offset for fence checking for (const line of lines) { - // Skip MEDIA extraction if this line is inside a fenced code block + // Fenced examples must remain text; extracting their MEDIA tokens would mutate transcripts. if (hasFenceMarkers && isInsideFence(fenceSpans, lineOffset)) { keptLines.push(line); pushTextSegment(line); @@ -607,6 +609,7 @@ export function splitMediaFromOutput( /\s/.test(payloadValue) && looksLikeLocalPath ) { + // A single valid split plus invalid leftovers can be one local path containing spaces. const fallback = normalizeMediaSource(cleanCandidate(payloadValue)); if (isValidMedia(fallback, { allowSpaces: true })) { media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback); diff --git a/src/media/pdf-extract.ts b/src/media/pdf-extract.ts index c309866b8c59..3e4becab56ab 100644 --- a/src/media/pdf-extract.ts +++ b/src/media/pdf-extract.ts @@ -5,9 +5,12 @@ import type { } from "../plugins/document-extractor-types.js"; import { extractDocumentContent } from "./document-extractors.runtime.js"; +/** Image payload extracted from a PDF page by the document-extract plugin. */ export type PdfExtractedImage = DocumentExtractedImage; +/** Text and extracted image payloads returned by PDF extraction callers. */ export type PdfExtractedContent = DocumentExtractionResult; +/** Extracts PDF content through the configured document extractor and hides extractor metadata. */ export async function extractPdfContent(params: { buffer: Buffer; maxPages: number; diff --git a/src/media/png-encode.ts b/src/media/png-encode.ts index 1e849ff21888..9c57699f2b3b 100644 --- a/src/media/png-encode.ts +++ b/src/media/png-encode.ts @@ -1,7 +1,3 @@ -/** - * Minimal PNG encoder for generating simple RGBA images without native dependencies. - * Used for QR codes, live probes, and other programmatic image generation. - */ import { deflateSync } from "node:zlib"; const CRC_TABLE = (() => { @@ -36,7 +32,10 @@ function pngChunk(type: string, data: Buffer): Buffer { return Buffer.concat([len, typeBuf, data, crcBuf]); } -/** Write a pixel to an RGBA buffer. Ignores out-of-bounds writes. */ +/** + * Writes one RGBA pixel into a width-strided buffer. + * Out-of-bounds coordinates are ignored so fixture drawing code can clip shapes cheaply. + */ export function fillPixel( buf: Buffer, x: number, @@ -65,7 +64,8 @@ function encodePng(buffer: Buffer, width: number, height: number, channels: 3 | const raw = Buffer.alloc((stride + 1) * height); for (let row = 0; row < height; row += 1) { const rawOffset = row * (stride + 1); - raw[rawOffset] = 0; // filter: none + // Each scanline starts with PNG filter byte 0 so raw RGB/RGBA rows stay literal. + raw[rawOffset] = 0; buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride); } const compressed = deflateSync(raw); @@ -88,12 +88,12 @@ function encodePng(buffer: Buffer, width: number, height: number, channels: 3 | ]); } -/** Encode an RGB buffer as a PNG image. */ +/** Encodes tightly packed RGB bytes (`width * height * 3`) as a PNG image. */ export function encodePngRgb(buffer: Buffer, width: number, height: number): Buffer { return encodePng(buffer, width, height, 3); } -/** Encode an RGBA buffer as a PNG image. */ +/** Encodes tightly packed RGBA bytes (`width * height * 4`) as a PNG image. */ export function encodePngRgba(buffer: Buffer, width: number, height: number): Buffer { return encodePng(buffer, width, height, 4); } diff --git a/src/media/prompt-image-order.ts b/src/media/prompt-image-order.ts index 97b8763e8ff5..f20eab0ac1c5 100644 --- a/src/media/prompt-image-order.ts +++ b/src/media/prompt-image-order.ts @@ -1 +1,2 @@ +/** Tracks whether prompt images stayed inline or were offloaded while preserving model order. */ export type PromptImageOrderEntry = "inline" | "offloaded"; diff --git a/src/media/qr-image.ts b/src/media/qr-image.ts index 5fd1dc212dd0..24eacbf867fa 100644 --- a/src/media/qr-image.ts +++ b/src/media/qr-image.ts @@ -15,6 +15,7 @@ type QrPngRenderOptions = { marginModules?: number; }; +/** Temp-file write options kept to filename segments so callers cannot choose parent paths. */ type QrPngTempFileOptions = QrPngRenderOptions & { tmpRoot: string; dirPrefix: string; @@ -54,6 +55,7 @@ function resolveQrTempPathSegment(name: string, value: string): string { return value; } +/** Renders QR text as raw PNG base64 after validating bounded renderer options. */ export async function renderQrPngBase64( input: string, opts: QrPngRenderOptions = {}, @@ -84,10 +86,12 @@ export async function renderQrPngBase64( return dataUrl.slice(QR_PNG_DATA_URL_PREFIX.length); } +/** Wraps PNG base64 in the exact data URL prefix expected by chat/media callers. */ export function formatQrPngDataUrl(base64: string): string { return `${QR_PNG_DATA_URL_PREFIX}${base64}`; } +/** Renders QR text as a PNG data URL. */ export async function renderQrPngDataUrl( input: string, opts: QrPngRenderOptions = {}, @@ -95,6 +99,7 @@ export async function renderQrPngDataUrl( return formatQrPngDataUrl(await renderQrPngBase64(input, opts)); } +/** Writes QR PNG output into a scoped temp directory and returns that directory as a media root. */ export async function writeQrPngTempFile( input: string, opts: QrPngTempFileOptions, diff --git a/src/media/qr-runtime.ts b/src/media/qr-runtime.ts index eed786b96a87..91381ed46004 100644 --- a/src/media/qr-runtime.ts +++ b/src/media/qr-runtime.ts @@ -7,10 +7,12 @@ const qrCodeRuntimeLoader = createLazyImportLoader(() => import("qrcode").then((mod) => mod.default ?? mod), ); +/** Loads the qrcode package lazily so QR support does not affect media startup paths. */ export async function loadQrCodeRuntime(): Promise { return await qrCodeRuntimeLoader.load(); } +/** Validates QR text before passing it to the renderer runtime. */ export function normalizeQrText(text: string): string { if (typeof text !== "string") { throw new TypeError("QR text must be a string."); diff --git a/src/media/qr-terminal.ts b/src/media/qr-terminal.ts index 3b88cb22d6d7..897e5aa0c090 100644 --- a/src/media/qr-terminal.ts +++ b/src/media/qr-terminal.ts @@ -44,6 +44,7 @@ function renderCompactTerminalQr(modules: QrTerminalModules): string { return lines.join("\n"); } +/** Renders QR text for terminal display, with an optional compact half-block mode. */ export async function renderQrTerminal( input: string, opts: { small?: boolean } = {}, @@ -51,6 +52,7 @@ export async function renderQrTerminal( const text = normalizeQrText(input); const qrCode = await loadQrCodeRuntime(); if (opts.small === true) { + // Avoid qrcode's small terminal mode so we control quiet-zone size and ANSI reset placement. return renderCompactTerminalQr(qrCode.create(text).modules); } return await qrCode.toString(text, { diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index b1bab6152474..8b5078fe0af6 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -61,6 +61,7 @@ function isAgentScopedHostMediaReadAllowed( return true; } +/** Creates a host reader bound to the agent workspace and configured local-file safety checks. */ export function createAgentScopedHostMediaReadFile( params: { cfg: OpenClawConfig; @@ -98,6 +99,7 @@ function appendWorkspaceDirToLocalRoots( return [...roots, resolvedWorkspaceDir]; } +/** Resolves roots and optional host read capability for outbound media in an agent context. */ export function resolveAgentScopedOutboundMediaAccess( params: { cfg: OpenClawConfig; @@ -113,6 +115,7 @@ export function resolveAgentScopedOutboundMediaAccess( params.mediaAccess?.workspaceDir ?? (params.agentId ? resolveAgentWorkspaceDir(params.cfg, params.agentId) : undefined); const hostMediaReadAllowed = isAgentScopedHostMediaReadAllowed(params); + // Even when host reads are denied, keep base roots so generated media remains addressable. const baseLocalRoots = params.mediaAccess?.localRoots ?? (hostMediaReadAllowed diff --git a/src/media/sniff-mime-from-base64.ts b/src/media/sniff-mime-from-base64.ts index f24740f2419e..c524bf0bd220 100644 --- a/src/media/sniff-mime-from-base64.ts +++ b/src/media/sniff-mime-from-base64.ts @@ -1,6 +1,7 @@ import { canonicalizeBase64 } from "@openclaw/media-core/base64"; import { detectMime } from "@openclaw/media-core/mime"; +/** Sniffs a MIME type from canonical base64 without decoding the full payload. */ export async function sniffMimeFromBase64(base64: string): Promise { const trimmed = base64.trim(); const canonicalBase64 = trimmed ? canonicalizeBase64(trimmed) : undefined; @@ -10,6 +11,7 @@ export async function sniffMimeFromBase64(base64: string): Promise path.join(resolveConfigDir(), "media"); -export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default +/** Default per-file media-store byte cap used by inbound staging and plugin SDK callers. */ +export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; const MAX_BYTES = MEDIA_MAX_BYTES; const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes // Files are intentionally readable by non-owner UIDs so Docker sandbox containers can access @@ -97,6 +98,7 @@ let httpRequestImpl: RequestImpl = defaultHttpRequestImpl; let httpsRequestImpl: RequestImpl = defaultHttpsRequestImpl; let resolvePinnedHostnameImpl: ResolvePinnedHostnameImpl = defaultResolvePinnedHostnameImpl; +/** Overrides network dependencies for media-store tests and restores defaults when omitted. */ export function setMediaStoreNetworkDepsForTest(deps?: { httpRequest?: RequestImpl; httpsRequest?: RequestImpl; @@ -122,21 +124,16 @@ function sanitizeFilename(name: string): string { return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60); } -/** - * Extract original filename from path if it matches the embedded format. - * Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}" - * Falls back to basename if no pattern match, or "file.bin" if empty. - */ +/** Restores the caller-facing filename from media-store paths with embedded UUID suffixes. */ export function extractOriginalFilename(filePath: string): string { const basename = basenameFromAnyPath(filePath); if (!basename) { return "file.bin"; - } // Fallback for empty input + } const ext = extnameFromAnyPath(basename); const nameWithoutExt = path.basename(basename, ext); - // Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens) const match = nameWithoutExt.match( /^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, ); @@ -144,13 +141,15 @@ export function extractOriginalFilename(filePath: string): string { return `${match[1]}${ext}`; } - return basename; // Fallback: use as-is + return basename; } +/** Returns the configured absolute media-store root without creating it. */ export function getMediaDir() { return resolveMediaDir(); } +/** Creates the configured media-store root with private directory permissions. */ export async function ensureMediaDir() { const mediaDir = resolveMediaDir(); await fs.mkdir(mediaDir, { recursive: true, mode: 0o700 }); @@ -189,6 +188,7 @@ async function retryAfterRecreatingDir(dir: string, run: () => Promise): P } } +/** Prunes expired media files, optionally recursing into scoped media subdirectories. */ export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS, options: CleanOldMediaOptions = {}) { await openMediaStore().pruneExpired({ maxDepth: options.recursive ? undefined : 1, @@ -299,6 +299,7 @@ async function downloadToFile( }); } +/** Media-store file metadata returned after bytes are persisted under a safe media ID. */ export type SavedMedia = { id: string; path: string; @@ -464,6 +465,7 @@ async function writeMediaStreamToFile(params: { } } +/** Stable error categories for unsafe or failed source-file ingestion. */ export type SaveMediaSourceErrorCode = | "invalid-path" | "not-found" @@ -471,6 +473,7 @@ export type SaveMediaSourceErrorCode = | "path-mismatch" | "too-large"; +/** Error raised when saveMediaSource cannot safely read or persist a source path. */ export class SaveMediaSourceError extends Error { code: SaveMediaSourceErrorCode; @@ -512,6 +515,7 @@ function toSaveMediaSourceError(err: FsSafeLikeError, maxBytes = MAX_BYTES): Sav } } +/** Saves a local path or HTTP(S) source into the media store after MIME/size validation. */ export async function saveMediaSource( source: string, headers?: Record, @@ -560,6 +564,7 @@ export async function saveMediaSource( } } +/** Saves an in-memory media buffer under a UUID-backed media ID. */ export async function saveMediaBuffer( buffer: Buffer, contentType?: string, @@ -591,6 +596,7 @@ export async function saveMediaBuffer( return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime }); } +/** Streams media into a sibling temp file before atomically publishing the final media ID. */ export async function saveMediaStream( stream: AsyncIterable, contentType?: string, @@ -670,6 +676,7 @@ export async function resolveMediaBufferPath(id: string, subdir = "inbound"): Pr } } +/** Read result for callers that need media bytes plus the resolved file path. */ export type ReadMediaBufferResult = { id: string; path: string; @@ -677,6 +684,7 @@ export type ReadMediaBufferResult = { size: number; }; +/** Reads a stored media ID with the same path guards and byte limit used by writers. */ export async function readMediaBuffer( id: string, subdir = "inbound", diff --git a/src/media/temp-files.ts b/src/media/temp-files.ts index d01bce135d13..d46cadf42899 100644 --- a/src/media/temp-files.ts +++ b/src/media/temp-files.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; +/** Best-effort temp-file cleanup helper for optional paths from media conversion flows. */ export async function unlinkIfExists(filePath: string | null | undefined): Promise { if (!filePath) { return; diff --git a/src/media/video-dimensions.ts b/src/media/video-dimensions.ts index fdc797e6d32d..df046c89e7e4 100644 --- a/src/media/video-dimensions.ts +++ b/src/media/video-dimensions.ts @@ -1,5 +1,6 @@ import { runFfprobe } from "./ffmpeg-exec.js"; +/** Positive video dimensions reported by ffprobe for the first video stream. */ export type VideoDimensions = { width: number; height: number; @@ -12,6 +13,7 @@ function parsePositiveDimension(value: unknown): number | undefined { return value; } +/** Parses ffprobe JSON output, accepting only positive integer first-stream dimensions. */ export function parseFfprobeVideoDimensions(stdout: string): VideoDimensions | undefined { const parsed = JSON.parse(stdout) as { streams?: Array<{ width?: unknown; height?: unknown }> }; const stream = parsed.streams?.[0]; @@ -20,6 +22,7 @@ export function parseFfprobeVideoDimensions(stdout: string): VideoDimensions | u return width && height ? { width, height } : undefined; } +/** Probes a video buffer through ffprobe stdin and treats probe failures as unknown dimensions. */ export async function probeVideoDimensions(buffer: Buffer): Promise { try { const stdout = await runFfprobe( diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 20e88d5017bc..dd190ad9bdea 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -36,6 +36,7 @@ import { export { getDefaultLocalRoots, LocalMediaAccessError }; export type { LocalMediaAccessErrorCode }; +/** Loaded media bytes plus resolved MIME kind and filename metadata for outbound/plugin callers. */ export type WebMediaResult = { buffer: Buffer; contentType?: string; @@ -65,8 +66,10 @@ type WebMediaOptions = { hostReadCapability?: boolean; }; +/** Compression preference used to tune image size/quality search grids. */ export type ImageQualityPreference = "auto" | "efficient" | "balanced" | "high"; +/** Per-model image compression constraints merged into outbound media policy. */ export type ImageCompressionModelPolicy = { maxBytes?: number; maxPixels?: number; @@ -74,6 +77,7 @@ export type ImageCompressionModelPolicy = { preferredSidePx?: number; }; +/** Image compression policy for model/tool callers that need bounded media payloads. */ export type ImageCompressionPolicy = { quality?: ImageQualityPreference; models?: ImageCompressionModelPolicy[]; @@ -660,6 +664,7 @@ function isPreservableImageMime( ); } +/** Returns the stricter byte cap between caller limits and image compression policy limits. */ export function effectiveImageBytesCap( baseCap: number | undefined, policy?: ImageCompressionPolicy, @@ -690,6 +695,7 @@ function buildDescendingLadder(maxSide: number, values: readonly number[]): numb return uniqueValues(fallbackLadder.filter((value) => value > 0)).toSorted((a, b) => b - a); } +/** Resolves the ordered max-side and JPEG quality search grid for an image compression policy. */ export function resolveImageCompressionGrid(policy?: ImageCompressionPolicy): { sides: number[]; qualities: number[]; @@ -772,6 +778,7 @@ async function optimizeImageWithFallback(params: { }; } +/** Optimizes image bytes for web-media delivery while preserving accepted original formats when possible. */ export async function optimizeImageBufferForWebMedia(params: { buffer: Buffer; contentType?: string; @@ -1081,6 +1088,7 @@ async function loadWebMediaInternal( }); } +/** Loads local, remote, hosted, or media-store media and optimizes images by default. */ export async function loadWebMedia( mediaUrl: string, maxBytesOrOptions?: number | WebMediaOptions, @@ -1092,6 +1100,7 @@ export async function loadWebMedia( ); } +/** Loads local, remote, hosted, or media-store media without image optimization. */ export async function loadWebMediaRaw( mediaUrl: string, maxBytesOrOptions?: number | WebMediaOptions, @@ -1103,6 +1112,7 @@ export async function loadWebMediaRaw( ); } +/** Optimizes image bytes to JPEG under a target byte cap using the shared compression grid. */ export async function optimizeImageToJpeg( buffer: Buffer, maxBytes: number, diff --git a/src/memory-host-sdk/engine-qmd.ts b/src/memory-host-sdk/engine-qmd.ts index 257cd7b1e265..f43380495fe5 100644 --- a/src/memory-host-sdk/engine-qmd.ts +++ b/src/memory-host-sdk/engine-qmd.ts @@ -1,3 +1,7 @@ +/** + * Core-facing facade for qmd engine availability checks. The package owns the + * binary probing contract; repo callers import through this stable local path. + */ export { checkQmdBinaryAvailability, resolveQmdBinaryUnavailableReason, diff --git a/src/memory-host-sdk/engine-storage.ts b/src/memory-host-sdk/engine-storage.ts index 364b2ea34e94..128506e08f0d 100644 --- a/src/memory-host-sdk/engine-storage.ts +++ b/src/memory-host-sdk/engine-storage.ts @@ -1,3 +1,7 @@ +/** + * Core-facing facade for memory backend storage config resolution. Keep this + * path stable while the shared SDK package owns provider status semantics. + */ export { resolveMemoryBackendConfig, type MemoryProviderStatus, diff --git a/src/memory-host-sdk/events.ts b/src/memory-host-sdk/events.ts index 752bb7d16f2a..f95662055481 100644 --- a/src/memory-host-sdk/events.ts +++ b/src/memory-host-sdk/events.ts @@ -3,8 +3,10 @@ import path from "node:path"; import { appendRegularFile } from "../infra/fs-safe.js"; import type { MemoryDreamingPhaseName } from "./dreaming.js"; +/** Workspace-relative JSONL audit log for memory recall, promotion, and dream events. */ export const MEMORY_HOST_EVENT_LOG_RELATIVE_PATH = path.join("memory", ".dreams", "events.jsonl"); +/** Event emitted when a recall query records the selected memory snippets. */ export type MemoryHostRecallRecordedEvent = { type: "memory.recall.recorded"; timestamp: string; @@ -18,6 +20,7 @@ export type MemoryHostRecallRecordedEvent = { }>; }; +/** Event emitted when deep-dream candidates are promoted into durable memory. */ export type MemoryHostPromotionAppliedEvent = { type: "memory.promotion.applied"; timestamp: string; @@ -33,6 +36,7 @@ export type MemoryHostPromotionAppliedEvent = { }>; }; +/** Event emitted after a dreaming phase writes inline memory and/or reports. */ export type MemoryHostDreamCompletedEvent = { type: "memory.dream.completed"; timestamp: string; @@ -43,15 +47,18 @@ export type MemoryHostDreamCompletedEvent = { storageMode: "inline" | "separate" | "both"; }; +/** Append-only memory host event schema stored as JSONL. */ export type MemoryHostEvent = | MemoryHostRecallRecordedEvent | MemoryHostPromotionAppliedEvent | MemoryHostDreamCompletedEvent; +/** Resolve the event log path inside a workspace without touching the filesystem. */ export function resolveMemoryHostEventLogPath(workspaceDir: string): string { return path.join(workspaceDir, MEMORY_HOST_EVENT_LOG_RELATIVE_PATH); } +/** Append one memory host event, creating the dreams directory with symlink-safe writes. */ export async function appendMemoryHostEvent( workspaceDir: string, event: MemoryHostEvent, @@ -65,6 +72,7 @@ export async function appendMemoryHostEvent( }); } +/** Read recent memory host events, ignoring corrupt JSONL lines left by partial writes. */ export async function readMemoryHostEvents(params: { workspaceDir: string; limit?: number; @@ -87,6 +95,8 @@ export async function readMemoryHostEvents(params: { try { return [JSON.parse(line) as MemoryHostEvent]; } catch { + // The log is best-effort diagnostics; one malformed line must not hide + // later valid events or break memory status rendering. return []; } }); diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index db62fe0baa2b..07a98405fb7b 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -1 +1,5 @@ +/** + * Host backend config barrel for memory hosts. The package implementation owns + * schema/default details; core imports this path to avoid deep package paths. + */ export * from "../../../packages/memory-host-sdk/src/host/backend-config.js"; diff --git a/src/memory-host-sdk/host/embedding-defaults.ts b/src/memory-host-sdk/host/embedding-defaults.ts index fc503c9aca35..c2b23ff1a6be 100644 --- a/src/memory-host-sdk/host/embedding-defaults.ts +++ b/src/memory-host-sdk/host/embedding-defaults.ts @@ -1,2 +1,3 @@ +/** Default local embedding model used when memory hosts run without remote embedding config. */ export const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf"; diff --git a/src/memory-host-sdk/host/embedding-inputs.ts b/src/memory-host-sdk/host/embedding-inputs.ts index f65a5d28f6aa..04c9151734f3 100644 --- a/src/memory-host-sdk/host/embedding-inputs.ts +++ b/src/memory-host-sdk/host/embedding-inputs.ts @@ -1,21 +1,26 @@ +/** Plain text segment accepted by embedding providers. */ export type EmbeddingInputTextPart = { type: "text"; text: string; }; +/** Base64 inline payload segment for multimodal embedding providers. */ export type EmbeddingInputInlineDataPart = { type: "inline-data"; mimeType: string; data: string; }; +/** Provider-neutral embedding input part. */ export type EmbeddingInputPart = EmbeddingInputTextPart | EmbeddingInputInlineDataPart; +/** Embedding input preserving legacy text plus optional structured parts. */ export type EmbeddingInput = { text: string; parts?: EmbeddingInputPart[]; }; +/** Build a text-only embedding input while keeping callers on the structured API. */ export function buildTextEmbeddingInput(text: string): EmbeddingInput { return { text }; } @@ -26,6 +31,7 @@ function isInlineDataEmbeddingInputPart( return part.type === "inline-data"; } +/** Return true when an embedding request needs multimodal provider support. */ export function hasNonTextEmbeddingParts(input: EmbeddingInput | undefined): boolean { if (!input?.parts?.length) { return false; diff --git a/src/memory-host-sdk/host/types.ts b/src/memory-host-sdk/host/types.ts index ab03cbea60c9..3314d2305efe 100644 --- a/src/memory-host-sdk/host/types.ts +++ b/src/memory-host-sdk/host/types.ts @@ -1 +1,5 @@ +/** + * Public memory host type barrel. Re-export through core so plugins and runtime + * code share the same contract without depending on package internals. + */ export * from "../../../packages/memory-host-sdk/src/host/types.js"; diff --git a/src/memory-host-sdk/multimodal.ts b/src/memory-host-sdk/multimodal.ts index 36b50cbbf4b2..b07bc6907a38 100644 --- a/src/memory-host-sdk/multimodal.ts +++ b/src/memory-host-sdk/multimodal.ts @@ -1 +1,5 @@ +/** + * Core-facing multimodal memory helpers. The shared SDK package owns modality + * detection and payload contracts; this facade keeps internal imports stable. + */ export * from "../../packages/memory-host-sdk/src/multimodal.js"; diff --git a/src/memory-host-sdk/query.ts b/src/memory-host-sdk/query.ts index 2a2ef6bbed42..929445320ec2 100644 --- a/src/memory-host-sdk/query.ts +++ b/src/memory-host-sdk/query.ts @@ -1 +1,5 @@ +/** + * Memory query contract facade for core callers. Keep query types and helpers + * routed through this local path instead of package implementation paths. + */ export * from "../../packages/memory-host-sdk/src/query.js"; diff --git a/src/memory-host-sdk/secret.ts b/src/memory-host-sdk/secret.ts index 24828dfda2a0..b6895314dddd 100644 --- a/src/memory-host-sdk/secret.ts +++ b/src/memory-host-sdk/secret.ts @@ -1,3 +1,7 @@ +/** + * Memory secret input facade. The shared SDK package owns accepted secret + * shapes; core uses this path for config/status checks. + */ export { hasConfiguredMemorySecretInput, resolveMemorySecretInputString, diff --git a/src/memory-host-sdk/status.ts b/src/memory-host-sdk/status.ts index 704b37737b45..2c4ecdf7e94b 100644 --- a/src/memory-host-sdk/status.ts +++ b/src/memory-host-sdk/status.ts @@ -1 +1,5 @@ +/** + * Memory status facade for runtime and UI callers. Status shape stays owned by + * the shared SDK package while core imports through this stable barrel. + */ export * from "../../packages/memory-host-sdk/src/status.js"; diff --git a/src/model-catalog/authority.ts b/src/model-catalog/authority.ts index 09bb99e623a5..10e74d739e25 100644 --- a/src/model-catalog/authority.ts +++ b/src/model-catalog/authority.ts @@ -3,6 +3,8 @@ import type { NormalizedModelCatalogRow, } from "@openclaw/model-catalog-core/model-catalog-types"; +// Source authority decides which duplicate catalog row survives when providers, +// manifests, config, and cache all describe the same provider/model merge key. const MODEL_CATALOG_SOURCE_AUTHORITY: Readonly> = { config: 0, manifest: 1, @@ -24,6 +26,8 @@ export function mergeModelCatalogRowsByAuthority( const byMergeKey = new Map(); for (const row of rows) { const existing = byMergeKey.get(row.mergeKey); + // Lower numeric authority wins: explicit config beats manifest/runtime + // discovery, while provider-index preview data is the weakest source. if (!existing || compareModelCatalogSourceAuthority(row.source, existing.source) < 0) { byMergeKey.set(row.mergeKey, row); } diff --git a/src/model-catalog/index.ts b/src/model-catalog/index.ts index b02ab03f83d9..1d1f3a7dd75c 100644 --- a/src/model-catalog/index.ts +++ b/src/model-catalog/index.ts @@ -1,3 +1,5 @@ +// Public model-catalog facade. Keep exports here curated so callers use the +// normalized planning APIs instead of reaching into provider-index internals. export { mergeModelCatalogRowsByAuthority } from "./authority.js"; export { loadOpenClawProviderIndex } from "./provider-index/index.js"; export { diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index cb5568d0938a..c5c86b4028c8 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -12,6 +12,8 @@ import type { import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import { normalizeUniqueStringEntries } from "../../packages/normalization-core/src/string-normalization.js"; +// Manifest planners convert plugin modelCatalog declarations into normalized +// rows and suppression entries while enforcing plugin ownership boundaries. type ManifestModelCatalogPlugin = { id: string; providers?: readonly string[]; @@ -82,6 +84,8 @@ export function planManifestModelCatalogRows(params: { for (const row of entry.rows) { const seen = seenRows.get(row.mergeKey); if (seen) { + // Conflicting plugin-owned declarations are dropped entirely so no + // plugin silently wins another plugin's model ref in the merged catalog. if (!conflicts.has(row.mergeKey)) { conflicts.set(row.mergeKey, { mergeKey: row.mergeKey, @@ -176,6 +180,7 @@ function buildModelCatalogProviderAliasTargets( for (const [rawAlias, alias] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { const aliasProvider = normalizeModelCatalogProviderId(rawAlias); const targetProvider = normalizeModelCatalogProviderId(alias.provider); + // Aliases are honored only when they point at a provider this plugin owns. if (!aliasProvider || !targetProvider || !ownedProviders.has(targetProvider)) { continue; } @@ -240,6 +245,8 @@ export function planManifestModelCatalogSuppressions(params: { if (modelFilter && model !== modelFilter) { continue; } + // Suppressions can affect owned providers and their declared aliases only; + // otherwise one plugin could hide another plugin's catalog entries. if (!providerRefs.has(provider)) { continue; } diff --git a/src/model-catalog/provider-index-planner.ts b/src/model-catalog/provider-index-planner.ts index 0986dafd2966..db6210442b0c 100644 --- a/src/model-catalog/provider-index-planner.ts +++ b/src/model-catalog/provider-index-planner.ts @@ -6,6 +6,8 @@ import type { } from "@openclaw/model-catalog-core/model-catalog-types"; import type { OpenClawProviderIndex } from "./provider-index/index.js"; +// Provider-index planner converts ClawHub-style preview catalog entries into +// normalized model rows for discovery before a plugin is installed. type ProviderIndexModelCatalogPlanEntry = { provider: string; pluginId: string; @@ -18,6 +20,8 @@ type ProviderIndexModelCatalogPlan = { }; function withPreviewStatusDefaults(providerCatalog: ModelCatalogProvider): ModelCatalogProvider { + // Provider-index rows are advisory discovery data, so unspecified model + // statuses default to preview instead of stable. return { ...providerCatalog, models: providerCatalog.models.map((model) => ({ diff --git a/src/model-catalog/provider-index/index.ts b/src/model-catalog/provider-index/index.ts index 8122d0fa9a21..92df8c2a287d 100644 --- a/src/model-catalog/provider-index/index.ts +++ b/src/model-catalog/provider-index/index.ts @@ -1,3 +1,4 @@ +// Provider-index public facade for normalized provider discovery metadata. export { loadOpenClawProviderIndex } from "./load.js"; export { normalizeOpenClawProviderIndex } from "./normalize.js"; export type { diff --git a/src/model-catalog/provider-index/load.ts b/src/model-catalog/provider-index/load.ts index 241ad1090d56..26807e24dd38 100644 --- a/src/model-catalog/provider-index/load.ts +++ b/src/model-catalog/provider-index/load.ts @@ -2,6 +2,8 @@ import { normalizeOpenClawProviderIndex } from "./normalize.js"; import { OPENCLAW_PROVIDER_INDEX } from "./openclaw-provider-index.js"; import type { OpenClawProviderIndex } from "./types.js"; +// Load the bundled provider index through the normalizer. Invalid generated or +// caller-supplied data falls back to an empty v1 index instead of leaking shape. export function loadOpenClawProviderIndex( source: unknown = OPENCLAW_PROVIDER_INDEX, ): OpenClawProviderIndex { diff --git a/src/model-catalog/provider-index/normalize.ts b/src/model-catalog/provider-index/normalize.ts index 8fa5560dee0e..4510e3872641 100644 --- a/src/model-catalog/provider-index/normalize.ts +++ b/src/model-catalog/provider-index/normalize.ts @@ -1,12 +1,12 @@ import { normalizeModelCatalog } from "@openclaw/model-catalog-core/model-catalog-normalize"; import { normalizeModelCatalogProviderId } from "@openclaw/model-catalog-core/model-catalog-refs"; import type { ModelCatalogProvider } from "@openclaw/model-catalog-core/model-catalog-types"; -import { parseClawHubPluginSpec } from "../../infra/clawhub-spec.js"; -import { parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; -import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import { asFiniteNumber } from "../../../packages/normalization-core/src/number-coercion.js"; import { normalizeOptionalString } from "../../../packages/normalization-core/src/string-coerce.js"; import { normalizeUniqueTrimmedStringList } from "../../../packages/normalization-core/src/string-normalization.js"; +import { parseClawHubPluginSpec } from "../../infra/clawhub-spec.js"; +import { parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; +import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import { isRecord } from "../../utils.js"; import type { OpenClawProviderIndex, @@ -16,6 +16,8 @@ import type { OpenClawProviderIndexProvider, } from "./types.js"; +// Provider-index normalization accepts generated discovery metadata from the +// bundled index and rejects malformed or prototype-polluting entries. const OPENCLAW_PROVIDER_INDEX_VERSION = 1; function normalizeSafeKey(value: unknown): string { @@ -31,6 +33,7 @@ function normalizeInstall(value: unknown): OpenClawProviderIndexPluginInstall | const parsedClawHub = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec) : null; const npmSpec = normalizeOptionalString(value.npmSpec); const parsedNpm = npmSpec ? parseRegistryNpmSpec(npmSpec) : null; + // Install metadata is useful only when at least one install spec parses. if (!parsedClawHub && !parsedNpm) { return undefined; } @@ -78,6 +81,8 @@ function normalizePreviewCatalog(params: { providerId: string; value: unknown; }): ModelCatalogProvider | undefined { + // Reuse catalog-core normalization so preview models obey the same model + // schema as installed plugin manifests. const catalog = normalizeModelCatalog( { providers: { [params.providerId]: params.value } }, { ownedProviders: new Set([params.providerId]) }, @@ -217,6 +222,8 @@ export function normalizeOpenClawProviderIndex(value: unknown): OpenClawProvider const providers: Record = {}; for (const [rawProviderId, rawProvider] of Object.entries(value.providers)) { const providerId = normalizeModelCatalogProviderId(rawProviderId); + // Provider ids become object keys, so blocked keys are dropped before + // writing into the normalized provider map. if (!providerId || isBlockedObjectKey(providerId)) { continue; } diff --git a/src/model-catalog/provider-index/types.ts b/src/model-catalog/provider-index/types.ts index d62fd8b80f33..1d4fcb8fe645 100644 --- a/src/model-catalog/provider-index/types.ts +++ b/src/model-catalog/provider-index/types.ts @@ -1,5 +1,7 @@ import type { ModelCatalogProvider } from "@openclaw/model-catalog-core/model-catalog-types"; +// Normalized provider-index schema. It describes providers discoverable before +// plugin install, including install hints, auth choices, and preview catalogs. export type OpenClawProviderIndexPluginInstall = { clawhubSpec?: string; npmSpec?: string; diff --git a/src/music-generation/capabilities.ts b/src/music-generation/capabilities.ts index 09b71f98c149..4d7b329ca876 100644 --- a/src/music-generation/capabilities.ts +++ b/src/music-generation/capabilities.ts @@ -5,12 +5,20 @@ import type { MusicGenerationProvider, } from "./types.js"; +/** + * Capability helpers for music generation providers. + * + * Music generation can run as prompt-only generation or image-conditioned edit; + * these helpers choose the active mode and return the matching capability block. + */ +/** Resolve generation mode from the presence of input images. */ export function resolveMusicGenerationMode(params: { inputImageCount?: number; }): MusicGenerationMode { return (params.inputImageCount ?? 0) > 0 ? "edit" : "generate"; } +/** List modes supported by a provider in stable display order. */ export function listSupportedMusicGenerationModes( provider: Pick, ): MusicGenerationMode[] { @@ -22,6 +30,7 @@ export function listSupportedMusicGenerationModes( return modes; } +/** Resolve the active mode and provider capability contract for one request. */ export function resolveMusicGenerationModeCapabilities(params: { provider?: Pick; inputImageCount?: number; diff --git a/src/music-generation/live-test-helpers.ts b/src/music-generation/live-test-helpers.ts index c8221fecafca..2ed414fd674c 100644 --- a/src/music-generation/live-test-helpers.ts +++ b/src/music-generation/live-test-helpers.ts @@ -7,8 +7,15 @@ import { resolveLiveAuthStore, } from "../media-generation/live-test-helpers.js"; +/** + * Live-test helpers for music generation providers. + * + * This module adapts the shared media live-test parsing/auth helpers to the + * music-generation config key and default provider model list. + */ export { parseProviderModelMap, redactLiveApiKey }; +/** Default live model refs used when a provider is enabled but not explicitly mapped. */ export const DEFAULT_LIVE_MUSIC_MODELS: Record = { fal: "fal/fal-ai/minimax-music/v2.6", google: "google/lyria-3-clip-preview", @@ -16,14 +23,17 @@ export const DEFAULT_LIVE_MUSIC_MODELS: Record = { openrouter: "openrouter/google/lyria-3-pro-preview", }; +/** Parse a comma-separated provider/model filter for live music tests. */ export function parseCsvFilter(raw?: string): Set | null { return parseLiveCsvFilter(raw); } +/** Resolve configured provider/model refs from the musicGenerationModel defaults. */ export function resolveConfiguredLiveMusicModels(cfg: OpenClawConfig): Map { return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.musicGenerationModel); } +/** Resolve whether live music tests should require auth profile keys. */ export function resolveLiveMusicAuthStore(params: { requireProfileKeys: boolean; hasLiveKeys: boolean; diff --git a/src/music-generation/model-ref.ts b/src/music-generation/model-ref.ts index 5ec8098dae86..0241ad22ac37 100644 --- a/src/music-generation/model-ref.ts +++ b/src/music-generation/model-ref.ts @@ -1,5 +1,12 @@ import { parseGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; +/** + * Model reference parsing for music generation. + * + * Music generation uses the same provider/model ref grammar as other media + * capabilities, but keeps this wrapper for a dedicated capability boundary. + */ +/** Parse a music generation model ref into provider and model ids. */ export function parseMusicGenerationModelRef( raw: string | undefined, ): { provider: string; model: string } | null { diff --git a/src/music-generation/normalization.ts b/src/music-generation/normalization.ts index 3fa54709c2b3..fc8766ef2387 100644 --- a/src/music-generation/normalization.ts +++ b/src/music-generation/normalization.ts @@ -11,6 +11,12 @@ import type { MusicGenerationSourceImage, } from "./types.js"; +/** + * Request normalization for music generation. + * + * Providers advertise per-mode and per-model support; this module removes + * unsupported caller overrides and records any duration coercion for metadata. + */ type ResolvedMusicGenerationOverrides = { lyrics?: string; instrumental?: boolean; @@ -25,9 +31,11 @@ function resolveModelBooleanSupport( defaultSupport: boolean | undefined, supportByModel: Readonly> | undefined, ): boolean { + // Per-model declarations override provider defaults because music models vary within a provider. return supportByModel?.[model] ?? defaultSupport === true; } +/** Sanitize caller overrides against provider capabilities before invoking a provider. */ export function resolveMusicGenerationOverrides(params: { provider: MusicGenerationProvider; model: string; @@ -101,6 +109,7 @@ export function resolveMusicGenerationOverrides(params: { if (format) { const supportedFormats = caps.supportedFormatsByModel?.[params.model] ?? caps.supportedFormats ?? []; + // An empty supportedFormats list means the provider validates formats internally. if ( !caps.supportsFormat || (supportedFormats.length > 0 && !supportedFormats.includes(format)) diff --git a/src/music-generation/provider-assets.ts b/src/music-generation/provider-assets.ts index 3ee2a95db717..3786b3e78c9d 100644 --- a/src/music-generation/provider-assets.ts +++ b/src/music-generation/provider-assets.ts @@ -6,6 +6,13 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { fetchProviderDownloadResponse } from "../media-understanding/shared.js"; import type { GeneratedMusicAsset } from "./types.js"; +/** + * Asset extraction and download helpers for music generation providers. + * + * Providers may return audio as URLs, file objects, or base64 payloads; these + * helpers normalize those shapes into bounded in-memory GeneratedMusicAsset values. + */ +/** Candidate audio file returned by a provider before download. */ export type GeneratedMusicFileCandidate = { url: string; mimeType?: string; @@ -14,6 +21,7 @@ export type GeneratedMusicFileCandidate = { function normalizeSpecificAudioMimeType(value: unknown): string | undefined { const mimeType = normalizeOptionalString(value)?.split(";")[0]?.trim().toLowerCase(); + // Generic binary types are less useful than known audio fallbacks for saved track names. if (!mimeType || mimeType === "application/octet-stream" || mimeType === "binary/octet-stream") { return undefined; } @@ -49,6 +57,7 @@ function pushGeneratedMusicFileCandidate( }); } +/** Extract URL/file candidates from common provider response keys. */ export function extractGeneratedMusicFileCandidates( payload: unknown, keys: readonly string[] = ["audio", "audio_file"], @@ -63,6 +72,7 @@ export function extractGeneratedMusicFileCandidates( return candidates; } +/** Convert a base64 provider payload into a generated music asset. */ export function generatedMusicAssetFromBase64(params: { base64: string; mimeType: string; @@ -77,6 +87,7 @@ export function generatedMusicAssetFromBase64(params: { }; } +/** Download a generated music URL with size limits and inferred audio metadata. */ export async function downloadGeneratedMusicAsset(params: { candidate: GeneratedMusicFileCandidate; timeoutMs: number; diff --git a/src/music-generation/provider-registry.ts b/src/music-generation/provider-registry.ts index ed582e9d8062..6e224943bb75 100644 --- a/src/music-generation/provider-registry.ts +++ b/src/music-generation/provider-registry.ts @@ -4,6 +4,12 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { resolvePluginCapabilityProviders } from "../plugins/capability-provider-runtime.js"; import type { MusicGenerationProviderPlugin } from "../plugins/types.js"; +/** + * Registry for music generation providers. + * + * Built-ins and plugin-provided capability providers share one alias map while + * rejecting unsafe object keys before they reach Maps or config-derived lookups. + */ const BUILTIN_MUSIC_GENERATION_PROVIDERS: readonly MusicGenerationProviderPlugin[] = []; const UNSAFE_PROVIDER_IDS = new Set(["__proto__", "constructor", "prototype"]); @@ -16,6 +22,7 @@ function normalizeMusicGenerationProviderId(id: string | undefined): string | un } function isSafeMusicGenerationProviderId(id: string | undefined): id is string { + // Keep prototype-pollution sentinel names out even after normal provider-id normalization. return Boolean(id && !UNSAFE_PROVIDER_IDS.has(id)); } @@ -59,12 +66,14 @@ function buildProviderMaps(cfg?: OpenClawConfig): { return { canonical, aliases }; } +/** List canonical music generation providers available for the current config. */ export function listMusicGenerationProviders( cfg?: OpenClawConfig, ): MusicGenerationProviderPlugin[] { return [...buildProviderMaps(cfg).canonical.values()]; } +/** Resolve a music generation provider by canonical id or alias. */ export function getMusicGenerationProvider( providerId: string | undefined, cfg?: OpenClawConfig, diff --git a/src/music-generation/runtime-types.ts b/src/music-generation/runtime-types.ts index 5cd575fac1c6..3542f19f3ffc 100644 --- a/src/music-generation/runtime-types.ts +++ b/src/music-generation/runtime-types.ts @@ -10,6 +10,13 @@ import type { MusicGenerationSourceImage, } from "./types.js"; +/** + * Runtime input/output contracts for music generation. + * + * These are separate from provider contracts because runtime results include + * fallback attempts, normalized metadata, and selected provider/model identity. + */ +/** Parameters accepted by the core music generation runtime. */ export type GenerateMusicParams = { cfg: OpenClawConfig; prompt: string; @@ -26,6 +33,7 @@ export type GenerateMusicParams = { timeoutMs?: number; }; +/** Result returned after a successful runtime provider attempt. */ export type GenerateMusicRuntimeResult = { tracks: GeneratedMusicAsset[]; provider: string; @@ -37,8 +45,10 @@ export type GenerateMusicRuntimeResult = { ignoredOverrides: MusicGenerationIgnoredOverride[]; }; +/** Parameters for listing music generation providers visible to runtime code. */ export type ListRuntimeMusicGenerationProvidersParams = { config?: OpenClawConfig; }; +/** Provider shape exposed by runtime listing APIs. */ export type RuntimeMusicGenerationProvider = MusicGenerationProvider; diff --git a/src/music-generation/runtime.ts b/src/music-generation/runtime.ts index 7b216c2b3926..2e4c10203e25 100644 --- a/src/music-generation/runtime.ts +++ b/src/music-generation/runtime.ts @@ -16,8 +16,16 @@ import { getMusicGenerationProvider, listMusicGenerationProviders } from "./prov import type { GenerateMusicParams, GenerateMusicRuntimeResult } from "./runtime-types.js"; import type { MusicGenerationResult } from "./types.js"; +/** + * Music generation runtime orchestration. + * + * The runtime resolves provider/model candidates, applies capability-based + * normalization, invokes providers, and records fallback attempts consistently + * with other media generation capabilities. + */ const log = createSubsystemLogger("music-generation"); +/** Injectable dependencies used by tests and alternate runtime hosts. */ export type MusicGenerationRuntimeDeps = { getProvider?: typeof getMusicGenerationProvider; listProviders?: typeof listMusicGenerationProviders; @@ -27,6 +35,7 @@ export type MusicGenerationRuntimeDeps = { export type { GenerateMusicParams, GenerateMusicRuntimeResult } from "./runtime-types.js"; +/** List runtime-visible music generation providers for a config snapshot. */ export function listRuntimeMusicGenerationProviders( params?: { config?: OpenClawConfig }, deps: MusicGenerationRuntimeDeps = {}, @@ -34,6 +43,7 @@ export function listRuntimeMusicGenerationProviders( return (deps.listProviders ?? listMusicGenerationProviders)(params?.config); } +/** Generate music with provider fallback and capability-aware request normalization. */ export async function generateMusic( params: GenerateMusicParams, deps: MusicGenerationRuntimeDeps = {}, @@ -71,6 +81,7 @@ export async function generateMusic( for (const candidate of candidates) { const provider = getProvider(candidate.provider, params.cfg); if (!provider) { + // Candidate resolution can include stale config refs; keep them in attempts for diagnostics. const error = `No music-generation provider registered for ${candidate.provider}`; attempts.push({ provider: candidate.provider, @@ -125,6 +136,7 @@ export async function generateMusic( }; } catch (err) { lastError = err; + // Preserve failed candidates so callers can see which provider/model refs were tried. recordCapabilityCandidateFailure({ attempts, provider: candidate.provider, diff --git a/src/music-generation/types.ts b/src/music-generation/types.ts index ce5806acff79..ce8ff993283b 100644 --- a/src/music-generation/types.ts +++ b/src/music-generation/types.ts @@ -2,8 +2,16 @@ import type { MediaNormalizationEntry } from "../../packages/media-generation-co import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** + * Public music generation provider contracts. + * + * Providers implement these request/result/capability shapes so the core + * runtime can normalize prompts, options, assets, and fallback diagnostics. + */ +/** Audio output formats currently understood by music generation providers. */ export type MusicGenerationOutputFormat = "mp3" | "wav"; +/** In-memory audio asset returned from a music generation provider. */ export type GeneratedMusicAsset = { buffer: Buffer; mimeType: string; @@ -11,6 +19,7 @@ export type GeneratedMusicAsset = { metadata?: Record; }; +/** Optional source image passed to image-conditioned music edit models. */ export type MusicGenerationSourceImage = { url?: string; buffer?: Buffer; @@ -24,6 +33,7 @@ type MusicGenerationProviderConfiguredContext = { agentDir?: string; }; +/** Provider request after runtime fallback and override normalization. */ export type MusicGenerationRequest = { provider: string; model: string; @@ -39,6 +49,7 @@ export type MusicGenerationRequest = { inputImages?: MusicGenerationSourceImage[]; }; +/** Provider result before runtime fallback metadata is attached. */ export type MusicGenerationResult = { tracks: GeneratedMusicAsset[]; model?: string; @@ -46,13 +57,16 @@ export type MusicGenerationResult = { metadata?: Record; }; +/** Caller override dropped because the selected provider/model does not support it. */ export type MusicGenerationIgnoredOverride = { key: "lyrics" | "instrumental" | "durationSeconds" | "format"; value: string | boolean | number; }; +/** Active music generation request mode. */ export type MusicGenerationMode = "generate" | "edit"; +/** Capability block for prompt-only music generation. */ export type MusicGenerationModeCapabilities = { maxTracks?: number; maxDurationSeconds?: number; @@ -66,21 +80,25 @@ export type MusicGenerationModeCapabilities = { supportedFormatsByModel?: Readonly>; }; +/** Capability block for image-conditioned music generation. */ export type MusicGenerationEditCapabilities = MusicGenerationModeCapabilities & { enabled: boolean; maxInputImages?: number; }; +/** Provider capability declaration, including optional mode-specific overrides. */ export type MusicGenerationProviderCapabilities = MusicGenerationModeCapabilities & { maxInputImages?: number; generate?: MusicGenerationModeCapabilities; edit?: MusicGenerationEditCapabilities; }; +/** Normalization metadata attached to runtime results. */ export type MusicGenerationNormalization = { durationSeconds?: MediaNormalizationEntry; }; +/** Provider implementation contract consumed by the music generation runtime. */ export type MusicGenerationProvider = { id: string; aliases?: string[]; diff --git a/src/node-host/config.ts b/src/node-host/config.ts index df6f8d4d1aaf..7c309cde4265 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -4,6 +4,13 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { writeJson } from "../infra/json-files.js"; +/** + * Persistent node-host identity/config file helpers. + * + * Node hosts keep a stable node id and optional Gateway connection metadata in + * the state directory so reconnects and plugin node commands can identify the host. + */ +/** Gateway endpoint metadata persisted with node-host config. */ export type NodeHostGatewayConfig = { host?: string; port?: number; @@ -42,6 +49,7 @@ function normalizeConfig(config: Partial | null): NodeHostConfig return base; } +/** Load and normalize the node-host config, or null when no readable config exists. */ export async function loadNodeHostConfig(): Promise { const filePath = resolveNodeHostConfigPath(); try { @@ -53,11 +61,13 @@ export async function loadNodeHostConfig(): Promise { } } +/** Save node-host config with private file permissions. */ export async function saveNodeHostConfig(config: NodeHostConfig): Promise { const filePath = resolveNodeHostConfigPath(); await writeJson(filePath, config, { mode: 0o600 }); } +/** Load or create a node-host config with a stable generated node id. */ export async function ensureNodeHostConfig(): Promise { const existing = await loadNodeHostConfig(); const normalized = normalizeConfig(existing); diff --git a/src/node-host/invoke-system-run-allowlist.ts b/src/node-host/invoke-system-run-allowlist.ts index 8be5698d5f57..56f23e5c34c8 100644 --- a/src/node-host/invoke-system-run-allowlist.ts +++ b/src/node-host/invoke-system-run-allowlist.ts @@ -23,6 +23,12 @@ import { } from "../infra/shell-inline-command.js"; import type { RunResult } from "./invoke-types.js"; +/** + * Allowlist analysis and argv rewriting for node-host system.run. + * + * This module keeps command approval analysis separate from process execution, + * and only rewrites shell transports when the rebuilt command still satisfies policy. + */ const POSIX_SHELL_WRAPPER_NAMES: ReadonlySet = POSIX_SHELL_WRAPPERS; type SystemRunAllowlistAnalysis = { @@ -34,6 +40,7 @@ type SystemRunAllowlistAnalysis = { segmentSatisfiedBy: ExecSegmentSatisfiedBy[]; }; +/** Evaluate system.run argv or shell command against the exec allowlist policy. */ export function evaluateSystemRunAllowlist(params: { shellCommand: string | null; argv: string[]; @@ -95,6 +102,7 @@ export function evaluateSystemRunAllowlist(params: { }; } +/** Resolve the single planned argv that can replace the caller argv after allowlist approval. */ export function resolvePlannedAllowlistArgv(params: { security: ExecSecurity; shellCommand: string | null; @@ -119,6 +127,7 @@ export function resolvePlannedAllowlistArgv(params: { return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null; } +/** Resolve final argv after safe-bin shell rewriting and allowlist revalidation. */ export function resolveSystemRunExecArgv(params: { plannedAllowlistArgv: string[] | undefined; argv: string[]; @@ -152,6 +161,7 @@ export function resolveSystemRunExecArgv(params: { params.segments.length === 1 && params.segments[0]?.argv.length > 0 ) { + // Windows shell transports expose a parsed argv segment that is safer than the wrapper argv. execArgv = params.segments[0].argv; } if ( @@ -197,6 +207,7 @@ export function resolveSystemRunExecArgv(params: { autoAllowSkills: params.autoAllowSkills, }); if (!rebuiltAllowlist.analysisOk || !rebuiltAllowlist.allowlistSatisfied) { + // Rewritten shell commands must prove the same allowlist contract before execution. return null; } execArgv = rewrittenArgv; @@ -264,6 +275,7 @@ function replacePosixShellInlineCommand(params: { return rewritten; } if (token.endsWith(params.oldCommand)) { + // Combined shell flags can leave the inline command in a suffix of the same argv token. rewritten[absoluteValueIndex] = token.slice(0, token.length - params.oldCommand.length) + params.nextCommand; return rewritten; @@ -271,6 +283,7 @@ function replacePosixShellInlineCommand(params: { return null; } +/** Mark truncated output in stderr when possible, otherwise stdout. */ export function applyOutputTruncation(result: RunResult): void { if (!result.truncated) { return; diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index 07de1def9608..615cf79b4906 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -1,5 +1,12 @@ import type { SkillBinTrustEntry, SystemRunApprovalPlan } from "../infra/exec-approvals.js"; +/** + * Shared request/result/event types for node-host command execution. + * + * These contracts are consumed by Gateway invoke handling, approval planning, + * and node-host event emission. + */ +/** Input payload for a node-host system.run invocation. */ export type SystemRunParams = { command: string[]; rawCommand?: string | null; @@ -16,6 +23,7 @@ export type SystemRunParams = { suppressNotifyOnExit?: boolean | null; }; +/** Captured process result returned by system.run execution. */ export type RunResult = { exitCode?: number; timedOut: boolean; @@ -26,6 +34,7 @@ export type RunResult = { truncated: boolean; }; +/** Gateway event payload emitted for exec lifecycle notifications. */ export type ExecEventPayload = { sessionKey: string; runId: string; @@ -39,6 +48,7 @@ export type ExecEventPayload = { suppressNotifyOnExit?: boolean; }; +/** Normalized exec result fields used when building finished events. */ export type ExecFinishedResult = { stdout?: string; stderr?: string; @@ -48,6 +58,7 @@ export type ExecFinishedResult = { success?: boolean; }; +/** Inputs required to emit an exec finished event. */ export type ExecFinishedEventParams = { sessionKey: string; runId: string; @@ -56,6 +67,7 @@ export type ExecFinishedEventParams = { suppressNotifyOnExit?: boolean; }; +/** Provider for trusted skill-bin entries used during approval checks. */ export type SkillBinsProvider = { current(force?: boolean): Promise; }; diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index 6e08cf76fcf7..419bfe2cd495 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -1,6 +1,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +/** + * Plugin node-host command registry bridge. + * + * Node hosts load the active plugin registry, expose registered capabilities + * and commands, and dispatch incoming node-host commands by exact command id. + */ let pluginRegistryLoaderModulePromise: | Promise | undefined; @@ -10,6 +16,7 @@ async function loadPluginRegistryLoaderModule() { return await pluginRegistryLoaderModulePromise; } +/** Ensure plugin registry data is loaded before node-host command dispatch. */ export async function ensureNodeHostPluginRegistry(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -22,6 +29,7 @@ export async function ensureNodeHostPluginRegistry(params: { }); } +/** List registered node-host capabilities and command ids in deterministic order. */ export function listRegisteredNodeHostCapsAndCommands(): { caps: string[]; commands: string[]; @@ -41,6 +49,7 @@ export function listRegisteredNodeHostCapsAndCommands(): { }; } +/** Invoke a registered node-host plugin command, or return null for unknown commands. */ export async function invokeRegisteredNodeHostCommand( command: string, paramsJSON?: string | null, diff --git a/src/node-host/with-timeout.ts b/src/node-host/with-timeout.ts index f97941198930..9766d0dfda33 100644 --- a/src/node-host/with-timeout.ts +++ b/src/node-host/with-timeout.ts @@ -1,5 +1,12 @@ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +/** + * AbortSignal-based timeout wrapper for node-host operations. + * + * The wrapper races work against an abort promise, clears timers/listeners on + * completion, and preserves object-shaped abort reasons as Error properties. + */ +/** Run work with an optional timeout and AbortSignal. */ export async function withTimeout( work: (signal: AbortSignal | undefined) => Promise, timeoutMs?: number, @@ -31,6 +38,7 @@ export async function withTimeout( } finally { clearTimeout(timer); if (abortListener) { + // Remove the listener even when work wins the race to avoid retaining closures. abortCtrl.signal.removeEventListener("abort", abortListener); } } diff --git a/src/pairing/pairing-labels.ts b/src/pairing/pairing-labels.ts index 300945a49eeb..f905a6c82927 100644 --- a/src/pairing/pairing-labels.ts +++ b/src/pairing/pairing-labels.ts @@ -1,6 +1,8 @@ import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { PairingChannel } from "./pairing-store.types.js"; +// Pairing label helpers. Channel adapters can customize the id label shown in +// owner approval prompts; legacy channels fall back to userId. export function resolvePairingIdLabel(channel: PairingChannel): string { return getPairingAdapter(channel)?.idLabel ?? "userId"; } diff --git a/src/pairing/pairing-messages.ts b/src/pairing/pairing-messages.ts index 2c3d30153831..9aecd74d5019 100644 --- a/src/pairing/pairing-messages.ts +++ b/src/pairing/pairing-messages.ts @@ -1,6 +1,8 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { PairingChannel } from "./pairing-store.types.js"; +// User-facing pairing reply formatter sent to unapproved channel users. The +// owner command is formatted through CLI helpers so profiles/aliases stay valid. export function buildPairingReply(params: { channel: PairingChannel; idLine: string; diff --git a/src/pairing/pairing-store.types.ts b/src/pairing/pairing-store.types.ts index d3a98789d953..0806c0da269e 100644 --- a/src/pairing/pairing-store.types.ts +++ b/src/pairing/pairing-store.types.ts @@ -1,14 +1,18 @@ import type { ChannelId } from "../channels/plugins/channel-id.types.js"; import type { ChannelPairingAdapter } from "../channels/plugins/pairing.types.js"; +// Pairing store contracts shared by channel ingress and approval flows. Pairing +// channels use channel ids but keep a narrower alias for readability. export type PairingChannel = ChannelId; +/** Reads approved ids from a channel/account allowFrom store. */ export type ReadChannelAllowFromStoreForAccount = (params: { channel: PairingChannel; accountId: string; env?: NodeJS.ProcessEnv; }) => Promise; +/** Creates or reuses a pending pairing request for one channel account. */ export type UpsertChannelPairingRequestForAccount = (params: { channel: PairingChannel; id: string | number; diff --git a/src/plugin-sdk/access-groups.test.ts b/src/plugin-sdk/access-groups.test.ts index 2dc3e0b36310..b648a0187d8a 100644 --- a/src/plugin-sdk/access-groups.test.ts +++ b/src/plugin-sdk/access-groups.test.ts @@ -1,3 +1,6 @@ +/** + * Tests access group helper behavior exposed through the SDK. + */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { diff --git a/src/plugin-sdk/access-groups.ts b/src/plugin-sdk/access-groups.ts index 084a772d9419..b2bc306d03f0 100644 --- a/src/plugin-sdk/access-groups.ts +++ b/src/plugin-sdk/access-groups.ts @@ -11,35 +11,55 @@ export { ACCESS_GROUP_ALLOW_FROM_PREFIX, parseAccessGroupAllowFromEntry }; /** Resolves membership for an access group using the full OpenClaw config. */ export type AccessGroupMembershipResolver = (params: { + /** Full config, available when membership needs cross-channel or provider state. */ cfg: OpenClawConfig; + /** Access group name referenced by `accessGroup:`. */ name: string; + /** Access group config selected by name. */ group: AccessGroupConfig; + /** Channel where the inbound sender is being checked. */ channel: ChannelId; + /** Channel account id for account-scoped membership checks. */ accountId: string; + /** Inbound sender id or handle being authorized. */ senderId: string; }) => boolean | Promise; /** Resolves membership for one access group when the caller already selected the config group. */ export type AccessGroupMembershipLookup = (params: { + /** Access group name referenced by `accessGroup:`. */ name: string; + /** Access group config selected by name. */ group: AccessGroupConfig; + /** Channel where the inbound sender is being checked. */ channel: ChannelId; + /** Channel account id for account-scoped membership checks. */ accountId: string; + /** Inbound sender id or handle being authorized. */ senderId: string; }) => boolean | Promise; /** Reports how access-group allowlist entries resolved for a channel sender. */ export type ResolvedAccessGroupAllowFromState = { + /** Unique access group names referenced by the allowlist. */ referenced: string[]; + /** Referenced groups that authorized the sender. */ matched: string[]; + /** Referenced groups absent from config. */ missing: string[]; + /** Referenced groups whose type cannot be evaluated without a resolver. */ unsupported: string[]; + /** Referenced groups whose resolver threw. */ failed: string[]; + /** Matched allowlist entries in `accessGroup:` form. */ matchedAllowFromEntries: string[]; + /** Whether the input allowlist referenced at least one access group. */ hasReferences: boolean; + /** Whether at least one referenced group authorized the sender. */ hasMatch: boolean; }; +/** Resolve the concrete sender allowlist entries for static message-sender groups. */ function resolveMessageSenderGroupEntries(params: { group: AccessGroupConfig; channel: ChannelId; @@ -52,12 +72,19 @@ function resolveMessageSenderGroupEntries(params: { /** Resolves `accessGroup:` allowlist entries without changing the original allowlist. */ export async function resolveAccessGroupAllowFromState(params: { + /** Configured access groups keyed by name. */ accessGroups?: Record; + /** Raw allowlist entries that may include `accessGroup:` references. */ allowFrom: Array | null | undefined; + /** Channel where the inbound sender is being checked. */ channel: ChannelId; + /** Channel account id for account-scoped membership checks. */ accountId: string; + /** Inbound sender id or handle being authorized. */ senderId: string; + /** Static sender matcher used for `message.senders` groups. */ isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + /** Optional resolver for non-static or integration-backed group types. */ resolveMembership?: AccessGroupMembershipLookup; }): Promise { const names = Array.from( @@ -97,6 +124,8 @@ export async function resolveAccessGroupAllowFromState(params: { continue; } + // Static sender groups are fully decided above; resolver hooks cover future + // group types or integration-backed membership without rechecking static entries. if (!params.resolveMembership) { if (group.type !== "message.senders") { state.unsupported.push(name); @@ -130,12 +159,19 @@ export async function resolveAccessGroupAllowFromState(params: { /** Returns the matched `accessGroup:` allowlist entries for a sender. */ export async function resolveAccessGroupAllowFromMatches(params: { + /** Full config containing `accessGroups`. */ cfg?: OpenClawConfig; + /** Raw allowlist entries that may include `accessGroup:` references. */ allowFrom: Array | null | undefined; + /** Channel where the inbound sender is being checked. */ channel: ChannelId; + /** Channel account id for account-scoped membership checks. */ accountId: string; + /** Inbound sender id or handle being authorized. */ senderId: string; + /** Static sender matcher used for `message.senders` groups. */ isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + /** Optional resolver for non-static or integration-backed group types. */ resolveMembership?: AccessGroupMembershipResolver; }): Promise { const cfg = params.cfg; @@ -161,13 +197,21 @@ export async function resolveAccessGroupAllowFromMatches(params: { /** Expands a matching access-group allowlist with the concrete sender entry. */ export async function expandAllowFromWithAccessGroups(params: { + /** Full config containing `accessGroups`. */ cfg?: OpenClawConfig; + /** Raw allowlist entries that may include `accessGroup:` references. */ allowFrom: Array | null | undefined; + /** Channel where the inbound sender is being checked. */ channel: ChannelId; + /** Channel account id for account-scoped membership checks. */ accountId: string; + /** Inbound sender id or handle being authorized. */ senderId: string; + /** Concrete allowlist entry appended after a group match; defaults to `senderId`. */ senderAllowEntry?: string; + /** Static sender matcher used for `message.senders` groups. */ isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + /** Optional resolver for non-static or integration-backed group types. */ resolveMembership?: AccessGroupMembershipResolver; }): Promise { const allowFrom = (params.allowFrom ?? []).map(String); diff --git a/src/plugin-sdk/account-helpers.ts b/src/plugin-sdk/account-helpers.ts index 8c3af81cdbaa..c554649ce621 100644 --- a/src/plugin-sdk/account-helpers.ts +++ b/src/plugin-sdk/account-helpers.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel account config merge and snapshot helpers. + */ export { createAccountListHelpers, describeAccountSnapshot, diff --git a/src/plugin-sdk/account-id.ts b/src/plugin-sdk/account-id.ts index c456249cb690..8fc0807ba347 100644 --- a/src/plugin-sdk/account-id.ts +++ b/src/plugin-sdk/account-id.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for normalized account id helpers. + */ export { DEFAULT_ACCOUNT_ID, normalizeAccountId, diff --git a/src/plugin-sdk/account-resolution-runtime.ts b/src/plugin-sdk/account-resolution-runtime.ts index bf657d788491..6933e80468ba 100644 --- a/src/plugin-sdk/account-resolution-runtime.ts +++ b/src/plugin-sdk/account-resolution-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for resolving configured channel account entries. + */ export { resolveMergedAccountConfig } from "../channels/plugins/account-helpers.js"; export { resolveNormalizedAccountEntry } from "../routing/account-lookup.js"; export { listConfiguredAccountIds } from "./account-configured-ids.js"; diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index 79de8d99a895..eb2cccd896ab 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for account id normalization and account matching helpers. + */ export * from "./account-core.js"; diff --git a/src/plugin-sdk/acp-runtime-backend.ts b/src/plugin-sdk/acp-runtime-backend.ts index d736e3d7b751..91ae2dca5b89 100644 --- a/src/plugin-sdk/acp-runtime-backend.ts +++ b/src/plugin-sdk/acp-runtime-backend.ts @@ -36,10 +36,16 @@ let dispatchAcpRuntimePromise: Promise< > | null = null; function loadDispatchAcpRuntime() { + // ACP dispatch pulls in session/media/manager code; cache the dynamic import so + // startup-loaded plugin surfaces stay light and concurrent hooks share one load. dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js"); return dispatchAcpRuntimePromise; } +/** + * Dispatch a plugin reply hook through ACP when the event targets an ACP-bound session. + * Returns a handled result only when ACP consumes the reply; otherwise callers continue normal delivery. + */ export async function tryDispatchAcpReplyHook( event: PluginHookReplyDispatchEvent, ctx: PluginHookReplyDispatchContext, diff --git a/src/plugin-sdk/agent-core.test.ts b/src/plugin-sdk/agent-core.test.ts index 9c3e6af3cb4e..e8c6e8c44592 100644 --- a/src/plugin-sdk/agent-core.test.ts +++ b/src/plugin-sdk/agent-core.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent core SDK exports and fixture-backed contracts. + */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/agent-dir-compat.test.ts b/src/plugin-sdk/agent-dir-compat.test.ts index afea8f294ecc..b0ed8ae28bd8 100644 --- a/src/plugin-sdk/agent-dir-compat.test.ts +++ b/src/plugin-sdk/agent-dir-compat.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent directory compatibility helpers. + */ import { describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "./agent-dir-compat.js"; diff --git a/src/plugin-sdk/agent-harness-runtime.test.ts b/src/plugin-sdk/agent-harness-runtime.test.ts index 41a3a056fa95..4fc8b38e842b 100644 --- a/src/plugin-sdk/agent-harness-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent harness runtime helpers and task dispatch behavior. + */ import { describe, expect, it } from "vitest"; import { classifyAgentHarnessTerminalOutcome, diff --git a/src/plugin-sdk/agent-harness-task-runtime.test.ts b/src/plugin-sdk/agent-harness-task-runtime.test.ts index 32ad23babc44..4893793a2d7b 100644 --- a/src/plugin-sdk/agent-harness-task-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-task-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests agent harness task runtime scope, persistence, and completion delivery. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { deliverSubagentAnnouncement } from "../agents/subagent-announce-delivery.js"; import { createAgentHarnessTaskRuntimeScope } from "../tasks/agent-harness-task-runtime-scope.js"; diff --git a/src/plugin-sdk/agent-harness-task-runtime.ts b/src/plugin-sdk/agent-harness-task-runtime.ts index 4496c7d9667c..bd7f2cb660c0 100644 --- a/src/plugin-sdk/agent-harness-task-runtime.ts +++ b/src/plugin-sdk/agent-harness-task-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK helpers for agent harness task persistence and completion delivery. + */ import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { buildAnnounceIdempotencyKey } from "../agents/announce-idempotency.js"; import { @@ -37,6 +40,7 @@ type RecordTaskRunProgressParams = Parameters[0]; type SetDeliveryStatusParams = Parameters[0]; +/** Scope and naming options used to bind task operations to one requester session. */ export type AgentHarnessTaskRuntimeScopeParams = { runtime: AgentHarnessTaskRuntimeId; scope: AgentHarnessTaskRuntimeScope; @@ -66,6 +70,7 @@ export type AgentHarnessScopedSetDeliveryStatusParams = Omit< "runtime" | "sessionKey" >; +/** Scoped task runtime that prevents callers from mutating tasks outside their harness scope. */ export type AgentHarnessTaskRuntime = { createRunningTaskRun(params: AgentHarnessScopedCreateRunningTaskRunParams): TaskRecord; tryCreateRunningTaskRun(params: AgentHarnessScopedCreateRunningTaskRunParams): TaskRecord | null; @@ -85,6 +90,7 @@ export type AgentHarnessCompletionDelivery = Awaited< const AGENT_HARNESS_COMPLETION_SOURCE_TOOL = "agent_harness_task"; +/** Creates a task runtime whose run ids and task records are constrained to one scope. */ export function createAgentHarnessTaskRuntime( params: AgentHarnessTaskRuntimeScopeParams, ): AgentHarnessTaskRuntime { @@ -153,6 +159,7 @@ export function createAgentHarnessTaskRuntime( }; } +/** Delivers a completed harness task result back to the requester or parent session. */ export async function deliverAgentHarnessTaskCompletion(params: { scope: AgentHarnessTaskRuntimeScope; childSessionKey: string; @@ -240,6 +247,7 @@ function mapHarnessCompletionStatus( return "error"; } +/** Returns true when completion delivery reached a persistent direct or steered path. */ export function isDurableAgentHarnessCompletionDelivery( delivery: AgentHarnessCompletionDelivery, ): boolean { diff --git a/src/plugin-sdk/agent-sessions.ts b/src/plugin-sdk/agent-sessions.ts index e5488bc7fca0..8e3e7d0d341f 100644 --- a/src/plugin-sdk/agent-sessions.ts +++ b/src/plugin-sdk/agent-sessions.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for agent session entry types and persistence helpers. + */ export * from "../agents/sessions/index.js"; diff --git a/src/plugin-sdk/allow-from.test.ts b/src/plugin-sdk/allow-from.test.ts index cf772312e21d..544ade97d8a0 100644 --- a/src/plugin-sdk/allow-from.test.ts +++ b/src/plugin-sdk/allow-from.test.ts @@ -1,3 +1,6 @@ +/** + * Tests allow-from parsing and normalization helpers. + */ import { describe, expect, it } from "vitest"; import { formatAllowFromLowercase, diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index 8199b0556e6e..5b29df20c712 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -33,7 +33,9 @@ export { /** Lowercase and optionally strip prefixes from allowlist entries before sender comparisons. */ export function formatAllowFromLowercase(params: { + /** Raw allowlist entries from config or channel-specific overrides. */ allowFrom: Array; + /** Optional prefix remover for channel aliases such as `tg:` or `zalo:`. */ stripPrefixRe?: RegExp; }): string[] { return normalizeStringEntries(params.allowFrom) @@ -44,7 +46,9 @@ export function formatAllowFromLowercase(params: { /** Normalize allowlist entries through a channel-provided parser or canonicalizer. */ export function formatNormalizedAllowFromEntries(params: { + /** Raw allowlist entries from config or channel-specific overrides. */ allowFrom: Array; + /** Channel-specific canonicalizer; empty results are omitted. */ normalizeEntry: (entry: string) => string | undefined | null; }): string[] { return normalizeStringEntries(params.allowFrom) @@ -54,8 +58,11 @@ export function formatNormalizedAllowFromEntries(params: { /** Check whether a sender id matches a simple normalized allowlist with wildcard support. */ export function isNormalizedSenderAllowed(params: { + /** Sender id or handle to compare after string coercion and lowercase normalization. */ senderId: string | number; + /** Raw allowlist entries; `*` allows every sender. */ allowFrom: Array; + /** Optional prefix remover applied to allowlist entries before comparison. */ stripPrefixRe?: RegExp; }): boolean { const normalizedAllow = formatAllowFromLowercase({ @@ -63,6 +70,7 @@ export function isNormalizedSenderAllowed(params: { stripPrefixRe: params.stripPrefixRe, }); if (normalizedAllow.length === 0) { + // Empty allowlists deny by default; callers must opt into wildcard access explicitly. return false; } if (normalizedAllow.includes("*")) { @@ -80,23 +88,36 @@ type ParsedChatAllowTarget = /** Match allowlist entries against senders, with conversation targets requiring explicit opt-in. */ export function isAllowedParsedChatSender(params: { + /** Raw allowlist entries, including handles, wildcard, or parsed chat targets. */ allowFrom: Array; + /** Sender handle/id from the inbound message. */ sender: string; + /** Optional numeric conversation id for channel-specific chat target entries. */ chatId?: number | null; + /** Optional stable conversation guid for channel-specific chat target entries. */ chatGuid?: string | null; + /** Optional human/channel conversation identifier for chat target entries. */ chatIdentifier?: string | null; + /** Enables matching conversation targets in addition to sender handles. */ allowConversationTargets?: boolean | null; + /** Channel-specific sender normalization hook. */ normalizeSender: (sender: string) => string; + /** Channel-specific allowlist parser for handles and conversation targets. */ parseAllowTarget: (entry: string) => ParsedChatAllowTarget; }): boolean { return isAllowedParsedChatSenderShared(params); } export type BasicAllowlistResolutionEntry = { + /** Original allowlist input. */ input: string; + /** Whether resolution found a concrete account/user id. */ resolved: boolean; + /** Resolved id when available. */ id?: string; + /** Resolved display name when available. */ name?: string; + /** Optional resolver note for UI or docs output. */ note?: string; }; @@ -115,7 +136,9 @@ export function mapBasicAllowlistResolutionEntries( /** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ export async function mapAllowlistResolutionInputs(params: { + /** Ordered allowlist inputs to resolve. */ inputs: string[]; + /** Resolver callback invoked once per input in order. */ mapInput: (input: string) => Promise | T; }): Promise { const results: T[] = []; diff --git a/src/plugin-sdk/allowlist-config-edit.test.ts b/src/plugin-sdk/allowlist-config-edit.test.ts index 33bbf5f2ad88..774c85d0c4ee 100644 --- a/src/plugin-sdk/allowlist-config-edit.test.ts +++ b/src/plugin-sdk/allowlist-config-edit.test.ts @@ -1,3 +1,6 @@ +/** + * Tests allowlist config edit helpers for flat, nested, and account-scoped records. + */ import { describe, expect, it } from "vitest"; import { buildDmGroupAccountAllowlistAdapter, diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index a2bff121e587..e5d8be782fd5 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -11,7 +11,10 @@ type AllowlistConfigPaths = { cleanupPaths?: string[][]; }; +/** Named allowlist entries attached to a route-specific override. */ export type AllowlistGroupOverride = { label: string; entries: string[] }; + +/** Per-entry display-name lookup results for channel allowlist UIs. */ export type AllowlistNameResolution = Array<{ input: string; resolved: boolean; @@ -43,10 +46,12 @@ const LEGACY_DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { cleanupPaths: [["dm", "allowFrom"]], }; +/** Resolve modern DM/group allowlist paths for account-scoped channel config writes. */ export function resolveDmGroupAllowlistConfigPaths(scope: "dm" | "group") { return scope === "dm" ? DM_ALLOWLIST_CONFIG_PATHS : GROUP_ALLOWLIST_CONFIG_PATHS; } +/** Resolve DM-only paths that still read and clean up the old nested dm.allowFrom location. */ export function resolveLegacyDmAllowlistConfigPaths(scope: "dm" | "group") { return scope === "dm" ? LEGACY_DM_ALLOWLIST_CONFIG_PATHS : null; } @@ -181,6 +186,8 @@ function resolveAccountScopedWriteTarget( writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget, }; } + // Once an accounts map exists, even the default account writes through it so scoped + // and unscoped config do not diverge inside the same channel stanza. const accounts = (channel.accounts ??= {}) as Record; const existingAccount = Object.hasOwn(accounts, normalizedAccountId) ? accounts[normalizedAccountId] @@ -316,6 +323,7 @@ function applyAccountScopedAllowlistConfigEdit(params: { } else { setNestedValue(resolvedTarget.target, params.paths.writePath, next); } + // Legacy readers can observe multiple paths, but writes must leave one canonical path. for (const path of params.paths.cleanupPaths ?? []) { deleteNestedValue(resolvedTarget.target, path); } diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts index d08b083fd1d8..35b79828700e 100644 --- a/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.preflight.test.ts @@ -1,3 +1,6 @@ +/** + * Preflight tests for Anthropic Vertex auth presence helpers. + */ import { afterEach, describe, expect, it, vi } from "vitest"; const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({ diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts index 2e94cd69381e..b3e61f2764c6 100644 --- a/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Anthropic Vertex auth presence helpers. + */ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/anthropic-vertex-auth-presence.ts b/src/plugin-sdk/anthropic-vertex-auth-presence.ts index 4dff81710e33..a20980a1573f 100644 --- a/src/plugin-sdk/anthropic-vertex-auth-presence.ts +++ b/src/plugin-sdk/anthropic-vertex-auth-presence.ts @@ -39,6 +39,8 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate( if (explicit) { return explicit; } + // Only probe the user's default ADC file for the real process environment; injected + // test/runtime env objects should not accidentally depend on host filesystem state. if (env !== process.env) { return undefined; } @@ -58,6 +60,10 @@ function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolea } } +/** + * Return whether Anthropic Vertex can authenticate through GCP metadata or ADC credentials. + * This is a preflight signal only; provider calls still perform their own auth validation. + */ export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean { return hasAnthropicVertexMetadataServerAdc(env) || canReadAnthropicVertexAdc(env); } diff --git a/src/plugin-sdk/anthropic-vertex.ts b/src/plugin-sdk/anthropic-vertex.ts index d6ec752204a6..34cd17ce6df6 100644 --- a/src/plugin-sdk/anthropic-vertex.ts +++ b/src/plugin-sdk/anthropic-vertex.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for Anthropic Vertex implicit provider discovery and config helpers. + */ import type { ModelProviderConfig } from "../config/types.js"; import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; @@ -24,12 +27,14 @@ function loadFacadeModule(): FacadeModule { }); } +/** Resolves the Anthropic Vertex region through the activated bundled provider facade. */ export const resolveAnthropicVertexClientRegion: FacadeModule["resolveAnthropicVertexClientRegion"] = ((...args) => loadFacadeModule().resolveAnthropicVertexClientRegion( ...args, )) as FacadeModule["resolveAnthropicVertexClientRegion"]; +/** Resolves the Anthropic Vertex project id through the activated provider facade. */ export const resolveAnthropicVertexProjectId: FacadeModule["resolveAnthropicVertexProjectId"] = (( ...args ) => diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts index 762702074fbf..2f478f2de5bf 100644 --- a/src/plugin-sdk/api-baseline.test.ts +++ b/src/plugin-sdk/api-baseline.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the plugin SDK public API baseline. + */ import path from "node:path"; import { describe, expect, it } from "vitest"; import { normalizePluginSdkApiDeclarationText } from "./api-baseline.js"; diff --git a/src/plugin-sdk/approval-approvers.ts b/src/plugin-sdk/approval-approvers.ts index efd26d6c29bb..3f7598e135d6 100644 --- a/src/plugin-sdk/approval-approvers.ts +++ b/src/plugin-sdk/approval-approvers.ts @@ -1,3 +1,6 @@ +/** + * Public SDK helper for deriving normalized approval approver ids. + */ import { uniqueStrings } from "../../packages/normalization-core/src/string-normalization.js"; type ApproverInput = string | number; @@ -6,6 +9,7 @@ function dedupeDefined(values: Array): string[] { return uniqueStrings(values.filter((value): value is string => Boolean(value))); } +/** Resolves explicit approvers first, then allow-from/default fallbacks with dedupe. */ export function resolveApprovalApprovers(params: { explicit?: readonly ApproverInput[] | null; allowFrom?: readonly ApproverInput[] | null; diff --git a/src/plugin-sdk/approval-auth-helpers.test.ts b/src/plugin-sdk/approval-auth-helpers.test.ts index c43846cb8aef..b32d4d5b9103 100644 --- a/src/plugin-sdk/approval-auth-helpers.test.ts +++ b/src/plugin-sdk/approval-auth-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests approval auth helper decisions and implicit same-chat authorization markers. + */ import { describe, expect, it } from "vitest"; import { createResolvedApproverActionAuthAdapter, diff --git a/src/plugin-sdk/approval-auth-helpers.ts b/src/plugin-sdk/approval-auth-helpers.ts index b2817ec7226d..36a442214ed3 100644 --- a/src/plugin-sdk/approval-auth-helpers.ts +++ b/src/plugin-sdk/approval-auth-helpers.ts @@ -3,14 +3,21 @@ import type { OpenClawConfig } from "./config-runtime.js"; type ApprovalKind = "exec" | "plugin"; type ApprovalAuthorizationResult = { + /** Whether the actor may perform the approval action. */ authorized: boolean; + /** User-facing denial reason when authorization fails. */ reason?: string; }; const IMPLICIT_SAME_CHAT_APPROVAL_AUTHORIZATION = Symbol( "openclaw.implicitSameChatApprovalAuthorization", ); +/** + * Marks an authorization result as the implicit same-chat fallback used when a + * channel has no configured approver allowlist. + */ export function markImplicitSameChatApprovalAuthorization( + /** Authorization result to tag as the empty-approver same-chat fallback. */ result: ApprovalAuthorizationResult, ): ApprovalAuthorizationResult { // Keep this non-enumerable to avoid changing auth payload shape. @@ -24,7 +31,12 @@ export function markImplicitSameChatApprovalAuthorization( return result; } +/** + * Checks whether an authorization result came from the implicit same-chat + * fallback instead of an explicitly configured approver allowlist. + */ export function isImplicitSameChatApprovalAuthorization( + /** Authorization result returned by approval auth helpers. */ result: ApprovalAuthorizationResult | null | undefined, ): boolean { return Boolean( @@ -37,9 +49,16 @@ export function isImplicitSameChatApprovalAuthorization( ); } +/** + * Builds the approval authorization adapter shared by channels that resolve + * approvers from account-scoped config. + */ export function createResolvedApproverActionAuthAdapter(params: { + /** Human-readable channel label used in denial messages. */ channelLabel: string; + /** Resolves normalized approver ids from config and optional account scope. */ resolveApprovers: (params: { cfg: OpenClawConfig; accountId?: string | null }) => string[]; + /** Optional sender normalizer; defaults to trimmed string normalization. */ normalizeSenderId?: (value: string) => string | undefined; }) { const normalizeSenderId = params.normalizeSenderId ?? normalizeOptionalString; @@ -51,10 +70,15 @@ export function createResolvedApproverActionAuthAdapter(params: { senderId, approvalKind, }: { + /** Full config used to resolve account-scoped approvers. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped approver config. */ accountId?: string | null; + /** Actor attempting the approval action. */ senderId?: string | null; + /** Approval action being authorized. */ action: "approve"; + /** Approval kind used in user-facing denial copy. */ approvalKind: ApprovalKind; }) { const approvers = params.resolveApprovers({ cfg, accountId }); diff --git a/src/plugin-sdk/approval-auth-runtime.ts b/src/plugin-sdk/approval-auth-runtime.ts index e77951600471..5304e1841dc9 100644 --- a/src/plugin-sdk/approval-auth-runtime.ts +++ b/src/plugin-sdk/approval-auth-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for approval auth adapters and same-chat authorization markers. + */ export { resolveApprovalApprovers } from "./approval-approvers.js"; export { createResolvedApproverActionAuthAdapter, diff --git a/src/plugin-sdk/approval-client-helpers.test.ts b/src/plugin-sdk/approval-client-helpers.test.ts index 3a3b2585cbf4..d9f4b4e70f8c 100644 --- a/src/plugin-sdk/approval-client-helpers.test.ts +++ b/src/plugin-sdk/approval-client-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests approval client helper filters and target recipient matching. + */ import { describe, expect, it } from "vitest"; import { createChannelExecApprovalProfile, diff --git a/src/plugin-sdk/approval-client-helpers.ts b/src/plugin-sdk/approval-client-helpers.ts index 7eaa55b5471c..33715d7b209d 100644 --- a/src/plugin-sdk/approval-client-helpers.ts +++ b/src/plugin-sdk/approval-client-helpers.ts @@ -16,14 +16,20 @@ type ApprovalTarget = "dm" | "channel" | "both"; type ChannelExecApprovalEnableMode = boolean | "auto"; type ChannelApprovalConfig = { + /** Whether the channel approval client is enabled for this account. */ enabled?: ChannelExecApprovalEnableMode; + /** Preferred approval delivery target for this account. */ target?: ApprovalTarget; + /** Optional agent filters for forwarded approval requests. */ agentFilter?: string[]; + /** Optional session filters for forwarded approval requests. */ sessionFilter?: string[]; }; type ApprovalProfileParams = { + /** Full config used to resolve account-scoped approval settings. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped approval settings. */ accountId?: string | null; }; @@ -37,8 +43,11 @@ function isApprovalTargetsMode(cfg: OpenClawConfig): boolean { export { getExecApprovalReplyMetadata, matchesApprovalRequestFilters }; +/** Return whether a channel account has an enabled approval client and at least one approver. */ export function isChannelExecApprovalClientEnabledFromConfig(params: { + /** Configured channel approval enable mode. */ enabled?: ChannelExecApprovalEnableMode; + /** Number of configured approvers after account resolution. */ approverCount: number; }): boolean { if (params.approverCount <= 0) { @@ -47,12 +56,22 @@ export function isChannelExecApprovalClientEnabledFromConfig(params: { return params.enabled === true || params.enabled === "auto"; } +/** + * Return whether a sender is one of the configured global exec approval forward targets. + * Channel plugins provide the target matcher because `to` shapes differ by provider. + */ export function isChannelExecApprovalTargetRecipient(params: { + /** Full config containing global exec approval target routing. */ cfg: OpenClawConfig; + /** Sender id or handle to compare with configured forward targets. */ senderId?: string | null; + /** Optional channel account id for account-scoped target matching. */ accountId?: string | null; + /** Channel id receiving the approval action. */ channel: string; + /** Optional sender normalizer; defaults to trimmed string normalization. */ normalizeSenderId?: (value: string) => string | undefined; + /** Channel-specific matcher for normalized sender ids against target records. */ matchTarget: (params: { target: ExecApprovalForwardTarget; normalizedSenderId: string; @@ -74,6 +93,7 @@ export function isChannelExecApprovalTargetRecipient(params: { if (normalizeOptionalLowercaseString(target.channel) !== normalizedChannel) { return false; } + // Account-scoped targets only match the same account; targets without accountId stay global. if ( normalizedAccountId && target.accountId && @@ -89,14 +109,24 @@ export function isChannelExecApprovalTargetRecipient(params: { }); } +/** + * Build the common approval-client profile used by channel plugins. + * The returned helpers centralize enablement, approver auth, request filters, and local prompt suppression. + */ export function createChannelExecApprovalProfile(params: { + /** Resolves channel approval config for the current account. */ resolveConfig: (params: ApprovalProfileParams) => ChannelApprovalConfig | undefined; + /** Resolves normalized approver ids for the current account. */ resolveApprovers: (params: ApprovalProfileParams) => string[]; + /** Optional sender normalizer; defaults to trimmed string normalization. */ normalizeSenderId?: (value: string) => string | undefined; + /** Optional global approval-target matcher for sender authorization. */ isTargetRecipient?: (params: ApprovalProfileParams & { senderId?: string | null }) => boolean; + /** Optional account matcher for filtering forwarded approval requests. */ matchesRequestAccount?: (params: ApprovalProfileParams & { request: ApprovalRequest }) => boolean; // Some channels encode the effective agent only in sessionKey for forwarded approvals. fallbackAgentIdFromSessionKey?: boolean; + /** Allows local prompt suppression even when the remote approval client is disabled. */ requireClientEnabledForLocalPromptSuppression?: boolean; }) { const normalizeSenderId = params.normalizeSenderId ?? normalizeOptionalString; @@ -161,11 +191,17 @@ export function createChannelExecApprovalProfile(params: { }; return { + /** Whether this account has an enabled channel approval client and approvers. */ isClientEnabled, + /** Whether a sender is in the resolved approver set. */ isApprover, + /** Whether a sender is either an approver or a configured approval target. */ isAuthorizedSender, + /** Preferred delivery target, defaulting to approver DMs. */ resolveTarget, + /** Whether this profile should handle a forwarded approval request. */ shouldHandleRequest, + /** Whether a local approval prompt should be suppressed for an already-rendered payload. */ shouldSuppressLocalPrompt, }; } diff --git a/src/plugin-sdk/approval-client-runtime.ts b/src/plugin-sdk/approval-client-runtime.ts index 62cc2672a29f..c31138d890e3 100644 --- a/src/plugin-sdk/approval-client-runtime.ts +++ b/src/plugin-sdk/approval-client-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for channel exec approval client helpers. + */ export { createChannelExecApprovalProfile, getExecApprovalReplyMetadata, diff --git a/src/plugin-sdk/approval-delivery-helpers.test.ts b/src/plugin-sdk/approval-delivery-helpers.test.ts index aadd4d6fe593..2aae8cae6e98 100644 --- a/src/plugin-sdk/approval-delivery-helpers.test.ts +++ b/src/plugin-sdk/approval-delivery-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests approval delivery helper capability composition. + */ import { describe, expect, it, vi } from "vitest"; import { createApproverRestrictedNativeApprovalAdapter, diff --git a/src/plugin-sdk/approval-delivery-helpers.ts b/src/plugin-sdk/approval-delivery-helpers.ts index 2871ece03fb0..0a3e24eddc4d 100644 --- a/src/plugin-sdk/approval-delivery-helpers.ts +++ b/src/plugin-sdk/approval-delivery-helpers.ts @@ -15,49 +15,72 @@ type ChannelApprovalCapabilitySurfaces = Pick< >; type ApprovalAdapterParams = { + /** Full config used to inspect channel approval settings. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped approval settings. */ accountId?: string | null; + /** Actor attempting the approval action. */ senderId?: string | null; }; type DeliverySuppressionParams = { + /** Full config used to inspect native approval delivery settings. */ cfg: OpenClawConfig; + /** Approval kind being delivered. */ approvalKind: ApprovalKind; + /** Forwarding fallback target under consideration. */ target: { channel: string; accountId?: string | null }; + /** Approval request metadata, including original turn source when available. */ request: { request: { turnSourceChannel?: string | null; turnSourceAccountId?: string | null } }; }; type ApproverRestrictedNativeApprovalParams = { + /** Channel id that owns this native approval capability. */ channel: string; + /** Human-readable channel label used in denial messages. */ channelLabel: string; + /** Lists configured account ids so DM-route availability can scan every account. */ listAccountIds: (cfg: OpenClawConfig) => string[]; + /** Whether an account has approvers configured. */ hasApprovers: (params: ApprovalAdapterParams) => boolean; + /** Whether a sender can approve exec approvals for this account. */ isExecAuthorizedSender: (params: ApprovalAdapterParams) => boolean; + /** Optional plugin approval authorization hook; defaults to exec authorization. */ isPluginAuthorizedSender?: (params: ApprovalAdapterParams) => boolean; + /** Whether native approval delivery is enabled for an account. */ isNativeDeliveryEnabled: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean; + /** Native delivery target preference for an account. */ resolveNativeDeliveryMode: (params: { cfg: OpenClawConfig; accountId?: string | null; }) => NativeApprovalDeliveryMode; + /** Requires the approval request's original turn channel to match this channel before suppression. */ requireMatchingTurnSourceChannel?: boolean; + /** Optional account id resolver used when deciding forwarding-fallback suppression. */ resolveSuppressionAccountId?: (params: DeliverySuppressionParams) => string | undefined; + /** Resolves the original channel target for native approval delivery. */ resolveOriginTarget?: (params: { cfg: OpenClawConfig; accountId?: string | null; approvalKind: ApprovalKind; request: NativeApprovalRequest; }) => NativeApprovalTarget | null | Promise; + /** Resolves approver DM targets for native approval delivery. */ resolveApproverDmTargets?: (params: { cfg: OpenClawConfig; accountId?: string | null; approvalKind: ApprovalKind; request: NativeApprovalRequest; }) => NativeApprovalTarget[] | Promise; + /** Whether DM-only native delivery should also notify the origin channel. */ notifyOriginWhenDmOnly?: boolean; + /** Native runtime hooks used by channel-specific delivery implementations. */ nativeRuntime?: ChannelApprovalCapability["nativeRuntime"]; + /** Optional setup description helper shown when exec approvals are unavailable. */ describeExecApprovalSetup?: ChannelApprovalCapability["describeExecApprovalSetup"]; }; +/** Build the canonical approval capability for channels that restrict approvals to configured approvers. */ function buildApproverRestrictedNativeApprovalCapability( params: ApproverRestrictedNativeApprovalParams, ): ChannelApprovalCapability { @@ -157,6 +180,8 @@ function buildApproverRestrictedNativeApprovalCapability( (resolvedAccountId === undefined ? input.target.accountId?.trim() : resolvedAccountId.trim()) || undefined; + // Suppress generic forwarding only when this channel's native route can + // handle the same account; otherwise the fallback is the only delivery path. return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId }); }, }, @@ -188,25 +213,38 @@ function buildApproverRestrictedNativeApprovalCapability( }); } +/** Build the legacy split approval adapter shape for approver-restricted native channels. */ export function createApproverRestrictedNativeApprovalAdapter( params: ApproverRestrictedNativeApprovalParams, ) { return splitChannelApprovalCapability(buildApproverRestrictedNativeApprovalCapability(params)); } +/** Assemble a channel approval capability from its auth, delivery, render, and native surfaces. */ export function createChannelApprovalCapability(params: { + /** Authorizes actors attempting approval actions. */ authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"]; + /** Reports whether approval actions are generally available. */ getActionAvailabilityState?: ChannelApprovalCapability["getActionAvailabilityState"]; + /** Reports whether exec approvals can start from the initiating surface. */ getExecInitiatingSurfaceState?: ChannelApprovalCapability["getExecInitiatingSurfaceState"]; + /** Optional command behavior override for approval replies. */ resolveApproveCommandBehavior?: ChannelApprovalCapability["resolveApproveCommandBehavior"]; + /** Optional setup copy for unavailable exec approval paths. */ describeExecApprovalSetup?: ChannelApprovalCapability["describeExecApprovalSetup"]; + /** Delivery fallback and DM-route helpers. */ delivery?: ChannelApprovalCapability["delivery"]; + /** Native runtime hooks for channel-specific approval delivery. */ nativeRuntime?: ChannelApprovalCapability["nativeRuntime"]; + /** Render hooks for pending/resolved approval payloads. */ render?: ChannelApprovalCapability["render"]; + /** Native target/capability discovery hooks. */ native?: ChannelApprovalCapability["native"]; /** @deprecated Pass delivery/nativeRuntime/render/native directly. */ approvals?: Partial; }): ChannelApprovalCapability { + // Keep the approvals alias for shipped plugin-sdk callers; registry tests track + // this compatibility marker until the public deprecation window closes. const surfaces: ChannelApprovalCapabilitySurfaces = { delivery: params.delivery ?? params.approvals?.delivery, nativeRuntime: params.nativeRuntime ?? params.approvals?.nativeRuntime, @@ -226,6 +264,7 @@ export function createChannelApprovalCapability(params: { }; } +/** Split the canonical approval capability into the adapter shape older channel loaders consume. */ export function splitChannelApprovalCapability(capability: ChannelApprovalCapability): { auth: { authorizeActorAction?: ChannelApprovalCapability["authorizeActorAction"]; @@ -254,6 +293,7 @@ export function splitChannelApprovalCapability(capability: ChannelApprovalCapabi }; } +/** Build the canonical approval capability for approver-restricted native delivery channels. */ export function createApproverRestrictedNativeApprovalCapability( params: ApproverRestrictedNativeApprovalParams, ): ChannelApprovalCapability { diff --git a/src/plugin-sdk/approval-delivery-runtime.ts b/src/plugin-sdk/approval-delivery-runtime.ts index 4eb4c0cc90f5..3dc6749e7142 100644 --- a/src/plugin-sdk/approval-delivery-runtime.ts +++ b/src/plugin-sdk/approval-delivery-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for creating and splitting channel approval capabilities. + */ export { createApproverRestrictedNativeApprovalAdapter, createApproverRestrictedNativeApprovalCapability, diff --git a/src/plugin-sdk/approval-gateway-runtime.ts b/src/plugin-sdk/approval-gateway-runtime.ts index 6422655c267b..b6517d30da7e 100644 --- a/src/plugin-sdk/approval-gateway-runtime.ts +++ b/src/plugin-sdk/approval-gateway-runtime.ts @@ -1 +1,4 @@ +/** + * Runtime SDK subpath for resolving approval requests over the gateway. + */ export { resolveApprovalOverGateway } from "../infra/approval-gateway-resolver.js"; diff --git a/src/plugin-sdk/approval-handler-adapter-runtime.ts b/src/plugin-sdk/approval-handler-adapter-runtime.ts index a3a45436d55f..23ff9731c66a 100644 --- a/src/plugin-sdk/approval-handler-adapter-runtime.ts +++ b/src/plugin-sdk/approval-handler-adapter-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for lazily adapting native channel approval handlers. + */ export { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY, createLazyChannelApprovalNativeRuntimeAdapter, diff --git a/src/plugin-sdk/approval-handler-runtime.ts b/src/plugin-sdk/approval-handler-runtime.ts index 9612335b952b..1ff734e2f475 100644 --- a/src/plugin-sdk/approval-handler-runtime.ts +++ b/src/plugin-sdk/approval-handler-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for approval handler adapters and approval view text helpers. + */ export { createChannelApprovalHandler, createChannelApprovalNativeRuntimeAdapter, @@ -46,6 +49,7 @@ import { buildApprovalResolvedReplyPayload } from "./approval-renderers.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; +/** Builds channel-visible resolved approval text for exec and plugin approvals. */ export function buildChannelApprovalResolvedText(params: { request: ApprovalRequest; resolved: ApprovalResolved; @@ -65,6 +69,7 @@ export function buildChannelApprovalResolvedText(params: { return payload.text ?? ""; } +/** Builds channel-visible expiration text for exec and plugin approvals. */ export function buildChannelApprovalExpiredText(params: { request: ApprovalRequest; view: ExpiredApprovalView; @@ -75,6 +80,7 @@ export function buildChannelApprovalExpiredText(params: { return `⏱️ Exec approval expired. ID: ${params.request.id}`; } +/** Resolves the account id prepared for approval routing with planned/context fallback order. */ export function resolvePreparedApprovalAccountId(params: { plannedAccountId?: string | null; contextAccountId?: string | null; diff --git a/src/plugin-sdk/approval-native-helpers.test.ts b/src/plugin-sdk/approval-native-helpers.test.ts index 34fd3be804c6..31dbc22d3411 100644 --- a/src/plugin-sdk/approval-native-helpers.test.ts +++ b/src/plugin-sdk/approval-native-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests native approval routing helpers and target matching logic. + */ import { describe, expect, it } from "vitest"; import { createChannelApproverDmTargetResolver, diff --git a/src/plugin-sdk/approval-native-helpers.ts b/src/plugin-sdk/approval-native-helpers.ts index cdd474d9eb61..993b789d020a 100644 --- a/src/plugin-sdk/approval-native-helpers.ts +++ b/src/plugin-sdk/approval-native-helpers.ts @@ -74,21 +74,30 @@ type ApprovalOriginOrSessionTargetChecker = ChannelApprovalForwardingEvaluatorParams["hasOriginOrSessionTarget"]; export type ChannelApprovalForwardingEligibilityParams = { + /** Full config containing exec/plugin approval forwarding settings. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped transport checks. */ accountId?: string | null; + /** Approval family whose forwarding config should be evaluated. */ approvalKind: ApprovalKind; + /** Approval request being considered for native delivery. */ request: ApprovalRequest; }; export type ChannelApprovalPotentialRouteParams = { + /** Full config containing exec/plugin approval forwarding settings. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped transport checks. */ accountId?: string | null; + /** Approval family whose forwarding config should be evaluated. */ approvalKind: ApprovalKind; + /** When true, ignore explicit target routes and only consider session/native origin routes. */ nativeSessionOnly?: boolean; }; export type ChannelApprovalExplicitTargetEligibilityParams = ChannelApprovalForwardingEligibilityParams & { + /** Forwarding target that may be handled by the channel-native approval route. */ target: ChannelApprovalForwardTarget; }; @@ -193,36 +202,51 @@ type NativeApprovalChannelRouteGates = { }; type BaseOriginResolverParams = { + /** Channel id whose origin target should be resolved. */ channel: string; + /** Optional gate; returning false prevents native origin delivery. */ shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; + /** Maps request turn-source metadata to a native target. */ resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null; + /** Maps a persisted session target to a native target. */ resolveSessionTarget: ( sessionTarget: ExecApprovalSessionTarget, request: ApprovalRequest, ) => TTarget | null; + /** Normalizes the returned target before delivery. */ normalizeTarget?: NativeApprovalTargetNormalizer; + /** Normalizes only matcher inputs when delivery target shape must stay native. */ normalizeTargetForMatch?: NativeApprovalTargetNormalizer; + /** Optional fallback target when neither turn-source nor session target resolves. */ resolveFallbackTarget?: (request: ApprovalRequest) => TTarget | null; }; type NativeOriginResolverParams = BaseOriginResolverParams & { + /** Optional native target matcher; defaults to route-exact target matching. */ targetsMatch?: (a: TTarget, b: TTarget) => boolean; }; type CustomOriginResolverParams = BaseOriginResolverParams & { + /** Custom matcher required when target shape is not `NativeApprovalTarget`. */ targetsMatch: (a: TTarget, b: TTarget) => boolean; }; export type NativeApprovalTarget = { + /** Channel-local destination id. */ to: string; + /** Optional channel account id associated with the destination. */ accountId?: string | null; + /** Optional thread/topic id inside the destination. */ threadId?: string | number | null; }; export function nativeApprovalTargetsMatch(params: { + /** Channel id used for route target normalization. */ channel?: string | null; + /** Left native target to compare. */ left: NativeApprovalTarget; + /** Right native target to compare. */ right: NativeApprovalTarget; }): boolean { return channelRouteTargetsMatchExact({ @@ -242,25 +266,37 @@ export function nativeApprovalTargetsMatch(params: { } export function shouldSuppressLocalNativeExecApprovalPrompt(params: { + /** Full config containing top-level or channel-specific approval settings. */ cfg: OpenClawConfig; + /** Optional channel account id for account-scoped native delivery checks. */ accountId?: string | null; + /** Reply payload that may already contain exec approval metadata. */ payload: ReplyPayload; + /** Outbound payload hint proving an active native exec approval route. */ hint?: ChannelOutboundPayloadHint; + /** Legacy transport gate for native delivery. */ isTransportEnabled?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean; + /** Preferred transport gate for native delivery. */ isNativeDeliveryEnabled?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean; + /** Optional channel-specific approval config resolver. */ resolveApprovalConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null; metadata: ExecApprovalReplyMetadata; }) => LocalNativeExecApprovalConfig | undefined; + /** Whether the resolved approval config must be enabled before suppressing local prompt. */ requireApprovalConfigEnabled?: boolean; + /** Whether forwarding mode must be session/both unless exact target proof is present. */ enforceForwardingMode?: boolean; + /** Optional session-route gate for the approval metadata. */ isSessionRouteEligible?: (params: { cfg: OpenClawConfig; accountId?: string | null; metadata: ExecApprovalReplyMetadata; }) => boolean; + /** Proof that target-mode forwarding already matched this exact native target. */ hasExactTargetProof?: boolean; + /** Whether agent filters may fall back to the agent segment in sessionKey. */ fallbackAgentIdFromSessionKey?: boolean; }): boolean { if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") { @@ -292,6 +328,8 @@ export function shouldSuppressLocalNativeExecApprovalPrompt(params: { params.enforceForwardingMode ?? params.resolveApprovalConfig === undefined; if (enforceForwardingMode) { const mode = config?.mode ?? "session"; + // In targets-only mode, local prompt suppression requires exact target + // proof so a session/native route cannot hide the only visible prompt. if (mode !== "session" && mode !== "both" && !params.hasExactTargetProof) { return false; } @@ -849,6 +887,8 @@ function createOriginTargetResolver( resolveSessionTarget: (sessionTarget) => normalizeTarget(params.resolveSessionTarget(sessionTarget, input.request)), targetsMatch: (left, right) => { + // Some transports need native delivery ids unchanged while matching on + // normalized aliases, so matcher normalization is separate from output normalization. const normalizedLeft = normalizeTargetForMatch(left); const normalizedRight = normalizeTargetForMatch(right); return Boolean( @@ -890,11 +930,14 @@ export function createChannelApproverDmTargetResolver< TApprover, TTarget extends NativeApprovalTarget = NativeApprovalTarget, >(params: { + /** Optional gate; returning false skips approver DM delivery for the request. */ shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; + /** Resolves approver records from config and optional account scope. */ resolveApprovers: (params: { cfg: OpenClawConfig; accountId?: string | null; }) => readonly TApprover[]; + /** Maps one approver record to a native DM target; nullish results are skipped. */ mapApprover: (approver: TApprover, params: ApprovalResolverParams) => TTarget | null | undefined; }) { return (input: ApprovalResolverParams): TTarget[] => { diff --git a/src/plugin-sdk/approval-native-runtime.ts b/src/plugin-sdk/approval-native-runtime.ts index d4eda02cbbe2..77f893b2ff9e 100644 --- a/src/plugin-sdk/approval-native-runtime.ts +++ b/src/plugin-sdk/approval-native-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for native approval routing, target matching, and forwarding gates. + */ export { createChannelApprovalForwardingEvaluator, createChannelApproverDmTargetResolver, diff --git a/src/plugin-sdk/approval-reaction-runtime.test.ts b/src/plugin-sdk/approval-reaction-runtime.test.ts index c615907d69ff..76f204555ef7 100644 --- a/src/plugin-sdk/approval-reaction-runtime.test.ts +++ b/src/plugin-sdk/approval-reaction-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests approval reaction runtime helper behavior. + */ import { describe, expect, it } from "vitest"; import type { ExecApprovalRequest } from "../infra/exec-approvals.js"; import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; diff --git a/src/plugin-sdk/approval-reaction-runtime.ts b/src/plugin-sdk/approval-reaction-runtime.ts index 92fb1bb898d6..b56bb71a25a0 100644 --- a/src/plugin-sdk/approval-reaction-runtime.ts +++ b/src/plugin-sdk/approval-reaction-runtime.ts @@ -37,6 +37,7 @@ type InMemoryApprovalReactionTarget = { expiresAtMs: number; }; +/** In-memory or backed store for approval targets awaiting reaction decisions. */ export type ApprovalReactionTargetStore = { register(key: string, target: TTarget, opts?: { ttlMs?: number }): void; lookup(key: string): Promise; @@ -44,17 +45,20 @@ export type ApprovalReactionTargetStore = { clearForTest(): void; }; +/** Product-ordered emoji binding for one approval decision. */ export type ApprovalReactionDecisionBinding = { decision: ExecApprovalReplyDecision; emoji: string; label: string; }; +/** Normalized reaction decision resolved from a channel reaction key. */ export type ApprovalReactionDecisionResolution = { decision: ExecApprovalReplyDecision; normalizedEmoji: string; }; +/** Stored target metadata needed to convert a reaction into an approval decision. */ export type ApprovalReactionTargetRecord = { approvalId: string; approvalKind?: ApprovalKind; @@ -80,6 +84,7 @@ export type ApprovalReactionPendingContent = { manualFallbackPayload: ReplyPayload; }; +/** Canonical reaction controls shown for approval prompts, in product display order. */ export const APPROVAL_REACTION_BINDINGS = [ { decision: "allow-once", emoji: "👍", label: "Allow Once" }, { decision: "allow-always", emoji: "♾️", label: "Allow Always" }, @@ -97,6 +102,7 @@ function normalizeDecisionList( return APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)); } +/** List the canonical reaction bindings allowed for a specific approval request. */ export function listApprovalReactionBindings(params: { allowedDecisions: readonly ExecApprovalReplyDecision[]; }): ApprovalReactionDecisionBinding[] { @@ -110,6 +116,7 @@ export function listApprovalReactionBindings(params: { ); } +/** Build user-facing reaction instructions, or null when no reaction decisions are allowed. */ export function buildApprovalReactionHint(params: { allowedDecisions: readonly ExecApprovalReplyDecision[]; }): string | null { @@ -120,17 +127,20 @@ export function buildApprovalReactionHint(params: { return `React with:\n\n${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join("\n")}`; } +/** Normalize reaction emoji so skin-tone and text/presentation variants match canonical bindings. */ export function normalizeApprovalReactionEmoji(reactionKey: string): string { const normalized = reactionKey .trim() .replace(VARIATION_SELECTOR_RE, "") .replace(FITZPATRICK_MODIFIER_RE, ""); + // Infinity commonly arrives without the emoji variation selector; restore the canonical binding. if (normalized === "♾") { return "♾️"; } return normalized; } +/** Resolve a reaction key to an allowed approval decision. */ export function resolveApprovalReactionDecision(params: { reactionKey: string; allowedDecisions: readonly ExecApprovalReplyDecision[]; @@ -147,6 +157,7 @@ export function resolveApprovalReactionDecision(params: { return null; } +/** Resolve a stored target plus reaction key into an approval decision payload. */ export function resolveApprovalReactionTarget(params: { target: ApprovalReactionTargetRecord | null | undefined; reactionKey: string; diff --git a/src/plugin-sdk/approval-renderers.test.ts b/src/plugin-sdk/approval-renderers.test.ts index be1db14d4284..ab617ac42ce4 100644 --- a/src/plugin-sdk/approval-renderers.test.ts +++ b/src/plugin-sdk/approval-renderers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests approval renderer payload and text formatting. + */ import { describe, expect, it } from "vitest"; import { buildApprovalPendingReplyPayload, diff --git a/src/plugin-sdk/approval-renderers.ts b/src/plugin-sdk/approval-renderers.ts index 9bddd80487e5..23c2475f0924 100644 --- a/src/plugin-sdk/approval-renderers.ts +++ b/src/plugin-sdk/approval-renderers.ts @@ -16,15 +16,25 @@ const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as cons /** Build a pending approval reply payload using the portable presentation API. */ export function buildApprovalPendingReplyPayload(params: { + /** Approval surface recorded in channel metadata; defaults to exec approvals. */ approvalKind?: "exec" | "plugin"; + /** Stable approval id used by `/approve` commands and metadata correlation. */ approvalId: string; + /** Short channel-facing approval slug for compact metadata displays. */ approvalSlug: string; + /** Visible approval request text sent to the channel. */ text: string; + /** Optional agent id associated with the approval request. */ agentId?: string | null; + /** Decisions rendered as buttons and accepted by the approval command. */ allowedDecisions?: readonly ExecApprovalReplyDecision[]; + /** Optional session key associated with the approval request. */ sessionKey?: string | null; + /** Channel-specific metadata merged with the shared approval metadata. */ channelData?: Record; }): ReplyPayload { + // Keep defaults aligned with the generic approval command UI when callers do + // not provide request-scoped decision restrictions. const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS; return { text: params.text, @@ -49,9 +59,13 @@ export function buildApprovalPendingReplyPayload(params: { /** Build a resolved approval reply payload with approval metadata but no controls. */ export function buildApprovalResolvedReplyPayload(params: { + /** Stable approval id used by `/approve` commands and metadata correlation. */ approvalId: string; + /** Short channel-facing approval slug for compact metadata displays. */ approvalSlug: string; + /** Visible resolved-state text sent to the channel. */ text: string; + /** Channel-specific metadata merged with the shared approval metadata. */ channelData?: Record; }): ReplyPayload { return { @@ -68,11 +82,17 @@ export function buildApprovalResolvedReplyPayload(params: { } export function buildPluginApprovalPendingReplyPayload(params: { + /** Plugin approval request to render. */ request: PluginApprovalRequest; + /** Current time used for request expiry copy. */ nowMs: number; + /** Optional visible text override. */ text?: string; + /** Optional compact approval slug; defaults to the request id prefix. */ approvalSlug?: string; + /** Optional decision override; defaults to the request's allowed decisions. */ allowedDecisions?: readonly ExecApprovalReplyDecision[]; + /** Channel-specific metadata merged with the shared approval metadata. */ channelData?: Record; }): ReplyPayload { return buildApprovalPendingReplyPayload({ @@ -88,9 +108,13 @@ export function buildPluginApprovalPendingReplyPayload(params: { } export function buildPluginApprovalResolvedReplyPayload(params: { + /** Resolved plugin approval event to render. */ resolved: PluginApprovalResolved; + /** Optional visible text override. */ text?: string; + /** Optional compact approval slug; defaults to the resolved id prefix. */ approvalSlug?: string; + /** Channel-specific metadata merged with the shared approval metadata. */ channelData?: Record; }): ReplyPayload { return buildApprovalResolvedReplyPayload({ diff --git a/src/plugin-sdk/approval-reply-runtime.ts b/src/plugin-sdk/approval-reply-runtime.ts index 3ca8d5eece73..45732355ef4d 100644 --- a/src/plugin-sdk/approval-reply-runtime.ts +++ b/src/plugin-sdk/approval-reply-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for building approval replies and exec approval presentations. + */ export { buildApprovalInteractiveReplyFromActionDescriptors, buildApprovalPresentation, diff --git a/src/plugin-sdk/browser-bridge.ts b/src/plugin-sdk/browser-bridge.ts index 7d26eb489544..931831b562b8 100644 --- a/src/plugin-sdk/browser-bridge.ts +++ b/src/plugin-sdk/browser-bridge.ts @@ -1,7 +1,11 @@ +/** + * Public SDK facade for starting and stopping the bundled browser bridge server. + */ import type { Server } from "node:http"; import type { ResolvedBrowserConfig } from "./browser-profiles.js"; import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +/** Running browser bridge server state returned to plugin callers. */ export type BrowserBridge = { server: Server; port: number; @@ -31,12 +35,14 @@ function loadFacadeModule(): BrowserBridgeFacadeModule { }); } +/** Starts the browser bridge runtime from the activated browser plugin facade. */ export async function startBrowserBridgeServer( params: Parameters[0], ): Promise { return await loadFacadeModule().startBrowserBridgeServer(params); } +/** Stops a browser bridge server previously returned by startBrowserBridgeServer. */ export async function stopBrowserBridgeServer(server: Server): Promise { await loadFacadeModule().stopBrowserBridgeServer(server); } diff --git a/src/plugin-sdk/browser-cdp.ts b/src/plugin-sdk/browser-cdp.ts index c1bf8bbb29ee..5630663b40c0 100644 --- a/src/plugin-sdk/browser-cdp.ts +++ b/src/plugin-sdk/browser-cdp.ts @@ -1,5 +1,6 @@ import { redactSensitiveText } from "../logging/redact.js"; +/** Detect an operator-supplied port before WHATWG URL normalization drops default ports. */ function hasRawExplicitPort(raw: string): boolean { const authority = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[/?#]/, 1)[0] ?? ""; const hostPort = authority.includes("@") @@ -13,7 +14,23 @@ function hasRawExplicitPort(raw: string): boolean { return /:\d+$/.test(hostPort); } -export function parseBrowserHttpUrl(raw: string, label: string) { +export type BrowserHttpUrlParseResult = { + /** Parsed URL object retained for callers that need protocol, host, path, or credentials. */ + parsed: URL; + /** Effective TCP port, including inferred 80/443 defaults. */ + port: number; + /** Whether the raw URL text included a port, even if URL normalization drops it. */ + hasExplicitPort: boolean; + /** URL string normalized by WHATWG URL rules with a trailing slash removed. */ + normalized: string; + /** Normalized URL string that preserves an explicitly supplied default port. */ + normalizedWithPort: string; +}; + +/** + * Parses a browser/CDP endpoint and returns both URL semantics and display-safe normalized forms. + */ +export function parseBrowserHttpUrl(raw: string, label: string): BrowserHttpUrlParseResult { const trimmed = raw.trim(); const parsed = new URL(trimmed); const allowed = ["http:", "https:", "ws:", "wss:"]; @@ -38,6 +55,8 @@ export function parseBrowserHttpUrl(raw: string, label: string) { const normalized = parsed.toString().replace(/\/$/, ""); let normalizedWithPort: string; if (hasExplicitPort && !parsed.port) { + // URL normalizes away default ports, but config diagnostics need to preserve + // whether the operator explicitly wrote `:80` or `:443`. const proto = parsed.protocol + "//"; const rest = normalized.slice(proto.length); const atIdx = rest.indexOf("@"); @@ -64,6 +83,9 @@ export function parseBrowserHttpUrl(raw: string, label: string) { }; } +/** + * Redacts credentials and known sensitive tokens from CDP URLs before logs or diagnostics. + */ export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { if (typeof cdpUrl !== "string") { return cdpUrl; diff --git a/src/plugin-sdk/browser-config.ts b/src/plugin-sdk/browser-config.ts index 890e9d97be49..6a2bfcbba56e 100644 --- a/src/plugin-sdk/browser-config.ts +++ b/src/plugin-sdk/browser-config.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for browser plugin configuration, CDP URL, and auth helpers. + */ export { DEFAULT_AI_SNAPSHOT_MAX_CHARS, DEFAULT_BROWSER_ACTION_TIMEOUT_MS, diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index 6ec64283de80..da37a1a6967a 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -2,15 +2,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; export type BrowserControlAuth = { + /** Bearer token accepted by the browser control HTTP surface. */ token?: string; + /** Password fallback for deployments that expose password-based browser control auth. */ password?: string; }; +/** Inputs used when resolving or creating browser control auth for the active config. */ type EnsureBrowserControlAuthParams = { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }; +/** Resolved auth plus the generated token when this call created one. */ type EnsureBrowserControlAuthResult = { auth: BrowserControlAuth; generatedToken?: string; @@ -27,6 +31,8 @@ type BrowserControlAuthSurface = { let cachedBrowserControlAuthSurface: BrowserControlAuthSurface | undefined; function loadBrowserControlAuthSurface(): BrowserControlAuthSurface { + // Browser owns auth generation and env precedence; this SDK wrapper only keeps + // the lazy public facade stable for plugin authors. cachedBrowserControlAuthSurface ??= loadBundledPluginPublicSurfaceModuleSync({ dirName: "browser", @@ -35,6 +41,7 @@ function loadBrowserControlAuthSurface(): BrowserControlAuthSurface { return cachedBrowserControlAuthSurface; } +/** Resolves browser control auth from config/env without generating new credentials. */ export function resolveBrowserControlAuth( cfg?: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -42,10 +49,12 @@ export function resolveBrowserControlAuth( return loadBrowserControlAuthSurface().resolveBrowserControlAuth(cfg, env); } +/** Returns whether browser control auth should be generated for this environment. */ export function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean { return loadBrowserControlAuthSurface().shouldAutoGenerateBrowserAuth(env); } +/** Ensures browser control auth exists, returning any token generated during the call. */ export async function ensureBrowserControlAuth( params: EnsureBrowserControlAuthParams, ): Promise { diff --git a/src/plugin-sdk/browser-facade-test-helpers.ts b/src/plugin-sdk/browser-facade-test-helpers.ts index 4e0853f6567c..ba3c1a78787c 100644 --- a/src/plugin-sdk/browser-facade-test-helpers.ts +++ b/src/plugin-sdk/browser-facade-test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Shared test helpers for browser facade delegation tests. + */ import { expect, vi } from "vitest"; type FacadeLoaderMock = ReturnType; @@ -14,6 +17,7 @@ const BROWSER_HOST_INSPECTION_ARTIFACT = { const BROWSER_VERSION = "Google Chrome 144.0.7534.0"; +/** Installs a mocked browser host inspection public surface. */ export function mockBrowserHostInspectionFacade( loadBundledPluginPublicSurfaceModuleSync: FacadeLoaderMock, executable: ChromeExecutableFixture, @@ -29,6 +33,7 @@ export function mockBrowserHostInspectionFacade( }); } +/** Asserts browser host inspection calls delegate through the browser public facade. */ export function expectBrowserHostInspectionDelegation(params: { executable: ChromeExecutableFixture; hostInspection: typeof import("./browser-host-inspection.js"); @@ -44,6 +49,7 @@ export function expectBrowserHostInspectionDelegation(params: { ); } +/** Asserts host inspection helpers surface facade load failures to callers. */ export async function expectBrowserHostInspectionFacadeUnavailable( loadBundledPluginPublicSurfaceModuleSync: FacadeLoaderMock, ) { diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index 889adfc1accd..57f2c3d114df 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -1,5 +1,9 @@ +/** + * Public SDK facade for browser executable lookup and browser version inspection. + */ import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; +/** Browser executable candidate discovered on the host platform. */ export type BrowserExecutable = { kind: "brave" | "canary" | "chromium" | "chrome" | "custom" | "edge"; path: string; @@ -22,16 +26,19 @@ function loadBrowserHostInspectionSurface(): BrowserHostInspectionSurface { return cachedBrowserHostInspectionSurface; } +/** Resolves the preferred local Chrome-compatible executable for a platform. */ export function resolveGoogleChromeExecutableForPlatform( platform: NodeJS.Platform, ): BrowserExecutable | null { return loadBrowserHostInspectionSurface().resolveGoogleChromeExecutableForPlatform(platform); } +/** Reads a browser executable version string through the activated browser facade. */ export function readBrowserVersion(executablePath: string): string | null { return loadBrowserHostInspectionSurface().readBrowserVersion(executablePath); } +/** Parses a browser major version from raw command output. */ export function parseBrowserMajorVersion(rawVersion: string | null | undefined): number | null { return loadBrowserHostInspectionSurface().parseBrowserMajorVersion(rawVersion); } diff --git a/src/plugin-sdk/browser-maintenance.test.ts b/src/plugin-sdk/browser-maintenance.test.ts index 2521cfd1f407..d9b30720383e 100644 --- a/src/plugin-sdk/browser-maintenance.test.ts +++ b/src/plugin-sdk/browser-maintenance.test.ts @@ -1,3 +1,6 @@ +/** + * Tests browser maintenance facade loading and cleanup behavior. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index a76ee8f128dc..d2d357f9bdbd 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for browser cleanup and trash operations. + */ import { canLoadActivatedBundledPluginPublicSurface, tryLoadActivatedBundledPluginPublicSurfaceModuleSync, @@ -36,6 +39,7 @@ function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface | null { return cachedBrowserMaintenanceSurface ?? null; } +/** Closes tracked browser tabs for requested session keys when the browser plugin is active. */ export async function closeTrackedBrowserTabsForSessions( params: CloseTrackedBrowserTabsParams, ): Promise { diff --git a/src/plugin-sdk/browser-node-host.test.ts b/src/plugin-sdk/browser-node-host.test.ts index 69c483cff707..1b7007c8bb14 100644 --- a/src/plugin-sdk/browser-node-host.test.ts +++ b/src/plugin-sdk/browser-node-host.test.ts @@ -1,3 +1,6 @@ +/** + * Tests browser node-host facade delegation and unavailable facade behavior. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); diff --git a/src/plugin-sdk/browser-node-host.ts b/src/plugin-sdk/browser-node-host.ts index 578c09f811ae..2ef45e66245e 100644 --- a/src/plugin-sdk/browser-node-host.ts +++ b/src/plugin-sdk/browser-node-host.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for invoking browser plugin node-host proxy commands. + */ import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; type BrowserNodeHostFacadeModule = { @@ -11,6 +14,7 @@ function loadFacadeModule(): BrowserNodeHostFacadeModule { }); } +/** Runs a serialized browser proxy command through the activated browser plugin facade. */ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise { return await loadFacadeModule().runBrowserProxyCommand(paramsJSON); } diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index d276d65b9396..c14773a39dde 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for browser profile defaults and activated profile resolution. + */ import path from "node:path"; import type { BrowserConfig, BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; @@ -13,6 +16,7 @@ export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000; export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); +/** Resolved browser tab cleanup settings after defaults and config are applied. */ export type ResolvedBrowserTabCleanupConfig = { enabled: boolean; idleMinutes: number; @@ -20,6 +24,7 @@ export type ResolvedBrowserTabCleanupConfig = { sweepMinutes: number; }; +/** Fully resolved browser plugin config used by browser runtime callers. */ export type ResolvedBrowserConfig = { enabled: boolean; evaluateEnabled: boolean; @@ -46,6 +51,7 @@ export type ResolvedBrowserConfig = { extraArgs: string[]; }; +/** One resolved browser profile target including CDP endpoint and launch mode. */ export type ResolvedBrowserProfile = { name: string; cdpPort: number; @@ -82,6 +88,7 @@ function loadBrowserProfilesSurface(): BrowserProfilesSurface { return cachedBrowserProfilesSurface; } +/** Resolves browser config through the activated bundled browser profile facade. */ export function resolveBrowserConfig( cfg: BrowserConfig | undefined, rootConfig?: OpenClawConfig, @@ -89,6 +96,7 @@ export function resolveBrowserConfig( return loadBrowserProfilesSurface().resolveBrowserConfig(cfg, rootConfig); } +/** Resolves one named browser profile from an already resolved browser config. */ export function resolveProfile( resolved: ResolvedBrowserConfig, profileName: string, diff --git a/src/plugin-sdk/browser-trash.ts b/src/plugin-sdk/browser-trash.ts index 9610f687a881..3acc67d80d6f 100644 --- a/src/plugin-sdk/browser-trash.ts +++ b/src/plugin-sdk/browser-trash.ts @@ -1,2 +1,5 @@ +/** + * Public SDK subpath for moving browser-owned paths to the platform trash. + */ import "../infra/fs-safe-defaults.js"; export { movePathToTrash, type MovePathToTrashOptions } from "@openclaw/fs-safe/advanced"; diff --git a/src/plugin-sdk/channel-access-compat.ts b/src/plugin-sdk/channel-access-compat.ts index 1afc289c03f1..f18e34bbcf0d 100644 --- a/src/plugin-sdk/channel-access-compat.ts +++ b/src/plugin-sdk/channel-access-compat.ts @@ -1 +1,4 @@ +/** + * Compatibility SDK subpath for shared direct-message access policy helpers. + */ export * from "../security/dm-policy-shared.js"; diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index f47a3dbd3fd0..20543e120e25 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel config helper authorization and write-scope behavior. + */ import { describe, expect, it } from "vitest"; import { formatPairingApproveHint } from "../channels/plugins/helpers.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 3eb745843606..478c636729de 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -34,8 +34,11 @@ export { const INTERNAL_MESSAGE_CHANNEL = "webchat"; +/** Origin scope used when authorizing channel config writes. */ export type ConfigWriteScope = ConfigWriteScopeLike; +/** Target account/channel for a config write authorization check. */ export type ConfigWriteTarget = ConfigWriteTargetLike; +/** Decision returned by channel config write policy helpers. */ export type ConfigWriteAuthorizationResult = ConfigWriteAuthorizationResultLike; type ChannelCrudConfigAdapter = Pick< @@ -61,6 +64,7 @@ type ChannelConfigAdapterWithAccessors = Pick< | "resolveDefaultTo" >; +/** Returns whether config writes are enabled for a channel/account target. */ export function resolveChannelConfigWrites(params: { cfg: OpenClawConfig; channelId?: string | null; @@ -69,6 +73,7 @@ export function resolveChannelConfigWrites(params: { return resolveChannelConfigWritesShared(params); } +/** Authorizes a channel config mutation against origin and target policy. */ export function authorizeConfigWrite(params: { cfg: OpenClawConfig; origin?: ConfigWriteScope; @@ -78,6 +83,7 @@ export function authorizeConfigWrite(params: { return authorizeConfigWriteShared(params); } +/** Returns true when trusted internal message scopes can bypass config write policy. */ export function canBypassConfigWritePolicy(params: { channel?: string | null; gatewayClientScopes?: string[] | null; @@ -89,6 +95,7 @@ export function canBypassConfigWritePolicy(params: { }); } +/** Formats the denial message shown when config write authorization fails. */ export function formatConfigWriteDeniedMessage(params: { result: Exclude; fallbackChannelId?: string | null; @@ -166,9 +173,13 @@ export function createScopedAccountConfigAccessors< // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Config preserves caller-specific config subtype for account resolvers. Config extends OpenClawConfig = OpenClawConfig, >(params: { + /** Resolves the account used by read-only config accessors from `{ cfg, accountId }`. */ resolveAccount: (params: { cfg: Config; accountId?: string | null }) => ResolvedAccount; + /** Reads raw allowlist entries from the resolved account. */ resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + /** Formats allowlist entries for display or config inspection. */ formatAllowFrom: (allowFrom: Array) => string[]; + /** Optional default destination selector; omitted when the channel has no default target. */ resolveDefaultTo?: (account: ResolvedAccount) => string | number | null | undefined; }): Pick< ChannelConfigAdapter, @@ -252,6 +263,8 @@ function resolveAccessorAccountWithFallback< | undefined, fallbackResolveAccessorAccount: (params: ChannelConfigAccessorParams) => AccessorAccount, ): (params: ChannelConfigAccessorParams) => AccessorAccount { + // Read-only accessors can use a lighter account projection than runtime setup; + // fall back to the runtime resolver only when the channel has no projection hook. return resolveAccessorAccount ?? fallbackResolveAccessorAccount; } @@ -548,6 +561,8 @@ export function createHybridChannelConfigBase< deleteAccount({ cfg, accountId }) { if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { if (params.preserveSectionOnDefaultDelete) { + // Some hybrid channels keep non-account config at the root, so deleting + // default account credentials must clear only account-owned fields. return clearTopLevelChannelConfigFields({ cfg, sectionKey: params.sectionKey, diff --git a/src/plugin-sdk/channel-config-writes.ts b/src/plugin-sdk/channel-config-writes.ts index 009fa6570746..721e69511c26 100644 --- a/src/plugin-sdk/channel-config-writes.ts +++ b/src/plugin-sdk/channel-config-writes.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel config write authorization and scope helpers. + */ export { authorizeConfigWrite, canBypassConfigWritePolicy, diff --git a/src/plugin-sdk/channel-contract-testing.test.ts b/src/plugin-sdk/channel-contract-testing.test.ts index 19a1341108b3..1e96846f4ee5 100644 --- a/src/plugin-sdk/channel-contract-testing.test.ts +++ b/src/plugin-sdk/channel-contract-testing.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel contract testing helpers exported by the plugin SDK. + */ import { expectChannelTurnDispatchResultContract } from "openclaw/plugin-sdk/channel-contract-testing"; import { describe, it } from "vitest"; diff --git a/src/plugin-sdk/channel-contract-testing.ts b/src/plugin-sdk/channel-contract-testing.ts index a2f2ce24dd9b..c2f940177824 100644 --- a/src/plugin-sdk/channel-contract-testing.ts +++ b/src/plugin-sdk/channel-contract-testing.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for channel plugin contract fixtures and payload suites. + */ export { expectChannelInboundContextContract, expectChannelTurnDispatchResultContract, diff --git a/src/plugin-sdk/channel-core.ts b/src/plugin-sdk/channel-core.ts index b4ddcfdc9055..3895e8f52ef9 100644 --- a/src/plugin-sdk/channel-core.ts +++ b/src/plugin-sdk/channel-core.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for core channel plugin construction helpers. + */ export type { ChannelConfigUiHint, ChannelPlugin, @@ -10,6 +13,7 @@ export type { import { createChannelPluginBase as createChannelPluginBaseFromCore } from "./core.js"; +/** Creates a channel plugin base while keeping the public import on this SDK subpath. */ export const createChannelPluginBase: typeof createChannelPluginBaseFromCore = (params) => createChannelPluginBaseFromCore(params); diff --git a/src/plugin-sdk/channel-feedback.ts b/src/plugin-sdk/channel-feedback.ts index 5f5c8c846658..0bdbef0c0a7b 100644 --- a/src/plugin-sdk/channel-feedback.ts +++ b/src/plugin-sdk/channel-feedback.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel feedback reactions, status reactions, and logging helpers. + */ export { resolveAckReaction } from "../agents/identity.js"; export { createAckReactionHandle, diff --git a/src/plugin-sdk/channel-inbound.test.ts b/src/plugin-sdk/channel-inbound.test.ts index aa83075bcf84..b9f6008dfcf7 100644 --- a/src/plugin-sdk/channel-inbound.test.ts +++ b/src/plugin-sdk/channel-inbound.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel inbound context and dispatch helper behavior. + */ import { describe, expect, it } from "vitest"; import { buildChannelInboundEventContext, diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 5f3d1ed11632..b23410283bce 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -131,6 +131,8 @@ export function buildChannelTurnContext( params: BuildChannelTurnContextParams, ): BuiltChannelTurnContext { const inboundEventKind = params.message.inboundEventKind ?? params.message.inboundTurnKind; + // Normalize the legacy turn-kind field before delegating so downstream context builders + // only need to preserve the current inbound-event contract. const ctx = buildChannelInboundEventContext({ ...params, message: { diff --git a/src/plugin-sdk/channel-ingress.ts b/src/plugin-sdk/channel-ingress.ts index b36b83e291a9..036b5a129d29 100644 --- a/src/plugin-sdk/channel-ingress.ts +++ b/src/plugin-sdk/channel-ingress.ts @@ -68,11 +68,13 @@ export type ChannelIngressPluginId = string & { readonly [CHANNEL_INGRESS_PLUGIN_ID]: true; }; +/** Selector for a single access-graph gate in an ingress decision. */ export type ChannelIngressGateSelector = { phase: IngressGatePhase; kind: IngressGateKind; }; +/** Canonical direct/group and command/non-command decisions for one inbound event. */ export type ChannelIngressDecisionBundle = { dm: ChannelIngressDecision; group: ChannelIngressDecision; @@ -80,6 +82,7 @@ export type ChannelIngressDecisionBundle = { groupCommand: ChannelIngressDecision; }; +/** Side effect produced while handling an ingress decision before turn admission is mapped. */ export type ChannelIngressSideEffectResult = | { kind: "none" } | { kind: "pairing-reply-sent" } @@ -94,6 +97,7 @@ export type RedactedIngressDiagnostics = { reasonCode: IngressReasonCode; }; +/** Stable selectors for the ingress gates most plugin SDK callers inspect. */ export const CHANNEL_INGRESS_GATE_SELECTORS = { command: { phase: "command", kind: "command" }, activation: { phase: "activation", kind: "mention" }, @@ -189,6 +193,7 @@ function defaultIngressMatchKey(params: { return `${params.kind}:${params.value}`; } +/** Find the first gate matching a selector in an ingress decision graph. */ export function findChannelIngressGate( decision: ChannelIngressDecision, selector: ChannelIngressGateSelector, @@ -198,6 +203,7 @@ export function findChannelIngressGate( ); } +/** Find the sender gate for a DM or group ingress decision. */ export function findChannelIngressSenderGate( decision: ChannelIngressDecision, params: { isGroup: boolean }, @@ -210,12 +216,14 @@ export function findChannelIngressSenderGate( ); } +/** Find the command authorization gate in an ingress decision, when command policy ran. */ export function findChannelIngressCommandGate( decision: ChannelIngressDecision, ): AccessGraphGate | undefined { return findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command); } +/** Run base and command ingress decisions for both DM and group states. */ export function decideChannelIngressBundle(params: { directState: ChannelIngressState; groupState: ChannelIngressState; @@ -268,6 +276,7 @@ function projectDmDecision( return decision.admission === "drop" ? "deny" : "allow"; } +/** Project a full ingress decision graph into the legacy AccessFacts shape used by channels. */ export function projectIngressAccessFacts(decision: ChannelIngressDecision): AccessFacts { const command = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command); const activation = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.activation); @@ -299,6 +308,8 @@ export function projectIngressAccessFacts(decision: ChannelIngressDecision): Acc useAccessGroups: command.command.useAccessGroups, allowTextCommands: command.command.allowTextCommands, modeWhenAccessGroupsOff: command.command.modeWhenAccessGroupsOff, + // Ingress decisions keep redacted gate facts; legacy AccessFacts preserves + // the authorizers property but does not expose individual sender entries. authorizers: [], } : undefined, @@ -340,6 +351,7 @@ export function mapChannelIngressDecisionToTurnAdmission( : { kind: "drop", reason: decision.reasonCode }; } +/** Brand a non-empty plugin id for channel ingress diagnostics and gate ids. */ export function createChannelIngressPluginId(id: string): ChannelIngressPluginId { const trimmed = id.trim(); if (!trimmed) { @@ -348,6 +360,10 @@ export function createChannelIngressPluginId(id: string): ChannelIngressPluginId return trimmed as ChannelIngressPluginId; } +/** + * Create a channel ingress subject from one or more identifiers. + * Missing opaque ids are generated deterministically so redacted match output stays stable. + */ export function createChannelIngressSubject( input: | ChannelIngressSubjectIdentifierInput @@ -365,6 +381,10 @@ export function createChannelIngressSubject( }; } +/** + * Create an adapter for channels that match allowlist entries against one normalized string id. + * Wildcards are preserved as `*`; empty normalized values are omitted from matchable entries. + */ export function createChannelIngressStringAdapter( params: CreateChannelIngressStringAdapterParams = {}, ): ChannelIngressAdapter { @@ -416,6 +436,10 @@ export function createChannelIngressStringAdapter( }; } +/** + * Create an adapter for channels that match one allowlist entry against multiple identifier kinds. + * This is useful when a channel supports stable ids plus aliases such as email or username. + */ export function createChannelIngressMultiIdentifierAdapter( params: CreateChannelIngressMultiIdentifierAdapterParams, ): ChannelIngressAdapter { @@ -455,11 +479,16 @@ export function createChannelIngressMultiIdentifierAdapter( }; } +/** Exhaustiveness helper for switch statements over ingress reason codes. */ export function assertNeverChannelIngressReason(reasonCode: never): never { throw new Error(`Unhandled channel ingress reason code: ${String(reasonCode)}`); } -/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)` or typed gate selectors. */ +/** + * Read the sender gate reason code for legacy callers. + * + * @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)` or typed gate selectors. + */ export function findChannelIngressSenderReasonCode( decision: ChannelIngressDecision, params: { isGroup: boolean }, @@ -467,7 +496,11 @@ export function findChannelIngressSenderReasonCode( return findChannelIngressSenderGate(decision, params)?.reasonCode ?? decision.reasonCode; } -/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)`. */ +/** + * Map channel-ingress reason codes back to legacy DM/group access reason codes. + * + * @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)`. + */ export function mapChannelIngressReasonCodeToDmGroupAccessReason(params: { reasonCode: IngressReasonCode; isGroup: boolean; @@ -496,7 +529,11 @@ export function mapChannelIngressReasonCodeToDmGroupAccessReason(params: { } } -/** @deprecated Use `senderAccess.reason` from `resolveChannelMessageIngress(...)`. */ +/** + * Format a legacy DM/group policy reason string from a mapped ingress reason code. + * + * @deprecated Use `senderAccess.reason` from `resolveChannelMessageIngress(...)`. + */ export function formatChannelIngressPolicyReason(params: { reasonCode: DmGroupAccessReasonCode; dmPolicy: string; @@ -526,7 +563,11 @@ export function formatChannelIngressPolicyReason(params: { return exhaustive; } -/** @deprecated Use `senderAccess.groupAccess` from `resolveChannelMessageIngress(...)`. */ +/** + * Project a sender ingress reason into the legacy group-access compatibility shape. + * + * @deprecated Use `senderAccess.groupAccess` from `resolveChannelMessageIngress(...)`. + */ export function projectChannelIngressSenderGroupAccess(params: { reasonCode: IngressReasonCode; decisionAllowed: boolean; @@ -553,7 +594,11 @@ export function projectChannelIngressSenderGroupAccess(params: { }; } -/** @deprecated Use `senderAccess` from `resolveChannelMessageIngress(...)`. */ +/** + * Project a full ingress decision into the legacy DM/group access compatibility shape. + * + * @deprecated Use `senderAccess` from `resolveChannelMessageIngress(...)`. + */ export function projectChannelIngressDmGroupAccess(params: { ingress: ChannelIngressDecision; isGroup: boolean; @@ -582,13 +627,18 @@ export function projectChannelIngressDmGroupAccess(params: { }; } +/** Resolve and normalize channel ingress state from SDK input. */ export async function resolveChannelIngressState( input: ChannelIngressStateInput, ): Promise { return await resolveChannelIngressStateInternal(input); } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve legacy ingress access with compatibility projections and effective allowlists. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export async function resolveChannelIngressAccess( params: ResolveChannelIngressAccessParams, ): Promise { diff --git a/src/plugin-sdk/channel-lifecycle.core.ts b/src/plugin-sdk/channel-lifecycle.core.ts index ef3ae7c4e0c6..e5390c414d76 100644 --- a/src/plugin-sdk/channel-lifecycle.core.ts +++ b/src/plugin-sdk/channel-lifecycle.core.ts @@ -13,18 +13,27 @@ type PassiveAccountLifecycleParams = { onStop?: () => void | Promise; }; +/** Runtime context passed to queued channel work. */ export type ChannelRunQueueTaskContext = { + /** Signal tied to the channel/account lifecycle that owns the queued work. */ lifecycleSignal?: AbortSignal; }; +/** Per-key async queue used by channel plugins to serialize account or thread work. */ export type ChannelRunQueue = { + /** Enqueue work under a serialization key such as account id, thread id, or chat id. */ enqueue: (key: string, task: (context: ChannelRunQueueTaskContext) => Promise) => void; + /** Stop accepting meaningful work and mark the lifecycle as inactive. */ deactivate: () => void; }; +/** Hooks used to wire channel queue state into runtime status and error reporting. */ export type ChannelRunQueueParams = { + /** Receives busy/idle lifecycle snapshots from the shared run-state machine. */ setStatus?: RunStateStatusSink; + /** Lifecycle signal propagated to queued tasks. */ abortSignal?: AbortSignal; + /** Best-effort sink for task failures after enqueueing. */ onError?: (error: unknown) => void; }; @@ -67,6 +76,7 @@ export function createChannelRunQueue(params: ChannelRunQueueParams): ChannelRun } runState.onRunStart(); try { + // Deactivation can happen while this key waited behind older work. if (!runState.isActive()) { return; } diff --git a/src/plugin-sdk/channel-lifecycle.queue.test.ts b/src/plugin-sdk/channel-lifecycle.queue.test.ts index 4f7a9f25e33a..21d57d586ed3 100644 --- a/src/plugin-sdk/channel-lifecycle.queue.test.ts +++ b/src/plugin-sdk/channel-lifecycle.queue.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel lifecycle queue ordering and failure handling. + */ import { describe, expect, it, vi } from "vitest"; import { createChannelRunQueue } from "./channel-lifecycle.core.js"; diff --git a/src/plugin-sdk/channel-lifecycle.test.ts b/src/plugin-sdk/channel-lifecycle.test.ts index 98e15f0f8b2a..de04aa5362f8 100644 --- a/src/plugin-sdk/channel-lifecycle.test.ts +++ b/src/plugin-sdk/channel-lifecycle.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel lifecycle hooks and SDK-visible lifecycle dispatch behavior. + */ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { diff --git a/src/plugin-sdk/channel-message.test.ts b/src/plugin-sdk/channel-message.test.ts index bfc8ebdf2b2e..a27f355da617 100644 --- a/src/plugin-sdk/channel-message.test.ts +++ b/src/plugin-sdk/channel-message.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel message helper behavior and mocked runtime interactions. + */ import { beforeAll, describe, expect, it, vi } from "vitest"; import { defineChannelMessageAdapter as defineCoreChannelMessageAdapter } from "../channels/message/index.js"; import { defineChannelMessageAdapter } from "./channel-outbound.js"; diff --git a/src/plugin-sdk/channel-outbound.ts b/src/plugin-sdk/channel-outbound.ts index 413fd2db80a6..39c861d36edc 100644 --- a/src/plugin-sdk/channel-outbound.ts +++ b/src/plugin-sdk/channel-outbound.ts @@ -207,6 +207,9 @@ export const deliverInboundReplyWithMessageSendContext: ChannelInboundKernelModu /** Sends a durable message batch without eager-loading channel message runtime internals. */ export async function sendDurableMessageBatch( + /** + * Durable send context and outbound batch data forwarded to the channel runtime. + */ params: DurableMessageSendContextParams, ): Promise { const mod = await loadChannelMessageRuntimeModule(); @@ -215,7 +218,13 @@ export async function sendDurableMessageBatch( /** Runs work inside a durable message send context loaded through the SDK lazy boundary. */ export async function withDurableMessageSendContext( + /** + * Durable send context used to bind sends, receipts, and lifecycle callbacks. + */ params: DurableMessageSendContextParams, + /** + * Callback executed with the loaded durable-send runtime context. + */ run: (ctx: DurableMessageSendContext) => Promise, ): Promise { const mod = await loadChannelMessageRuntimeModule(); diff --git a/src/plugin-sdk/channel-pairing.test.ts b/src/plugin-sdk/channel-pairing.test.ts index 0f596605590a..4815bff00587 100644 --- a/src/plugin-sdk/channel-pairing.test.ts +++ b/src/plugin-sdk/channel-pairing.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel pairing helpers and pairing reply behavior. + */ import { describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { diff --git a/src/plugin-sdk/channel-pairing.ts b/src/plugin-sdk/channel-pairing.ts index cae18027e403..f8eee43d7983 100644 --- a/src/plugin-sdk/channel-pairing.ts +++ b/src/plugin-sdk/channel-pairing.ts @@ -17,6 +17,7 @@ type ScopedPairingAccess = ReturnType; /** Pairing helpers scoped to one channel account. */ export type ChannelPairingController = ScopedPairingAccess & { + /** Issue a pairing challenge using the controller's channel and scoped store writer. */ issueChallenge: ( params: Omit[0], "channel" | "upsertPairingRequest">, ) => ReturnType; @@ -24,10 +25,13 @@ export type ChannelPairingController = ScopedPairingAccess & { /** Pre-bind the channel id and storage sink for pairing challenges. */ export function createChannelPairingChallengeIssuer(params: { + /** Channel id attached to every challenge issued by the returned helper. */ channel: ChannelId; + /** Store writer that persists pending pairing requests for the bound channel. */ upsertPairingRequest: Parameters[0]["upsertPairingRequest"]; }) { return ( + /** Challenge details supplied at message handling time. */ challenge: Omit< Parameters[0], "channel" | "upsertPairingRequest" @@ -42,8 +46,11 @@ export function createChannelPairingChallengeIssuer(params: { /** Build the full scoped pairing controller used by channel runtime code. */ export function createChannelPairingController(params: { + /** Plugin runtime that provides pairing store operations. */ core: PluginRuntime; + /** Channel id scoped into reads, writes, and issued challenges. */ channel: ChannelId; + /** Channel account id normalized before pairing store access. */ accountId: string; }): ChannelPairingController { const access = createScopedPairingAccess(params); diff --git a/src/plugin-sdk/channel-policy.test.ts b/src/plugin-sdk/channel-policy.test.ts index 682326d9786d..17888d699cf7 100644 --- a/src/plugin-sdk/channel-policy.test.ts +++ b/src/plugin-sdk/channel-policy.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel policy helper exports and policy decisions. + */ import { describe, expect, it } from "vitest"; import { formatPairingApproveHint } from "../channels/plugins/helpers.js"; import type { GroupPolicy } from "../config/types.base.js"; diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index 1fa13ff44318..2792b7d2d758 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -78,7 +78,10 @@ export function coerceNativeSetting(value: unknown): boolean | "auto" | undefine return undefined; } -/** Candidate mutable allowlist path inspected for dangerous name-matching warnings. */ +/** + * Candidate allowlist inspected for dangerous name/email/nick matching warnings. + * `pathLabel` is emitted in doctor output, so callers should pass the exact config path. + */ export type ChannelMutableAllowlistCandidate = { pathLabel: string; list: unknown; @@ -117,7 +120,10 @@ function collectMutableAllowlistWarningLines( ]; } -/** Creates a warning collector for mutable name/email/nick allowlists when matching is disabled. */ +/** + * Create a warning collector for mutable name/email/nick allowlists while stable-id matching is required. + * Channel plugins provide a detector for entries that depend on dangerous name matching. + */ export function createDangerousNameMatchingMutableAllowlistWarningCollector(params: { channel: string; detector: (entry: string) => boolean; @@ -154,27 +160,48 @@ export function createDangerousNameMatchingMutableAllowlistWarningCollector(para }; } -/** Compose the common DM policy resolver with restrict-senders group warnings. */ +/** + * Compose the common account-scoped DM policy resolver with restrict-senders group warnings. + * This is the shared adapter shape for channels whose DM security and group policy live together. + */ export function createRestrictSendersChannelSecurity< ResolvedAccount extends { accountId?: string | null }, >(params: { + /** Channel config key used for default account lookup and warning collection. */ channelKey: string; + /** Reads the account-level DM policy value before shared defaults are applied. */ resolveDmPolicy: (account: ResolvedAccount) => string | null | undefined; + /** Reads account-level sender allowlist entries for DM policy resolution. */ resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + /** Reads the group policy value used by restrict-senders warnings. */ resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + /** Operator-facing surface name in warning text. */ surface: string; + /** Operator-facing description of who can trigger when group policy is open. */ openScope: string; + /** Config path shown for the group policy field that should be restricted. */ groupPolicyPath: string; + /** Config path shown for the group sender allowlist field. */ groupAllowFromPath: string; + /** Whether group replies require mentions, reducing open-policy warning severity. */ mentionGated?: boolean; + /** Override for channels whose provider presence is not the channel config key itself. */ providerConfigPresent?: (cfg: OpenClawConfig) => boolean; + /** Fallback account id used when scoped config inherits from another account. */ resolveFallbackAccountId?: (account: ResolvedAccount) => string | null | undefined; + /** Default DM policy when the account and shared defaults omit one. */ defaultDmPolicy?: string; + /** Account-scoped allowlist path suffix for warning/proof output. */ allowFromPathSuffix?: string; + /** Account-scoped policy path suffix for warning/proof output. */ policyPathSuffix?: string; + /** Channel id used when formatting pairing approval hints. */ approveChannelId?: string; + /** Explicit pairing approval hint, when the default channel hint is not correct. */ approveHint?: string; + /** Normalizes configured DM allowlist entries before sender matching. */ normalizeDmEntry?: (raw: string) => string; + /** Allows non-default accounts to inherit shared defaults from the default account. */ inheritSharedDefaultsFromDefaultAccount?: boolean; }): ChannelSecurityAdapter { return { diff --git a/src/plugin-sdk/channel-reply-core.ts b/src/plugin-sdk/channel-reply-core.ts index fbb84b28c8da..1bea4b4c3ad3 100644 --- a/src/plugin-sdk/channel-reply-core.ts +++ b/src/plugin-sdk/channel-reply-core.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel reply pipeline construction and typing callbacks. + */ export { createChannelReplyPipeline, createReplyPrefixContext, diff --git a/src/plugin-sdk/channel-reply-pipeline.test.ts b/src/plugin-sdk/channel-reply-pipeline.test.ts index 42c0ed479333..2a25fb210a44 100644 --- a/src/plugin-sdk/channel-reply-pipeline.test.ts +++ b/src/plugin-sdk/channel-reply-pipeline.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel reply pipeline prefix context and typing callback behavior. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; diff --git a/src/plugin-sdk/channel-route.test.ts b/src/plugin-sdk/channel-route.test.ts index 7afb21d8d4ea..671de6564f5e 100644 --- a/src/plugin-sdk/channel-route.test.ts +++ b/src/plugin-sdk/channel-route.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel route helpers and session route resolution. + */ import { describe, expect, it } from "vitest"; import { channelRouteCompactKey, diff --git a/src/plugin-sdk/channel-route.ts b/src/plugin-sdk/channel-route.ts index c94f45b7a021..df14a3203a64 100644 --- a/src/plugin-sdk/channel-route.ts +++ b/src/plugin-sdk/channel-route.ts @@ -16,29 +16,45 @@ export type ChannelRouteThreadSource = "explicit" | "target" | "session" | "turn /** Normalized channel route used for comparison, binding, and dedupe helpers. */ export type ChannelRouteRef = { + /** Lowercase channel id such as `slack`, `telegram`, or `discord`. */ channel?: string; + /** Normalized account/profile id when a channel supports multiple accounts. */ accountId?: string; target?: { + /** Canonical destination id used for route equality and delivery. */ to: string; + /** Original destination text when provider target grammar differs from the canonical id. */ rawTo?: string; + /** Coarse destination shape used by channels with different direct/group/broadcast rules. */ chatType?: ChannelRouteChatType; }; thread?: { + /** Provider thread/topic/root id; strings are preserved when providers use opaque ids. */ id: string | number; + /** Provider-specific thread family for channels that distinguish topics, replies, and threads. */ kind?: ChannelRouteThreadKind; + /** Runtime source that supplied the thread id, used when callers need route provenance. */ source?: ChannelRouteThreadSource; }; }; /** Loose route input accepted at SDK boundaries before normalization. */ export type ChannelRouteRefInput = { + /** Raw channel id; normalized to lowercase. */ channel?: unknown; + /** Raw account/profile id; normalized with account-id rules when string. */ accountId?: unknown; + /** Raw destination id before trimming and route-key normalization. */ to?: unknown; + /** Provider-specific target text retained when different from `to`. */ rawTo?: unknown; + /** Coarse destination shape supplied by channels that distinguish target kinds. */ chatType?: ChannelRouteChatType; + /** Raw provider thread/topic/root id before route-key normalization. */ threadId?: unknown; + /** Provider-specific thread family carried with the normalized thread id. */ threadKind?: ChannelRouteThreadKind; + /** Runtime surface that supplied the thread id. */ threadSource?: ChannelRouteThreadSource; }; @@ -53,8 +69,11 @@ export type ChannelRouteKeyInput = ChannelRouteRef | ChannelRouteTargetInput; /** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */ export type ChannelRouteExplicitTarget = { + /** Canonical destination id parsed from the provider-specific target string. */ to: string; + /** Optional provider thread/topic/root id parsed from the target string. */ threadId?: string | number; + /** Coarse destination shape parsed from the target string. */ chatType?: ChannelRouteChatType; }; @@ -134,18 +153,27 @@ export function normalizeChannelRouteTarget( /** Parsed target shape retained for deprecated explicit-target parser adapters. */ export type ChannelRouteParsedTarget = ChannelRouteTargetInput & { + /** Normalized lowercase channel id. */ channel: string; + /** Trimmed provider-specific target text originally supplied by the caller. */ rawTo: string; + /** Canonical destination id used by route equality and delivery. */ to: string; + /** Optional thread/topic/root id from the parser or fallback value. */ threadId?: string | number; + /** Coarse destination shape parsed from the provider-specific target. */ chatType?: ChannelRouteChatType; }; /** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */ export function resolveChannelRouteTargetWithParser(params: { + /** Channel id used for normalization and parser dispatch. */ channel: string; + /** Provider-specific target text to parse. */ rawTarget?: string | null; + /** Thread id to use when the parsed target omits one. */ fallbackThreadId?: string | number | null; + /** Legacy parser that understands the channel's explicit-target grammar. */ parseExplicitTarget: ChannelRouteExplicitTargetParser; }): ChannelRouteParsedTarget | null { const channel = normalizeLowercaseStringOrEmpty(params.channel); @@ -194,6 +222,11 @@ function accountsEqual(left?: string, right?: string): boolean { return (left ?? "") === (right ?? ""); } +/** + * Checks strict route equality after normalization. + * Missing account ids are not compatible here; use share-conversation helpers for parent/child + * matching where omitted account/thread values intentionally widen the route. + */ export function channelRoutesMatchExact(params: { left?: ChannelRouteRef | null; right?: ChannelRouteRef | null; @@ -234,7 +267,7 @@ export function channelRoutesShareConversation(params: { return threadIdsEqual(left.thread.id, right.thread.id); } -/** Exact route comparison for loose target input. */ +/** Exact route comparison for loose target input after SDK boundary normalization. */ export function channelRouteTargetsMatchExact(params: { left?: ChannelRouteTargetInput | null; right?: ChannelRouteTargetInput | null; @@ -245,7 +278,7 @@ export function channelRouteTargetsMatchExact(params: { }); } -/** Conversation-level route comparison for loose target input. */ +/** Conversation-level route comparison for loose target input after SDK boundary normalization. */ export function channelRouteTargetsShareConversation(params: { left?: ChannelRouteTargetInput | null; right?: ChannelRouteTargetInput | null; diff --git a/src/plugin-sdk/channel-runtime-context.ts b/src/plugin-sdk/channel-runtime-context.ts index 45edf7a1abf6..81af225ce5bc 100644 --- a/src/plugin-sdk/channel-runtime-context.ts +++ b/src/plugin-sdk/channel-runtime-context.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for registering and watching channel runtime contexts. + */ export { getChannelRuntimeContext, registerChannelRuntimeContext, diff --git a/src/plugin-sdk/channel-secret-tts-runtime.ts b/src/plugin-sdk/channel-secret-tts-runtime.ts index 6d2222c542b7..f5d4822194f4 100644 --- a/src/plugin-sdk/channel-secret-tts-runtime.ts +++ b/src/plugin-sdk/channel-secret-tts-runtime.ts @@ -1 +1,4 @@ +/** + * Runtime SDK subpath for collecting nested channel TTS secret assignments. + */ export { collectNestedChannelTtsAssignments } from "../secrets/channel-secret-tts-runtime.js"; diff --git a/src/plugin-sdk/channel-send-result.test.ts b/src/plugin-sdk/channel-send-result.test.ts index 485f46f61c1a..f58c588c9faf 100644 --- a/src/plugin-sdk/channel-send-result.test.ts +++ b/src/plugin-sdk/channel-send-result.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel send result normalization and adapter wrapping helpers. + */ import { describe, expect, it } from "vitest"; import { attachChannelToResult, diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index 46d71d87ad31..d880c6cdc6bd 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -7,13 +7,21 @@ export type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; /** Legacy raw send result shape accepted from channel SDK adapters. */ export type ChannelSendRawResult = { + /** Whether the channel send operation succeeded. */ ok: boolean; + /** Platform message id; null/undefined normalizes to the empty-id sentinel. */ messageId?: string | null; + /** Legacy error text converted to an Error for outbound callers. */ error?: string | null; }; /** Attaches the channel id to a single outbound send result. */ -export function attachChannelToResult(channel: string, result: T) { +export function attachChannelToResult( + /** Channel id to stamp onto the returned delivery result. */ + channel: string, + /** Delivery-shaped result without channel metadata. */ + result: T, +) { return { channel, ...result, @@ -21,13 +29,20 @@ export function attachChannelToResult(channel: string, result: } /** Attaches the channel id to each outbound send result in order. */ -export function attachChannelToResults(channel: string, results: readonly T[]) { +export function attachChannelToResults( + /** Channel id to stamp onto every returned delivery result. */ + channel: string, + /** Ordered delivery-shaped results without channel metadata. */ + results: readonly T[], +) { return results.map((result) => attachChannelToResult(channel, result)); } /** Creates an empty outbound delivery result for send paths that produced no platform id. */ export function createEmptyChannelResult( + /** Channel id attached to the synthetic empty result. */ channel: string, + /** Additional delivery metadata to preserve alongside the empty message id. */ result: Partial> & { messageId?: string; } = {}, @@ -46,9 +61,13 @@ type SendPollParams = Parameters /** Wraps outbound send methods that already return delivery-shaped results without channel ids. */ export function createAttachedChannelResultAdapter(params: { + /** Channel id attached to every wrapped send result. */ channel: string; + /** Text sender that returns an outbound result without channel metadata. */ sendText?: (ctx: SendTextParams) => MaybePromise>; + /** Media sender that returns an outbound result without channel metadata. */ sendMedia?: (ctx: SendMediaParams) => MaybePromise>; + /** Poll sender that returns a poll result without channel metadata. */ sendPoll?: (ctx: SendPollParams) => MaybePromise>; }): Pick { return { @@ -66,8 +85,11 @@ export function createAttachedChannelResultAdapter(params: { /** Wraps legacy raw text/media send methods and normalizes their results. */ export function createRawChannelSendResultAdapter(params: { + /** Channel id attached to every normalized legacy send result. */ channel: string; + /** Legacy text sender that returns ok/messageId/error fields. */ sendText?: (ctx: SendTextParams) => MaybePromise; + /** Legacy media sender that returns ok/messageId/error fields. */ sendMedia?: (ctx: SendMediaParams) => MaybePromise; }): Pick { return { @@ -81,7 +103,12 @@ export function createRawChannelSendResultAdapter(params: { } /** Normalize raw channel send results into the shape shared outbound callers expect. */ -export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { +export function buildChannelSendResult( + /** Channel id attached to the normalized delivery result. */ + channel: string, + /** Legacy raw channel result to normalize. */ + result: ChannelSendRawResult, +) { return { channel, ok: result.ok, diff --git a/src/plugin-sdk/channel-setup.ts b/src/plugin-sdk/channel-setup.ts index cbf3d1f468df..8a5280702141 100644 --- a/src/plugin-sdk/channel-setup.ts +++ b/src/plugin-sdk/channel-setup.ts @@ -18,15 +18,21 @@ export { /** Metadata used to advertise an optional channel plugin during setup flows. */ type OptionalChannelSetupParams = { + /** Channel id shown in setup status and wizard routing. */ channel: string; + /** Human-readable plugin name used in install guidance. */ label: string; + /** Package spec operators should install to enable the optional channel. */ npmSpec?: string; + /** Docs path linked from setup validation and wizard hints. */ docsPath?: string; }; /** Paired setup adapter + setup wizard for channels that may not be installed yet. */ export type OptionalChannelSetupSurface = { + /** Adapter that fails validation with install guidance until the plugin is installed. */ setupAdapter: ChannelSetupAdapter; + /** Wizard status/finalize surface that points operators to the missing plugin. */ setupWizard: ChannelSetupWizard; }; @@ -37,6 +43,7 @@ export { /** Build both optional setup surfaces from one metadata object. */ export function createOptionalChannelSetupSurface( + /** Optional plugin metadata shared by the adapter and wizard. */ params: OptionalChannelSetupParams, ): OptionalChannelSetupSurface { return { diff --git a/src/plugin-sdk/channel-status.ts b/src/plugin-sdk/channel-status.ts index 5d4e83df8e6b..906638fef56a 100644 --- a/src/plugin-sdk/channel-status.ts +++ b/src/plugin-sdk/channel-status.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel status summaries, credential snapshots, and probe issues. + */ export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 58bed9b63419..b12bc1973455 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -1,3 +1,6 @@ +/** + * Tests channel streaming helper lifecycle and event forwarding. + */ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildChannelProgressDraftLine, diff --git a/src/plugin-sdk/channel-target-testing.ts b/src/plugin-sdk/channel-target-testing.ts index 35b656062e9d..14cd6648ef85 100644 --- a/src/plugin-sdk/channel-target-testing.ts +++ b/src/plugin-sdk/channel-target-testing.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for shared channel target resolver error-case contracts. + */ export { installCommonResolveTargetErrorCases, type ResolveTargetFn, diff --git a/src/plugin-sdk/channel-targets.ts b/src/plugin-sdk/channel-targets.ts index 2d6003ca7391..f40b879f2c40 100644 --- a/src/plugin-sdk/channel-targets.ts +++ b/src/plugin-sdk/channel-targets.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for channel target parsing, matching, and allowlist helpers. + */ export { applyChannelMatchMeta, buildChannelKeyCandidates, diff --git a/src/plugin-sdk/chat-channel-ids.test.ts b/src/plugin-sdk/chat-channel-ids.test.ts index 8f056205de5b..7270b985775c 100644 --- a/src/plugin-sdk/chat-channel-ids.test.ts +++ b/src/plugin-sdk/chat-channel-ids.test.ts @@ -1,3 +1,6 @@ +/** + * Tests chat channel id normalization and matching helpers. + */ import { describe, expect, it } from "vitest"; import { listBundledChannelCatalogEntries } from "../channels/bundled-channel-catalog-read.js"; import { diff --git a/src/plugin-sdk/chat-channel-ids.ts b/src/plugin-sdk/chat-channel-ids.ts index f62bc0a983da..97edf4cef8e4 100644 --- a/src/plugin-sdk/chat-channel-ids.ts +++ b/src/plugin-sdk/chat-channel-ids.ts @@ -21,6 +21,7 @@ function pushUniquePrefix(target: string[], seen: Set, raw: string | und if (!value) { return; } + // Envelope matching is case-insensitive; keep the first catalog spelling for display/tests. const key = value.toLocaleLowerCase("en-US"); if (seen.has(key)) { return; @@ -29,10 +30,15 @@ function pushUniquePrefix(target: string[], seen: Set, raw: string | und target.push(value); } +/** Bundled chat-channel ids from the official channel catalog. */ export const BUNDLED_CHAT_CHANNEL_IDS = Object.freeze( BUNDLED_CHAT_CHANNEL_ENTRIES.map((entry) => entry.id), ); +/** + * Channel ids, labels, and aliases that can appear as inbound-envelope prefixes. + * Consumers should use this for envelope cleanup instead of hardcoding channel names. + */ export const BUNDLED_CHAT_CHANNEL_ENVELOPE_PREFIXES = Object.freeze( (() => { const seen = new Set(); diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 82191e483813..310c7d0f8389 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -1,3 +1,6 @@ +/** + * Public SDK type surface for CLI backend plugins and watchdog defaults. + */ export type { CliBackendConfig } from "../config/types.js"; export type { CliBackendAuthEpochMode, diff --git a/src/plugin-sdk/command-auth-native.ts b/src/plugin-sdk/command-auth-native.ts index dd2201f3b543..9125a7ec41d0 100644 --- a/src/plugin-sdk/command-auth-native.ts +++ b/src/plugin-sdk/command-auth-native.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for native command specs, parsing, and authorization helpers. + */ export { buildCommandTextFromArgs, findCommandByNativeName, diff --git a/src/plugin-sdk/command-auth.test.ts b/src/plugin-sdk/command-auth.test.ts index f8a80081fdfa..5c7683e31169 100644 --- a/src/plugin-sdk/command-auth.test.ts +++ b/src/plugin-sdk/command-auth.test.ts @@ -1,3 +1,6 @@ +/** + * Tests command authorization helpers and native command gating. + */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 366773936c7d..7ac302c68133 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -109,7 +109,12 @@ export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js" export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; export type { StoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Inputs for legacy sender command authorization. + * Kept for plugins that still compose command auth from DM/group allowlists instead of channel ingress. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; rawBody: string; @@ -131,7 +136,11 @@ export type ResolveSenderCommandAuthorizationParams = { }) => boolean; }; -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Injectable runtime hooks for legacy command authorization tests and channel adapters. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export type CommandAuthorizationRuntime = { shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; resolveCommandAuthorizedFromAuthorizers: (params: { @@ -140,7 +149,11 @@ export type CommandAuthorizationRuntime = { }) => boolean; }; -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Legacy command authorization params with runtime hooks grouped for dependency injection. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit< ResolveSenderCommandAuthorizationParams, "shouldComputeCommandAuthorized" | "resolveCommandAuthorizedFromAuthorizers" @@ -148,7 +161,11 @@ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit< runtime: CommandAuthorizationRuntime; }; -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Classify direct-DM command handling after sender authorization has been computed. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; @@ -166,7 +183,11 @@ export function resolveDirectDmAuthorizationOutcome(params: { return "allowed"; } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve legacy command authorization using an injected runtime object. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export async function resolveSenderCommandAuthorizationWithRuntime( params: ResolveSenderCommandAuthorizationWithRuntimeParams, ): ReturnType { @@ -177,7 +198,12 @@ export async function resolveSenderCommandAuthorizationWithRuntime( }); } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve whether a sender may run slash/control commands under legacy DM/group policy. + * Returns effective allowlists so callers can report the exact source set used for authorization. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export async function resolveSenderCommandAuthorization( params: ResolveSenderCommandAuthorizationParams, ): Promise<{ @@ -188,6 +214,8 @@ export async function resolveSenderCommandAuthorization( commandAuthorized: boolean | undefined; }> { const shouldComputeAuth = params.shouldComputeCommandAuthorized(params.rawBody, params.cfg); + // Pairing-store allowlists apply to DM sender authorization only; group commands + // must rely on configured group allowlists or access-group expansion. const storeAllowFrom = !params.isGroup && params.dmPolicy !== "allowlist" && params.dmPolicy !== "open" ? await params.readAllowFromStore().catch(() => []) diff --git a/src/plugin-sdk/command-detection.ts b/src/plugin-sdk/command-detection.ts index e6b8ef64bed6..d4b83f0e8f2e 100644 --- a/src/plugin-sdk/command-detection.ts +++ b/src/plugin-sdk/command-detection.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for detecting control commands in inbound messages. + */ export { hasControlCommand, hasInlineCommandTokens, diff --git a/src/plugin-sdk/command-gating.ts b/src/plugin-sdk/command-gating.ts index 6b9e0bcfa09f..8c83a63a770f 100644 --- a/src/plugin-sdk/command-gating.ts +++ b/src/plugin-sdk/command-gating.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for command authorization and control-command gating. + */ export type { CommandAuthorizer, CommandGatingModeWhenAccessGroupsOff, diff --git a/src/plugin-sdk/command-primitives-runtime.ts b/src/plugin-sdk/command-primitives-runtime.ts index 199c499c69a2..0c0daeb0d52c 100644 --- a/src/plugin-sdk/command-primitives-runtime.ts +++ b/src/plugin-sdk/command-primitives-runtime.ts @@ -1,2 +1,5 @@ +/** + * Runtime SDK subpath for command primitive text classifiers. + */ export { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js"; export { isBtwRequestText } from "../auto-reply/reply/btw-command.js"; diff --git a/src/plugin-sdk/command-status-runtime.ts b/src/plugin-sdk/command-status-runtime.ts index 8d6e780f58e1..738266bfd4d0 100644 --- a/src/plugin-sdk/command-status-runtime.ts +++ b/src/plugin-sdk/command-status-runtime.ts @@ -1,3 +1,6 @@ +/** + * Lazy runtime SDK subpath for command status reply generation. + */ import { createLazyRuntimeMethodBinder, createLazyRuntimeModule } from "../shared/lazy-runtime.js"; type CommandStatusRuntime = typeof import("./command-status.runtime.js"); @@ -9,5 +12,6 @@ const bindCommandStatusRuntime = createLazyRuntimeMethodBinder(loadCommandStatus export type { ResolveDirectStatusReplyForSessionParams } from "./command-status.runtime.js"; +/** Resolves the direct status reply text for a session without eagerly loading runtime code. */ export const resolveDirectStatusReplyForSession: CommandStatusRuntime["resolveDirectStatusReplyForSession"] = bindCommandStatusRuntime((runtime) => runtime.resolveDirectStatusReplyForSession); diff --git a/src/plugin-sdk/command-status.runtime.test.ts b/src/plugin-sdk/command-status.runtime.test.ts index 5f104e02235b..23f7cc9c7911 100644 --- a/src/plugin-sdk/command-status.runtime.test.ts +++ b/src/plugin-sdk/command-status.runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests command status runtime lazy loading and direct status reply behavior. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const buildStatusReply = vi.fn(async (params: unknown) => params); diff --git a/src/plugin-sdk/command-status.runtime.ts b/src/plugin-sdk/command-status.runtime.ts index 6005c81dace0..7743e8068590 100644 --- a/src/plugin-sdk/command-status.runtime.ts +++ b/src/plugin-sdk/command-status.runtime.ts @@ -10,16 +10,29 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadSessionEntry } from "../gateway/session-utils.js"; export type ResolveDirectStatusReplyForSessionParams = { + /** Caller config used when the target session cannot load a config snapshot. */ cfg: OpenClawConfig; + /** Requested session key; whitespace-only keys produce no status reply. */ sessionKey: string; + /** Channel/surface name used when rendering the status command context. */ channel: string; + /** Optional sender id for command-context rendering and audit output. */ senderId?: string; + /** Whether the requester is an owner and may see owner-only session state. */ senderIsOwner: boolean; + /** Whether the requester passed channel allowlist/authorization checks. */ isAuthorizedSender: boolean; + /** Whether the status reply is being rendered for a group conversation. */ isGroup: boolean; + /** Channel default activation mode used by the status renderer for groups. */ defaultGroupActivation: () => "always" | "mention"; }; +/** + * Builds a direct `/status` reply for an arbitrary session key. + * Unauthorized requesters may see the session exists, but configured reasoning + * state is masked so private agent/session defaults are not leaked. + */ export async function resolveDirectStatusReplyForSession( params: ResolveDirectStatusReplyForSessionParams, ): Promise { @@ -89,6 +102,8 @@ export async function resolveDirectStatusReplyForSession( statusEntry?.reasoningLevel !== undefined && statusEntry.reasoningLevel !== null; const canUseReasoningState = params.senderIsOwner || params.isAuthorizedSender; if (!canUseReasoningState && (sessionReasoningExplicitlySet || hasAgentReasoningDefault)) { + // Reasoning defaults can reveal agent/session configuration; unauthenticated + // direct status callers get the conservative display value instead. resolvedReasoningLevel = "off"; } const reasoningExplicitlySet = sessionReasoningExplicitlySet || hasAgentReasoningDefault; diff --git a/src/plugin-sdk/command-status.ts b/src/plugin-sdk/command-status.ts index 27d1be745dd2..5c04152a2255 100644 --- a/src/plugin-sdk/command-status.ts +++ b/src/plugin-sdk/command-status.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for command and help status message rendering. + */ export { buildCommandsMessage, buildCommandsMessagePaginated, diff --git a/src/plugin-sdk/command-surface.ts b/src/plugin-sdk/command-surface.ts index 553a30108385..3f5e152afbdf 100644 --- a/src/plugin-sdk/command-surface.ts +++ b/src/plugin-sdk/command-surface.ts @@ -1,2 +1,5 @@ +/** + * Public SDK subpath for command text normalization and routing decisions. + */ export { normalizeCommandBody } from "../auto-reply/commands-registry-normalize.js"; export { shouldHandleTextCommands } from "../auto-reply/commands-text-routing.js"; diff --git a/src/plugin-sdk/config-mutation.ts b/src/plugin-sdk/config-mutation.ts index c0773c6919bd..3caf4de38f6e 100644 --- a/src/plugin-sdk/config-mutation.ts +++ b/src/plugin-sdk/config-mutation.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for config file writes and mutation helpers. + */ export { logConfigUpdated } from "../config/logging.js"; export { readConfigFileSnapshotForWrite } from "../config/io.js"; export { mutateConfigFile, replaceConfigFile } from "../config/mutate.js"; diff --git a/src/plugin-sdk/config-runtime.test.ts b/src/plugin-sdk/config-runtime.test.ts index 32e41ad1baf8..512d0385b8fb 100644 --- a/src/plugin-sdk/config-runtime.test.ts +++ b/src/plugin-sdk/config-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests config runtime exports and snapshot/cache behavior exposed through the SDK. + */ import { describe, expect, it } from "vitest"; import { resolveLivePluginConfigObject, diff --git a/src/plugin-sdk/conversation-binding-runtime.ts b/src/plugin-sdk/conversation-binding-runtime.ts index 86d84a95450e..8c70d62f7401 100644 --- a/src/plugin-sdk/conversation-binding-runtime.ts +++ b/src/plugin-sdk/conversation-binding-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for conversation binding routes and session binding records. + */ export { ensureConfiguredBindingRouteReady, resolveConfiguredBindingRoute, diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index 8fd8a4fc5e44..3405d22dd1fb 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -1,3 +1,6 @@ +/** + * Tests core plugin SDK exports and channel plugin construction. + */ import { describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; diff --git a/src/plugin-sdk/cron-store-runtime.ts b/src/plugin-sdk/cron-store-runtime.ts index 31d39eab73c9..859dfbfb73b6 100644 --- a/src/plugin-sdk/cron-store-runtime.ts +++ b/src/plugin-sdk/cron-store-runtime.ts @@ -1 +1,4 @@ +/** + * Runtime SDK subpath for reading and writing persisted cron state. + */ export { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; diff --git a/src/plugin-sdk/dangerous-name-runtime.ts b/src/plugin-sdk/dangerous-name-runtime.ts index 30c968d33e13..c62647c125e3 100644 --- a/src/plugin-sdk/dangerous-name-runtime.ts +++ b/src/plugin-sdk/dangerous-name-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for dangerous provider/channel name matching config. + */ export { isDangerousNameMatchingEnabled, resolveDangerousNameMatchingEnabled, diff --git a/src/plugin-sdk/delivery-queue-runtime.test.ts b/src/plugin-sdk/delivery-queue-runtime.test.ts index 9a6d7bf108a1..7217c992bae2 100644 --- a/src/plugin-sdk/delivery-queue-runtime.test.ts +++ b/src/plugin-sdk/delivery-queue-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests delivery queue runtime ordering and retry behavior. + */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ diff --git a/src/plugin-sdk/delivery-queue-runtime.ts b/src/plugin-sdk/delivery-queue-runtime.ts index fe3fdb04a0ac..9c149c7aa2a2 100644 --- a/src/plugin-sdk/delivery-queue-runtime.ts +++ b/src/plugin-sdk/delivery-queue-runtime.ts @@ -8,6 +8,7 @@ type DrainPendingDeliveriesOptions = Omit< Parameters[0], "deliver" > & { + /** Optional delivery implementation for tests or plugin-owned send paths. */ deliver?: DeliverFn; }; @@ -18,6 +19,11 @@ async function loadOutboundDeliverRuntime(): Promise { const deliver = opts.deliver ?? (await loadOutboundDeliverRuntime()).deliverOutboundPayloadsInternal; diff --git a/src/plugin-sdk/direct-dm-guard-policy.ts b/src/plugin-sdk/direct-dm-guard-policy.ts index 1c3f6b0927bb..5f433541f7b3 100644 --- a/src/plugin-sdk/direct-dm-guard-policy.ts +++ b/src/plugin-sdk/direct-dm-guard-policy.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for direct-message guard policy evaluation. + */ export * from "../channels/direct-dm-guard-policy.js"; diff --git a/src/plugin-sdk/direct-dm.test.ts b/src/plugin-sdk/direct-dm.test.ts index cbd89c421863..d158f42249ce 100644 --- a/src/plugin-sdk/direct-dm.test.ts +++ b/src/plugin-sdk/direct-dm.test.ts @@ -1,3 +1,6 @@ +/** + * Tests direct-message guard policy helpers exposed through the SDK. + */ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { diff --git a/src/plugin-sdk/discord.test.ts b/src/plugin-sdk/discord.test.ts index d578d705149e..3eab89b4541f 100644 --- a/src/plugin-sdk/discord.test.ts +++ b/src/plugin-sdk/discord.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Discord SDK helpers and Discord-facing compatibility behavior. + */ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { diff --git a/src/plugin-sdk/document-extractor.ts b/src/plugin-sdk/document-extractor.ts index f93c4117a8b9..d8efc3369829 100644 --- a/src/plugin-sdk/document-extractor.ts +++ b/src/plugin-sdk/document-extractor.ts @@ -1,3 +1,6 @@ +/** + * Public SDK type surface for document extractor plugins. + */ export type { DocumentExtractedImage, DocumentExtractionRequest, diff --git a/src/plugin-sdk/embedding-providers.ts b/src/plugin-sdk/embedding-providers.ts index b7219a463d9b..7c0769efc9cf 100644 --- a/src/plugin-sdk/embedding-providers.ts +++ b/src/plugin-sdk/embedding-providers.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for embedding provider registration and runtime access. + */ export { getEmbeddingProvider, listEmbeddingProviders, diff --git a/src/plugin-sdk/extension-shared.ts b/src/plugin-sdk/extension-shared.ts index bed63867da45..b93beb8578a3 100644 --- a/src/plugin-sdk/extension-shared.ts +++ b/src/plugin-sdk/extension-shared.ts @@ -35,6 +35,11 @@ type RequireOpenAllowFromFn = (params: { message: string; }) => void; +/** + * Builds the standard passive-channel status object used by plugin status surfaces. + * Missing lifecycle fields are normalized to stable defaults so callers can merge + * plugin-specific extras without leaking `undefined` into status responses. + */ export function buildPassiveChannelStatusSummary( snapshot: PassiveChannelStatusSnapshot, extra?: TExtra, @@ -49,6 +54,7 @@ export function buildPassiveChannelStatusSummary( }; } +/** Adds probe state to the standard passive-channel status summary. */ export function buildPassiveProbedChannelStatusSummary( snapshot: PassiveChannelStatusSnapshot, extra?: TExtra, @@ -60,6 +66,7 @@ export function buildPassiveProbedChannelStatusSummary( }; } +/** Normalizes optional traffic timestamps for channel status payloads. */ export function buildTrafficStatusSummary(snapshot?: TrafficStatusSnapshot | null) { return { lastInboundAt: snapshot?.lastInboundAt ?? null, @@ -67,6 +74,10 @@ export function buildTrafficStatusSummary(snapshot?: TrafficStatusSnapshot | nul }; } +/** + * Runs a passive monitor until the supplied abort signal fires, then calls `stop()`. + * This adapts simple plugin monitors to the shared passive account lifecycle. + */ export async function runStoppablePassiveMonitor(params: { abortSignal: AbortSignal; start: () => Promise; @@ -80,6 +91,10 @@ export async function runStoppablePassiveMonitor( runtime: TRuntime | undefined, logger: Parameters[0]["logger"], @@ -93,6 +108,7 @@ export function resolveLoggerBackedRuntime( ); } +/** Applies the shared validation rule for open DM policies that require wildcard allowlists. */ export function requireChannelOpenAllowFrom(params: { channel: string; policy?: string; @@ -109,6 +125,7 @@ export function requireChannelOpenAllowFrom(params: { }); } +/** Extracts a fixed set of fields from unknown status issue payloads without trusting shape. */ export function readStatusIssueFields( value: unknown, fields: readonly TField[], @@ -124,10 +141,12 @@ export function readStatusIssueFields( return result; } +/** Converts string or numeric account identifiers from status issue payloads to strings. */ export function coerceStatusIssueAccountId(value: unknown): string | undefined { return typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined; } +/** Creates a promise with externally controlled resolve/reject hooks for async handoff code. */ export function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -159,6 +178,7 @@ type PluginConfigIssueMessageOptions = { rootInvalidTypeMessage?: string; }; +/** Formats Zod plugin-config issues into stable user-facing status messages. */ export function formatPluginConfigIssue( issue: z.ZodIssue | undefined, options?: PluginConfigIssueMessageOptions, @@ -175,6 +195,7 @@ export function formatPluginConfigIssue( return issue.message; } +/** Keeps only string/number path segments so config issue paths stay JSON-safe. */ export function normalizePluginConfigIssuePath( path: readonly unknown[], ): PluginConfigIssuePathSegment[] { @@ -184,6 +205,7 @@ export function normalizePluginConfigIssuePath( }); } +/** Converts raw Zod issues into the plugin status issue shape used by bundled channels. */ export function mapPluginConfigIssues( issues: readonly z.ZodIssue[], options?: PluginConfigIssueMessageOptions, @@ -194,6 +216,7 @@ export function mapPluginConfigIssues( })); } +/** Checks whether a read-only plugin path may resolve a secret through an env provider. */ export function canResolveEnvSecretRefInReadOnlyPath(params: { cfg?: OpenClawConfig; provider: string; @@ -210,6 +233,7 @@ export function canResolveEnvSecretRefInReadOnlyPath(params: { return !allowlist || allowlist.includes(params.id); } +/** Reads plugin package versions across source, bundled, and test layouts with a fallback. */ export function readPluginPackageVersion(params: { require: PackageJsonRequire; candidates?: readonly string[]; @@ -228,6 +252,11 @@ export function readPluginPackageVersion(params: { return params.fallback ?? "unknown"; } +/** + * Builds an ambient Node proxy agent when proxy env/config is active. + * Managed proxy CA trust is attached when available; creation errors are reported + * through `onError` and otherwise degrade to no agent. + */ export async function resolveAmbientNodeProxyAgent(params?: { onError?: (error: unknown) => void; onUsingProxy?: () => void; diff --git a/src/plugin-sdk/facade-activation-check.runtime.ts b/src/plugin-sdk/facade-activation-check.runtime.ts index 21a9cc0ea6e7..89fef908e2a6 100644 --- a/src/plugin-sdk/facade-activation-check.runtime.ts +++ b/src/plugin-sdk/facade-activation-check.runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime boundary checks for bundled plugin public-surface facade imports. + */ import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; @@ -31,6 +34,7 @@ const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([ ]); const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {}; +/** Minimal manifest shape needed to decide whether a bundled facade may load. */ export type FacadePluginManifestLike = Pick< PluginManifestRecord, "id" | "origin" | "enabledByDefault" | "enabledByDefaultOnPlatforms" | "rootDir" | "channels" @@ -110,6 +114,7 @@ function getFacadeManifestRegistry(params: { }).plugins; } +/** Resolves the concrete plugin module location recorded in the manifest registry. */ export function resolveRegistryPluginModuleLocation(params: { dirName: string; artifactBasename: string; @@ -237,6 +242,7 @@ function resolveBundledPluginManifestRecord(params: { return resolved; } +/** Resolves the stable plugin id used for telemetry and error reporting. */ export function resolveTrackedFacadePluginId(params: { dirName: string; artifactBasename: string; @@ -248,6 +254,7 @@ export function resolveTrackedFacadePluginId(params: { return resolveBundledPluginManifestRecord(params)?.id ?? params.dirName; } +/** Evaluates whether a bundled plugin's api/runtime-api facade is currently enabled. */ export function resolveBundledPluginPublicSurfaceAccess(params: { dirName: string; artifactBasename: string; @@ -285,6 +292,7 @@ export function resolveBundledPluginPublicSurfaceAccess(params: { }); } +/** Applies normalized config and default enablement rules to one bundled manifest. */ export function evaluateBundledPluginPublicSurfaceAccess(params: { params: { dirName: string; artifactBasename: string }; manifestRecord: FacadePluginManifestLike; @@ -316,6 +324,7 @@ export function evaluateBundledPluginPublicSurfaceAccess(params: { }; } +/** Throws the public error used when a disabled bundled plugin facade is imported. */ export function throwForBundledPluginPublicSurfaceAccess(params: { access: { allowed: boolean; pluginId?: string; reason?: string }; request: { dirName: string; artifactBasename: string }; @@ -326,6 +335,7 @@ export function throwForBundledPluginPublicSurfaceAccess(params: { ); } +/** Resolves bundled facade access and throws unless the facade is allowed to load. */ export function resolveActivatedBundledPluginPublicSurfaceAccessOrThrow(params: { dirName: string; artifactBasename: string; diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index 3e89a905c0f5..c94e0890afb9 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -1,3 +1,6 @@ +/** + * Tests bundled plugin facade loader resolution and activation checks. + */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/src/plugin-sdk/facade-resolution-shared.ts b/src/plugin-sdk/facade-resolution-shared.ts index 90c59cf28b5e..6118cc34e1af 100644 --- a/src/plugin-sdk/facade-resolution-shared.ts +++ b/src/plugin-sdk/facade-resolution-shared.ts @@ -1,3 +1,6 @@ +/** + * Shared resolver for bundled plugin facade module paths and registry fallbacks. + */ import fs from "node:fs"; import path from "node:path"; import { areBundledPluginsDisabled } from "../plugins/bundled-dir.js"; @@ -8,6 +11,7 @@ import { resolveBundledPluginSourcePublicSurfacePath, } from "../plugins/public-surface-runtime.js"; +/** Resolved facade module path plus the package/plugin root that bounds imports. */ export type FacadeModuleLocationLike = { modulePath: string; boundaryRoot: string; @@ -19,6 +23,7 @@ type FacadeRegistryRecordLike = { channels: readonly string[]; }; +/** Builds the cache key for one facade lookup under the current bundled-plugin mode. */ export function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string; @@ -31,6 +36,7 @@ export function createFacadeResolutionKey(params: { }::${disabledKey}`; } +/** Chooses the boundary root that should constrain a resolved facade module. */ export function resolveFacadeBoundaryRoot(params: { modulePath: string; bundledPluginsDir?: string | null; @@ -45,6 +51,7 @@ export function resolveFacadeBoundaryRoot(params: { : params.packageRoot; } +/** Resolves a bundled facade from source in dev and built artifacts in dist installs. */ export function resolveBundledFacadeModuleLocation(params: { currentModulePath: string; packageRoot: string; @@ -93,6 +100,7 @@ export function resolveBundledFacadeModuleLocation(params: { : null; } +/** Resolves a facade path from manifest registry records using id, folder, then channel matches. */ export function resolveRegistryPluginModuleLocationFromRecords(params: { registry: readonly FacadeRegistryRecordLike[]; dirName: string; diff --git a/src/plugin-sdk/fetch-auth.ts b/src/plugin-sdk/fetch-auth.ts index b29df19953db..61811da9b2eb 100644 --- a/src/plugin-sdk/fetch-auth.ts +++ b/src/plugin-sdk/fetch-auth.ts @@ -4,6 +4,7 @@ import { } from "../infra/fetch-headers.js"; export type ScopeTokenProvider = { + /** Return a bearer token for the requested OAuth/API scope. */ getAccessToken: (scope: string) => Promise; }; @@ -13,13 +14,21 @@ function isAuthFailureStatus(status: number): boolean { /** Retry a fetch with bearer tokens from the provided scopes when the unauthenticated attempt fails. */ export async function fetchWithBearerAuthScopeFallback(params: { + /** Absolute URL to request. */ url: string; + /** Token scopes to try in order after the initial unauthenticated request fails. */ scopes: readonly string[]; + /** Optional token source; when omitted, only the unauthenticated request is attempted. */ tokenProvider?: ScopeTokenProvider; + /** Fetch implementation override for tests or plugin runtimes. Defaults to global `fetch`. */ fetchFn?: typeof fetch; + /** Request options reused across unauthenticated and authenticated attempts. */ requestInit?: RequestInit; + /** Reject non-HTTPS URLs before any request is sent. */ requireHttps?: boolean; + /** Optional policy gate for whether this URL is allowed to receive bearer auth. */ shouldAttachAuth?: (url: string) => boolean; + /** Override which responses should trigger scoped-token retries. Defaults to 401/403. */ shouldRetry?: (response: Response) => boolean; }): Promise { const fetchFn = params.fetchFn ?? fetch; diff --git a/src/plugin-sdk/fetch-runtime.test.ts b/src/plugin-sdk/fetch-runtime.test.ts index c2e92842e4f2..fd633efce886 100644 --- a/src/plugin-sdk/fetch-runtime.test.ts +++ b/src/plugin-sdk/fetch-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin SDK fetch runtime helpers and fixture path behavior. + */ import path from "node:path"; import { pathToFileURL } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/file-lock.test.ts b/src/plugin-sdk/file-lock.test.ts index 0b72f1c758a9..84475d612adc 100644 --- a/src/plugin-sdk/file-lock.test.ts +++ b/src/plugin-sdk/file-lock.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin SDK file lock retry, stale lock, and cleanup behavior. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index 0ab2f2d9f87a..777b3e5c091d 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -8,6 +8,7 @@ import { shouldRemoveDeadOwnerOrExpiredLock } from "../infra/stale-lock-file.js" import { getProcessStartTime } from "../shared/pid-alive.js"; export type FileLockOptions = { + /** Retry policy used while waiting for another process or re-entrant holder to release. */ retries: { retries: number; factor: number; @@ -15,11 +16,14 @@ export type FileLockOptions = { maxTimeout: number; randomize?: boolean; }; + /** Milliseconds after which a dead-owner or expired sidecar lock may be reclaimed. */ stale: number; }; export type FileLockHandle = { + /** Absolute path to the `.lock` sidecar held for this file path. */ lockPath: string; + /** Releases one held reference; callers must await it before assuming peers can proceed. */ release: () => Promise; }; @@ -27,12 +31,16 @@ export const FILE_LOCK_TIMEOUT_ERROR_CODE = "file_lock_timeout"; export const FILE_LOCK_STALE_ERROR_CODE = "file_lock_stale"; export type FileLockTimeoutError = Error & { + /** Stable error discriminator for lock acquisition timeout handling. */ code: typeof FILE_LOCK_TIMEOUT_ERROR_CODE; + /** Lock sidecar path that could not be acquired before retries were exhausted. */ lockPath: string; }; export type FileLockStaleError = Error & { + /** Stable error discriminator for stale-lock reclaim failures. */ code: typeof FILE_LOCK_STALE_ERROR_CODE; + /** Lock sidecar path that could not be safely reclaimed. */ lockPath: string; }; diff --git a/src/plugin-sdk/fs-safe-compat.test.ts b/src/plugin-sdk/fs-safe-compat.test.ts index e6fad47cea60..d48d01d67cce 100644 --- a/src/plugin-sdk/fs-safe-compat.test.ts +++ b/src/plugin-sdk/fs-safe-compat.test.ts @@ -1,3 +1,6 @@ +/** + * Tests fs-safe compatibility exports used by plugin SDK callers. + */ import fs from "node:fs"; import path from "node:path"; import { loadSecretFileSync as loadSecretFileSyncFromCore } from "openclaw/plugin-sdk/core"; diff --git a/src/plugin-sdk/gateway-method-runtime.test.ts b/src/plugin-sdk/gateway-method-runtime.test.ts index 35c7b5cad0e9..a5e480c42095 100644 --- a/src/plugin-sdk/gateway-method-runtime.test.ts +++ b/src/plugin-sdk/gateway-method-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests gateway method runtime wrappers exposed to plugins. + */ import { describe, expect, it, vi } from "vitest"; import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { dispatchGatewayMethod } from "./gateway-method-runtime.js"; diff --git a/src/plugin-sdk/gateway-method-runtime.ts b/src/plugin-sdk/gateway-method-runtime.ts index 7fcffa792a16..0afe88042800 100644 --- a/src/plugin-sdk/gateway-method-runtime.ts +++ b/src/plugin-sdk/gateway-method-runtime.ts @@ -2,22 +2,33 @@ import { dispatchGatewayMethodInProcessRaw } from "../gateway/server-plugins.js" import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; export type GatewayMethodDispatchError = { + /** Stable machine-readable error code returned by the Gateway method. */ code: string; + /** Human-readable error summary safe to forward to the plugin caller. */ message: string; + /** Optional structured method-specific diagnostics. */ details?: unknown; + /** Whether the caller can retry the same request without changing params. */ retryable?: boolean; + /** Suggested delay before retrying when the Gateway can estimate backoff. */ retryAfterMs?: number; }; export type GatewayMethodDispatchResponse = { + /** True when the Gateway method completed and `payload` contains its result. */ ok: boolean; + /** Method-specific result payload for successful responses. */ payload?: unknown; + /** Gateway error envelope for failed responses. */ error?: GatewayMethodDispatchError; + /** Optional response metadata that plugins may pass through unchanged. */ meta?: Record; }; export type GatewayMethodDispatchOptions = { + /** Wait for the Gateway's final response instead of returning the first response frame. */ expectFinal?: boolean; + /** Maximum time to wait for Gateway dispatch before the runtime reports a timeout. */ timeoutMs?: number; }; @@ -25,12 +36,17 @@ export type GatewayMethodDispatchOptions = { * Dispatch a Gateway control-plane method from an authenticated plugin request scope. */ export async function dispatchGatewayMethod( + /** Gateway method name, validated by the Gateway method router. */ method: string, + /** Method-specific params forwarded without SDK-side normalization. */ params?: unknown, + /** Dispatch behavior controls for response timing and timeout handling. */ options?: GatewayMethodDispatchOptions, ): Promise { const scope = getPluginRuntimeGatewayRequestScope(); if (scope?.gatewayMethodDispatchAllowed !== true) { + // Gateway methods can mutate/control local runtime state; require the + // authenticated HTTP-route scope recorded by the plugin loader contract. const pluginLabel = scope?.pluginId ? ` for plugin "${scope.pluginId}"` : ""; throw new Error( `Gateway method dispatch is reserved for plugin HTTP routes that declare contracts.gatewayMethodDispatch: ["authenticated-request"]${pluginLabel}.`, diff --git a/src/plugin-sdk/global-singleton.ts b/src/plugin-sdk/global-singleton.ts index 269713f19219..7860893b9314 100644 --- a/src/plugin-sdk/global-singleton.ts +++ b/src/plugin-sdk/global-singleton.ts @@ -1,2 +1,5 @@ +/** + * Public SDK subpath for process-wide singleton and scoped expiring cache helpers. + */ export * from "../shared/global-singleton.js"; export * from "../shared/scoped-expiring-id-cache.js"; diff --git a/src/plugin-sdk/google-model-id.ts b/src/plugin-sdk/google-model-id.ts index 293a26184d63..827dfcc2781d 100644 --- a/src/plugin-sdk/google-model-id.ts +++ b/src/plugin-sdk/google-model-id.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for normalizing Google and Antigravity preview model ids. + */ export { normalizeAntigravityPreviewModelId as normalizeAntigravityModelId, normalizeGooglePreviewModelId as normalizeGoogleModelId, diff --git a/src/plugin-sdk/group-access.test.ts b/src/plugin-sdk/group-access.test.ts index 2d2460daa493..d4024c4ec615 100644 --- a/src/plugin-sdk/group-access.test.ts +++ b/src/plugin-sdk/group-access.test.ts @@ -1,3 +1,6 @@ +/** + * Tests group access policy helpers and SDK-visible access decisions. + */ import { describe, expect, it } from "vitest"; import { evaluateGroupRouteAccessForPolicy, diff --git a/src/plugin-sdk/health.ts b/src/plugin-sdk/health.ts index 8b9532e318f5..44730432f8fe 100644 --- a/src/plugin-sdk/health.ts +++ b/src/plugin-sdk/health.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for health checks, doctor linting, and repair result types. + */ export { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; export { readConfigFileSnapshot } from "../config/config.js"; export type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/plugin-sdk/image-generation-core.auth.runtime.ts b/src/plugin-sdk/image-generation-core.auth.runtime.ts index fe6804373fc8..f7cd7e4008fe 100644 --- a/src/plugin-sdk/image-generation-core.auth.runtime.ts +++ b/src/plugin-sdk/image-generation-core.auth.runtime.ts @@ -1 +1,4 @@ +/** + * Runtime SDK subpath for resolving image-generation provider API keys. + */ export { resolveApiKeyForProvider } from "../agents/model-auth.js"; diff --git a/src/plugin-sdk/image-generation-runtime.ts b/src/plugin-sdk/image-generation-runtime.ts index d3b83ae43163..4bff3bdbd04d 100644 --- a/src/plugin-sdk/image-generation-runtime.ts +++ b/src/plugin-sdk/image-generation-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for image generation provider access. + */ export { generateImage, listRuntimeImageGenerationProviders, diff --git a/src/plugin-sdk/inline-image-data-url-runtime.ts b/src/plugin-sdk/inline-image-data-url-runtime.ts index 401d2eb15095..d5d6ba79a013 100644 --- a/src/plugin-sdk/inline-image-data-url-runtime.ts +++ b/src/plugin-sdk/inline-image-data-url-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for validating and sanitizing inline image data URLs. + */ export { INLINE_IMAGE_DATA_URL_PREFIX, sanitizeInlineImageDataUrl, diff --git a/src/plugin-sdk/interactive-runtime.ts b/src/plugin-sdk/interactive-runtime.ts index bd4c60094972..855cb85fa4c3 100644 --- a/src/plugin-sdk/interactive-runtime.ts +++ b/src/plugin-sdk/interactive-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for interactive replies and message presentation helpers. + */ export { adaptMessagePresentationForChannel, applyPresentationActionLimits, diff --git a/src/plugin-sdk/json-unsafe-integers.ts b/src/plugin-sdk/json-unsafe-integers.ts index def5c7fd33c3..795e76f123ed 100644 --- a/src/plugin-sdk/json-unsafe-integers.ts +++ b/src/plugin-sdk/json-unsafe-integers.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for JSON parsing that preserves unsafe integer literals. + */ export { parseJsonObjectPreservingUnsafeIntegers, parseJsonPreservingUnsafeIntegers, diff --git a/src/plugin-sdk/keyed-async-queue.test.ts b/src/plugin-sdk/keyed-async-queue.test.ts index 9630d6ba6844..9cf7cdf072b4 100644 --- a/src/plugin-sdk/keyed-async-queue.test.ts +++ b/src/plugin-sdk/keyed-async-queue.test.ts @@ -1,3 +1,6 @@ +/** + * Tests keyed async queue serialization and cancellation behavior. + */ import { describe, expect, it, vi } from "vitest"; import { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js"; diff --git a/src/plugin-sdk/lazy-runtime.ts b/src/plugin-sdk/lazy-runtime.ts index e1f829204a2b..7020fbd934c9 100644 --- a/src/plugin-sdk/lazy-runtime.ts +++ b/src/plugin-sdk/lazy-runtime.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for lazy runtime module and method binding helpers. + */ export { createLazyRuntimeModule, createLazyRuntimeMethod, diff --git a/src/plugin-sdk/lazy-value.test.ts b/src/plugin-sdk/lazy-value.test.ts index 80859fe266e1..625aa4c6a995 100644 --- a/src/plugin-sdk/lazy-value.test.ts +++ b/src/plugin-sdk/lazy-value.test.ts @@ -1,3 +1,6 @@ +/** + * Tests cached lazy value getter behavior and fallback handling. + */ import { describe, expect, it, vi } from "vitest"; import { createCachedLazyValueGetter } from "./lazy-value.js"; diff --git a/src/plugin-sdk/lazy-value.ts b/src/plugin-sdk/lazy-value.ts index 6a0a859e0a06..10a94deecc58 100644 --- a/src/plugin-sdk/lazy-value.ts +++ b/src/plugin-sdk/lazy-value.ts @@ -1,5 +1,9 @@ +/** + * Public SDK helper for caching a lazily computed value behind a getter. + */ type LazyValue = T | (() => T); +/** Returns a getter that resolves the supplied value at most once. */ export function createCachedLazyValueGetter(value: LazyValue): () => T; export function createCachedLazyValueGetter( value: LazyValue, diff --git a/src/plugin-sdk/llm.ts b/src/plugin-sdk/llm.ts index 89b80bb4c022..8ddeb591f157 100644 --- a/src/plugin-sdk/llm.ts +++ b/src/plugin-sdk/llm.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for LLM provider registration, streaming, model utils, and validation. + */ export { getApiProvider, getApiProviders, diff --git a/src/plugin-sdk/lmstudio.ts b/src/plugin-sdk/lmstudio.ts index e4224d5ebae9..9d1ba63a532b 100644 --- a/src/plugin-sdk/lmstudio.ts +++ b/src/plugin-sdk/lmstudio.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for LM Studio provider config, discovery, and auth helpers. + */ import type { OpenClawConfig } from "../config/types.js"; import type { ProviderAuthMethodNonInteractiveContext, @@ -83,19 +86,23 @@ function loadFacadeModule(): FacadeModule { }); } +/** Prompts for LM Studio configuration through the activated bundled provider facade. */ export const promptAndConfigureLmstudioInteractive: FacadeModule["promptAndConfigureLmstudioInteractive"] = ((...args) => loadFacadeModule().promptAndConfigureLmstudioInteractive( ...args, )) as FacadeModule["promptAndConfigureLmstudioInteractive"]; +/** Applies non-interactive LM Studio auth/configuration through the provider facade. */ export const configureLmstudioNonInteractive: FacadeModule["configureLmstudioNonInteractive"] = (( ...args ) => loadFacadeModule().configureLmstudioNonInteractive( ...args, )) as FacadeModule["configureLmstudioNonInteractive"]; +/** Discovers LM Studio provider config through the activated provider facade. */ export const discoverLmstudioProvider: FacadeModule["discoverLmstudioProvider"] = ((...args) => loadFacadeModule().discoverLmstudioProvider(...args)) as FacadeModule["discoverLmstudioProvider"]; +/** Prepares dynamic LM Studio models through the activated provider facade. */ export const prepareLmstudioDynamicModels: FacadeModule["prepareLmstudioDynamicModels"] = (( ...args ) => diff --git a/src/plugin-sdk/logging-core.ts b/src/plugin-sdk/logging-core.ts index bbaa346fb887..b7f676b8b449 100644 --- a/src/plugin-sdk/logging-core.ts +++ b/src/plugin-sdk/logging-core.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for logging, diagnostics, and redaction helpers. + */ export { createSubsystemLogger } from "../logging/subsystem.js"; export { getChildLogger, diff --git a/src/plugin-sdk/markdown-table-runtime.ts b/src/plugin-sdk/markdown-table-runtime.ts index 24a621e29f1a..91891fa769af 100644 --- a/src/plugin-sdk/markdown-table-runtime.ts +++ b/src/plugin-sdk/markdown-table-runtime.ts @@ -1,3 +1,8 @@ +/** Resolve the channel-specific markdown table rendering mode from config defaults. */ export { resolveMarkdownTableMode } from "../config/markdown-tables.js"; + +/** Convert markdown tables using the resolved channel mode before message delivery. */ export { convertMarkdownTables } from "../../packages/markdown-core/src/tables.js"; + +/** Public markdown table conversion mode accepted by config and channel helpers. */ export type { MarkdownTableMode } from "../config/types.base.js"; diff --git a/src/plugin-sdk/media-understanding-runtime.ts b/src/plugin-sdk/media-understanding-runtime.ts index 94374056eb48..e82a36d9ff2b 100644 --- a/src/plugin-sdk/media-understanding-runtime.ts +++ b/src/plugin-sdk/media-understanding-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for media understanding, image description, and audio transcription. + */ export { describeImageFile, describeImageFileWithModel, diff --git a/src/plugin-sdk/memory-core-bundled-runtime.test.ts b/src/plugin-sdk/memory-core-bundled-runtime.test.ts index 43a3c0a2365a..2b3464e8aae7 100644 --- a/src/plugin-sdk/memory-core-bundled-runtime.test.ts +++ b/src/plugin-sdk/memory-core-bundled-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests bundled memory core runtime facade loading. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); diff --git a/src/plugin-sdk/memory-core-engine-runtime.test.ts b/src/plugin-sdk/memory-core-engine-runtime.test.ts index 1fb400d34c6b..2caa94a07927 100644 --- a/src/plugin-sdk/memory-core-engine-runtime.test.ts +++ b/src/plugin-sdk/memory-core-engine-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests memory core engine runtime facade behavior. + */ import { describe, expect, it } from "vitest"; import type { ShortTermAuditIssue } from "./memory-core-engine-runtime.js"; diff --git a/src/plugin-sdk/memory-core-host-embedding-registry.ts b/src/plugin-sdk/memory-core-host-embedding-registry.ts index 99993652d402..205ebdd34dcb 100644 --- a/src/plugin-sdk/memory-core-host-embedding-registry.ts +++ b/src/plugin-sdk/memory-core-host-embedding-registry.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for memory host embedding provider registration and lookup. + */ export { DEFAULT_LOCAL_MODEL } from "../../packages/memory-host-sdk/src/host/embedding-defaults.js"; export { listMemoryEmbeddingProviders, diff --git a/src/plugin-sdk/memory-core-host-engine-foundation.ts b/src/plugin-sdk/memory-core-host-engine-foundation.ts index acf881bbe666..2d25c22be3ef 100644 --- a/src/plugin-sdk/memory-core-host-engine-foundation.ts +++ b/src/plugin-sdk/memory-core-host-engine-foundation.ts @@ -1,3 +1,6 @@ +/** + * Public SDK foundation surface for memory host engine config, paths, and shared helpers. + */ export * from "../../packages/memory-host-sdk/src/engine-foundation.js"; export { resolveAgentContextLimits, diff --git a/src/plugin-sdk/memory-core-host-engine-qmd.ts b/src/plugin-sdk/memory-core-host-engine-qmd.ts index 21a0be44873f..a9b5db639a62 100644 --- a/src/plugin-sdk/memory-core-host-engine-qmd.ts +++ b/src/plugin-sdk/memory-core-host-engine-qmd.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for memory host QMD engine helpers. + */ export * from "../../packages/memory-host-sdk/src/engine-qmd.js"; diff --git a/src/plugin-sdk/memory-core-host-engine-storage.ts b/src/plugin-sdk/memory-core-host-engine-storage.ts index c0614268ad30..b5e9e807243e 100644 --- a/src/plugin-sdk/memory-core-host-engine-storage.ts +++ b/src/plugin-sdk/memory-core-host-engine-storage.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for memory host storage, indexing, and search primitives. + */ export { buildFileEntry, buildMemoryReadResult, @@ -27,8 +30,10 @@ export { statRegularFile, } from "../../packages/memory-host-sdk/src/engine-storage.js"; +/** Origin bucket for memory search results exposed through the SDK. */ export type MemorySource = "memory" | "sessions"; +/** Normalized search hit shape returned by memory host searches. */ export type MemorySearchResult = { path: string; startLine: number; @@ -41,6 +46,7 @@ export type MemorySearchResult = { citation?: string; }; +/** Health probe result for embedding provider availability checks. */ export type MemoryEmbeddingProbeResult = { ok: boolean; error?: string; diff --git a/src/plugin-sdk/memory-core-host-runtime-cli.ts b/src/plugin-sdk/memory-core-host-runtime-cli.ts index 7976dd239126..f788e5eef671 100644 --- a/src/plugin-sdk/memory-core-host-runtime-cli.ts +++ b/src/plugin-sdk/memory-core-host-runtime-cli.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for memory host CLI runtime utilities and terminal helpers. + */ export * from "../../packages/memory-host-sdk/src/runtime-cli.js"; export { formatErrorMessage, withManager } from "../cli/cli-utils.js"; export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; diff --git a/src/plugin-sdk/memory-core-host-runtime-files.ts b/src/plugin-sdk/memory-core-host-runtime-files.ts index f4daa70a22d1..4b8d63ad9fcf 100644 --- a/src/plugin-sdk/memory-core-host-runtime-files.ts +++ b/src/plugin-sdk/memory-core-host-runtime-files.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for memory host runtime file path helpers. + */ export * from "../../packages/memory-host-sdk/src/runtime-files.js"; diff --git a/src/plugin-sdk/memory-core-host-secret.ts b/src/plugin-sdk/memory-core-host-secret.ts index 285776b9617f..20c8e06de4a7 100644 --- a/src/plugin-sdk/memory-core-host-secret.ts +++ b/src/plugin-sdk/memory-core-host-secret.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for memory host secret input resolution. + */ export { hasConfiguredMemorySecretInput, resolveMemorySecretInputString, diff --git a/src/plugin-sdk/memory-core-host-status.ts b/src/plugin-sdk/memory-core-host-status.ts index 474420fa67da..92967010f834 100644 --- a/src/plugin-sdk/memory-core-host-status.ts +++ b/src/plugin-sdk/memory-core-host-status.ts @@ -1,2 +1,5 @@ +/** + * Public SDK subpath for memory host status and dreaming state helpers. + */ export * from "../../packages/memory-host-sdk/src/status.js"; export * from "../memory-host-sdk/dreaming.js"; diff --git a/src/plugin-sdk/memory-host-core.test.ts b/src/plugin-sdk/memory-host-core.test.ts index fa5d363a36e7..6afcd4f221ec 100644 --- a/src/plugin-sdk/memory-host-core.test.ts +++ b/src/plugin-sdk/memory-host-core.test.ts @@ -1,3 +1,6 @@ +/** + * Tests memory host core public artifact discovery and workspace handling. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/plugin-sdk/memory-host-core.ts b/src/plugin-sdk/memory-host-core.ts index d064225a8bbc..fd03a76a34d7 100644 --- a/src/plugin-sdk/memory-host-core.ts +++ b/src/plugin-sdk/memory-host-core.ts @@ -1,3 +1,6 @@ +/** + * Public SDK facade for memory host runtime core and public artifact discovery. + */ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; @@ -32,6 +35,7 @@ async function listMarkdownFilesRecursive(rootDir: string): Promise { return files.toSorted((left, right) => left.localeCompare(right)); } +/** Lists public memory artifacts for one workspace, including notes and event logs. */ export async function listMemoryWorkspacePublicArtifacts(params: { workspaceDir: string; agentIds: string[]; @@ -87,6 +91,7 @@ export async function listMemoryWorkspacePublicArtifacts(params: { return [...deduped.values()]; } +/** Lists public memory artifacts across all configured memory workspaces. */ export async function listMemoryHostPublicArtifacts(params: { cfg: OpenClawConfig; }): Promise { diff --git a/src/plugin-sdk/memory-host-events.test.ts b/src/plugin-sdk/memory-host-events.test.ts index c8a41800a031..39ec6a8a28f0 100644 --- a/src/plugin-sdk/memory-host-events.test.ts +++ b/src/plugin-sdk/memory-host-events.test.ts @@ -1,3 +1,6 @@ +/** + * Tests memory host event log helpers and persisted event behavior. + */ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/memory-host-events.ts b/src/plugin-sdk/memory-host-events.ts index 8f68c7d1e6ce..8b06b1522ec1 100644 --- a/src/plugin-sdk/memory-host-events.ts +++ b/src/plugin-sdk/memory-host-events.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for memory host event log types and helpers. + */ export * from "../memory-host-sdk/events.js"; diff --git a/src/plugin-sdk/memory-host-markdown.test.ts b/src/plugin-sdk/memory-host-markdown.test.ts index c9a2884698fa..c52e60a41f14 100644 --- a/src/plugin-sdk/memory-host-markdown.test.ts +++ b/src/plugin-sdk/memory-host-markdown.test.ts @@ -1,3 +1,6 @@ +/** + * Tests managed Markdown block replacement helpers. + */ import { describe, expect, it } from "vitest"; import { replaceManagedMarkdownBlock, withTrailingNewline } from "./memory-host-markdown.js"; diff --git a/src/plugin-sdk/memory-host-markdown.ts b/src/plugin-sdk/memory-host-markdown.ts index d516cbc1c3de..a2e14fbccd49 100644 --- a/src/plugin-sdk/memory-host-markdown.ts +++ b/src/plugin-sdk/memory-host-markdown.ts @@ -1,3 +1,6 @@ +/** + * Public SDK helpers for maintaining generated blocks inside Markdown files. + */ export type ManagedMarkdownBlockParams = { original: string; body: string; @@ -14,10 +17,12 @@ function isLineWhitespace(value: string): boolean { return /^[\t \r\n]*$/.test(value); } +/** Ensures generated Markdown content ends with exactly the caller-provided body plus newline. */ export function withTrailingNewline(content: string): string { return content.endsWith("\n") ? content : `${content}\n`; } +/** Replaces all existing managed blocks with one current block, or appends it if missing. */ export function replaceManagedMarkdownBlock(params: ManagedMarkdownBlockParams): string { const headingPrefix = params.heading ? `${params.heading}\n` : ""; const managedBlock = `${headingPrefix}${params.startMarker}\n${params.body}\n${params.endMarker}`; diff --git a/src/plugin-sdk/memory-host-search.runtime.ts b/src/plugin-sdk/memory-host-search.runtime.ts index 5c81f64a90f5..9241d3c8a26f 100644 --- a/src/plugin-sdk/memory-host-search.runtime.ts +++ b/src/plugin-sdk/memory-host-search.runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for active memory search manager operations. + */ export { closeActiveMemorySearchManager, closeActiveMemorySearchManagers, diff --git a/src/plugin-sdk/memory-host-search.test.ts b/src/plugin-sdk/memory-host-search.test.ts index 09ac487464ec..bccfedbeaafe 100644 --- a/src/plugin-sdk/memory-host-search.test.ts +++ b/src/plugin-sdk/memory-host-search.test.ts @@ -1,3 +1,6 @@ +/** + * Tests memory host search manager lifecycle helpers. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { diff --git a/src/plugin-sdk/memory-host-search.ts b/src/plugin-sdk/memory-host-search.ts index c31f8939dc6e..481eb676ad34 100644 --- a/src/plugin-sdk/memory-host-search.ts +++ b/src/plugin-sdk/memory-host-search.ts @@ -1,8 +1,12 @@ +/** + * Lazy public SDK facade for active memory search manager lifecycle operations. + */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RegisteredMemorySearchManager } from "../plugins/memory-state.js"; type ActiveMemorySearchPurpose = "default" | "status"; +/** Active manager lookup result, including a soft error when memory is unavailable. */ export type ActiveMemorySearchManagerResult = { manager: RegisteredMemorySearchManager | null; error?: string; @@ -14,6 +18,7 @@ async function loadMemoryHostSearchRuntime(): Promise { const runtime = await loadMemoryHostSearchRuntime(); await runtime.closeActiveMemorySearchManagers(cfg); } +/** Closes the active memory search manager for one agent. */ export async function closeActiveMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; diff --git a/src/plugin-sdk/migration-runtime.test.ts b/src/plugin-sdk/migration-runtime.test.ts index 6642b18b1723..d7633d07e560 100644 --- a/src/plugin-sdk/migration-runtime.test.ts +++ b/src/plugin-sdk/migration-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin SDK migration runtime facades and migration helper behavior. + */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; diff --git a/src/plugin-sdk/migration.ts b/src/plugin-sdk/migration.ts index d93947179e75..2aada95a291e 100644 --- a/src/plugin-sdk/migration.ts +++ b/src/plugin-sdk/migration.ts @@ -22,6 +22,7 @@ export type { export const MIGRATION_REASON_MISSING_SOURCE_OR_TARGET = "missing source or target"; export const MIGRATION_REASON_TARGET_EXISTS = "target exists"; +/** Creates a migration item, defaulting new provider output to the planned state. */ export function createMigrationItem( params: Omit & { status?: MigrationItem["status"] }, ): MigrationItem { @@ -31,18 +32,22 @@ export function createMigrationItem( }; } +/** Marks a planned item as blocked by an existing target value. */ export function markMigrationItemConflict(item: MigrationItem, reason: string): MigrationItem { return { ...item, status: "conflict", reason }; } +/** Marks an item as failed during detection or apply. */ export function markMigrationItemError(item: MigrationItem, reason: string): MigrationItem { return { ...item, status: "error", reason }; } +/** Marks an item as intentionally skipped, usually for manual follow-up. */ export function markMigrationItemSkipped(item: MigrationItem, reason: string): MigrationItem { return { ...item, status: "skipped", reason }; } +/** Counts migration item statuses for provider plans, apply results, and CLI reports. */ export function summarizeMigrationItems(items: readonly MigrationItem[]): MigrationSummary { return { total: items.length, @@ -94,7 +99,9 @@ function isSecretKey(key: string): boolean { } export type MigrationConfigPatchDetails = { + /** Config object path where the patch should be merged. */ path: string[]; + /** Patch value stored on the migration item. */ value: unknown; }; @@ -105,6 +112,7 @@ class MigrationConfigPatchConflictError extends Error { } } +/** Reads a nested config value, returning undefined when a parent is not an object. */ export function readMigrationConfigPath( root: Record, path: readonly string[], @@ -119,6 +127,7 @@ export function readMigrationConfigPath( return current; } +/** Deep-merges object patches and replaces scalar/array values with a cloned target value. */ export function mergeMigrationConfigValue(left: unknown, right: unknown): unknown { if (!isRecord(left) || !isRecord(right)) { return structuredClone(right); @@ -130,6 +139,7 @@ export function mergeMigrationConfigValue(left: unknown, right: unknown): unknow return next; } +/** Writes a config patch path in-place, creating missing object parents as needed. */ export function writeMigrationConfigPath( root: Record, path: readonly string[], @@ -150,12 +160,14 @@ export function writeMigrationConfigPath( current[leaf] = mergeMigrationConfigValue(current[leaf], value); } +/** Checks whether a config patch would overwrite existing leaf keys without `--overwrite`. */ export function hasMigrationConfigPatchConflict( config: MigrationProviderContext["config"], path: readonly string[], value: unknown, ): boolean { if (!isRecord(value)) { + // Scalar patches conflict with any existing value at the target path. return readMigrationConfigPath(config as Record, path) !== undefined; } const existing = readMigrationConfigPath(config as Record, path); @@ -165,6 +177,7 @@ export function hasMigrationConfigPatchConflict( return Object.keys(value).some((key) => existing[key] !== undefined); } +/** Builds a planned or conflicting config-merge migration item. */ export function createMigrationConfigPatchItem(params: { id: string; target: string; @@ -189,6 +202,7 @@ export function createMigrationConfigPatchItem(params: { }); } +/** Builds a skipped item that records user-facing manual migration guidance. */ export function createMigrationManualItem(params: { id: string; source: string; @@ -206,6 +220,7 @@ export function createMigrationManualItem(params: { }); } +/** Reads config patch metadata from an item produced by `createMigrationConfigPatchItem`. */ export function readMigrationConfigPatchDetails( item: MigrationItem, ): MigrationConfigPatchDetails | undefined { @@ -219,6 +234,7 @@ export function readMigrationConfigPatchDetails( return { path, value: item.details?.value }; } +/** Applies one planned config patch through the runtime config writer and returns its final status. */ export async function applyMigrationConfigPatchItem( ctx: MigrationProviderContext, item: MigrationItem, @@ -261,6 +277,7 @@ export async function applyMigrationConfigPatchItem( } } +/** Manual items never mutate state; applying one preserves the skipped/manual status. */ export function applyMigrationManualItem(item: MigrationItem): MigrationItem { return markMigrationItemSkipped(item, item.reason ?? "manual follow-up required"); } @@ -309,14 +326,17 @@ function redactMigrationValueInternal(value: unknown, seen: WeakSet): un return next; } +/** Redacts likely secret values while preserving SecretRef-like objects for operator context. */ export function redactMigrationValue(value: unknown): unknown { return redactMigrationValueInternal(value, new WeakSet()); } +/** Redacts sensitive fields from one migration item before report/output serialization. */ export function redactMigrationItem(item: MigrationItem): MigrationItem { return redactMigrationValue(item) as MigrationItem; } +/** Redacts sensitive fields from a full migration plan before report/output serialization. */ export function redactMigrationPlan(plan: T): T { return redactMigrationValue(plan) as T; } diff --git a/src/plugin-sdk/model-session-runtime.ts b/src/plugin-sdk/model-session-runtime.ts index 41a874231d10..7fb421dce3a5 100644 --- a/src/plugin-sdk/model-session-runtime.ts +++ b/src/plugin-sdk/model-session-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for model overrides and agent concurrency session helpers. + */ export { resolveChannelModelOverride } from "../channels/model-overrides.js"; export { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; export { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; diff --git a/src/plugin-sdk/models-provider-runtime.ts b/src/plugin-sdk/models-provider-runtime.ts index 20dfbb2a5f0d..9caab6253232 100644 --- a/src/plugin-sdk/models-provider-runtime.ts +++ b/src/plugin-sdk/models-provider-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for building model-provider command replies. + */ export { buildModelsProviderData, formatModelsAvailableHeader, diff --git a/src/plugin-sdk/native-command-config-runtime.ts b/src/plugin-sdk/native-command-config-runtime.ts index 27c5df606a6c..d6389cdab66b 100644 --- a/src/plugin-sdk/native-command-config-runtime.ts +++ b/src/plugin-sdk/native-command-config-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for native command and skill enablement config checks. + */ export { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, diff --git a/src/plugin-sdk/native-command-registry.ts b/src/plugin-sdk/native-command-registry.ts index 05de39bdc667..63d0f07a397a 100644 --- a/src/plugin-sdk/native-command-registry.ts +++ b/src/plugin-sdk/native-command-registry.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for chat/native command definitions and argument helpers. + */ export { buildCommandTextFromArgs, findCommandByNativeName, diff --git a/src/plugin-sdk/opencode.test.ts b/src/plugin-sdk/opencode.test.ts index 7dc4a29a57ce..7c264cd29e70 100644 --- a/src/plugin-sdk/opencode.test.ts +++ b/src/plugin-sdk/opencode.test.ts @@ -1,3 +1,6 @@ +/** + * Tests OpenCode SDK helpers and provider-facing OpenCode contracts. + */ import { describe, expect, it } from "vitest"; import { createOpencodeCatalogApiKeyAuthMethod } from "./opencode.js"; diff --git a/src/plugin-sdk/opencode.ts b/src/plugin-sdk/opencode.ts index b2f8fe694cde..238a5bc0ce98 100644 --- a/src/plugin-sdk/opencode.ts +++ b/src/plugin-sdk/opencode.ts @@ -11,14 +11,23 @@ const OPENCODE_SHARED_WIZARD_GROUP = { } as const; export function createOpencodeCatalogApiKeyAuthMethod(params: { + /** Provider id for the catalog being configured, such as `opencode` or `opencode-go`. */ providerId: string; + /** Human-facing auth method label for this catalog. */ label: string; + /** CLI/setup option key that carries the OpenCode API key. */ optionKey: string; + /** CLI flag name that maps to the option key. */ flagName: `--${string}`; + /** Default model written when this catalog is selected. */ defaultModel: string; + /** Provider-specific config patch applied after shared API-key auth succeeds. */ applyConfig: (cfg: OpenClawConfig) => OpenClawConfig; + /** Setup note explaining how the shared OpenCode key is reused. */ noteMessage: string; + /** Wizard choice id for this catalog. */ choiceId: string; + /** Wizard choice label for this catalog. */ choiceLabel: string; }) { return createProviderApiKeyAuthMethod({ @@ -30,6 +39,8 @@ export function createOpencodeCatalogApiKeyAuthMethod(params: { flagName: params.flagName, envVar: "OPENCODE_API_KEY", promptMessage: "Enter OpenCode API key", + // Zen and Go catalogs intentionally share profile ids so one imported key + // satisfies either provider without duplicate credential prompts. profileIds: [...OPENCODE_SHARED_PROFILE_IDS], defaultModel: params.defaultModel, expectedProviders: ["opencode", "opencode-go"], diff --git a/src/plugin-sdk/optional-channel-setup.ts b/src/plugin-sdk/optional-channel-setup.ts index f3cf8b18ce83..34e4242d9f66 100644 --- a/src/plugin-sdk/optional-channel-setup.ts +++ b/src/plugin-sdk/optional-channel-setup.ts @@ -4,9 +4,13 @@ import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js" import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; type OptionalChannelSetupParams = { + /** Channel id used by setup wizard status and routing. */ channel: string; + /** Human-readable plugin label shown in operator-facing install guidance. */ label: string; + /** Package spec operators should install before running real channel setup. */ npmSpec?: string; + /** Docs path linked from validation and wizard status messages. */ docsPath?: string; }; @@ -19,11 +23,19 @@ function buildOptionalChannelSetupMessage(params: OptionalChannelSetupParams): s return message.join(" "); } +/** + * Creates a setup adapter for optional channel plugins that are not installed. + * Validation returns install guidance, while config mutation fails with the same + * message so setup flows cannot silently create partial channel config. + */ export function createOptionalChannelSetupAdapter( + /** Optional plugin metadata used to build setup validation guidance. */ params: OptionalChannelSetupParams, ): ChannelSetupAdapter { const message = buildOptionalChannelSetupMessage(params); return { + // Optional channels still need a stable account key so setup status can route + // the missing-plugin message through the same account-scoped UI as installed plugins. resolveAccountId: ({ accountId }) => accountId ?? DEFAULT_ACCOUNT_ID, applyAccountConfig: () => { throw new Error(message); @@ -32,7 +44,12 @@ export function createOptionalChannelSetupAdapter( }; } +/** + * Creates a wizard surface for optional channel plugins that are not installed. + * The wizard is always unconfigured and stops finalize with install guidance. + */ export function createOptionalChannelSetupWizard( + /** Optional plugin metadata used to build setup wizard status guidance. */ params: OptionalChannelSetupParams, ): ChannelSetupWizard { const message = buildOptionalChannelSetupMessage(params); diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 43fc2c25177e..fcfd223b5055 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -2,14 +2,23 @@ import { buildOutboundMediaLoadOptions, type OutboundMediaAccess } from "../medi import { loadWebMedia } from "./web-media.js"; export type OutboundMediaLoadOptions = { + /** Maximum allowed media payload size before the load is rejected. */ maxBytes?: number; + /** Whether callers may load remote URLs, local files, or both. */ mediaAccess?: OutboundMediaAccess; + /** Approved local roots for file/path media; `"any"` disables root restriction. */ mediaLocalRoots?: readonly string[] | "any"; + /** Optional local file reader used by tests or plugin-specific filesystem adapters. */ mediaReadFile?: (filePath: string) => Promise; + /** Workspace root used when resolving relative local media paths. */ workspaceDir?: string; + /** Explicit proxy URL forwarded to shared outbound media loading policy. */ proxyUrl?: string; + /** Fetch implementation for remote media loads. */ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + /** Extra fetch options merged into remote media requests. */ requestInit?: RequestInit; + /** Allows explicit proxy DNS behavior to be trusted by the media fetch guard. */ trustExplicitProxyDns?: boolean; }; diff --git a/src/plugin-sdk/pair-loop-guard-runtime.test.ts b/src/plugin-sdk/pair-loop-guard-runtime.test.ts index 8fd4a08baa38..9ae579b26cfc 100644 --- a/src/plugin-sdk/pair-loop-guard-runtime.test.ts +++ b/src/plugin-sdk/pair-loop-guard-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests pairing loop guard runtime helpers for channel setup flows. + */ import { describe, expect, it } from "vitest"; import { createPairLoopGuard, diff --git a/src/plugin-sdk/pair-loop-guard-runtime.ts b/src/plugin-sdk/pair-loop-guard-runtime.ts index 641c19cf95b9..6413d0564aed 100644 --- a/src/plugin-sdk/pair-loop-guard-runtime.ts +++ b/src/plugin-sdk/pair-loop-guard-runtime.ts @@ -1,16 +1,24 @@ /** Resolved pair-loop guard settings in milliseconds for runtime checks. */ export type PairLoopGuardSettings = { + /** Whether protection is active after config and channel capability gates. */ enabled: boolean; + /** Number of pair events allowed before cooldown starts. */ maxEventsPerWindow: number; + /** Rolling event window size in milliseconds. */ windowMs: number; + /** Suppression duration in milliseconds once the threshold is exceeded. */ cooldownMs: number; }; /** User-facing pair-loop guard config accepted by channel plugins. */ export type PairLoopGuardConfig = { + /** Enables or disables loop protection for the channel/account scope. */ enabled?: boolean; + /** Number of pair events allowed before cooldown starts. */ maxEventsPerWindow?: number; + /** Rolling event window size in seconds for config files. */ windowSeconds?: number; + /** Suppression duration in seconds for config files. */ cooldownSeconds?: number; }; @@ -28,8 +36,11 @@ export type PairLoopGuardResult = /** Snapshot entry for observability and tests. */ export type PairLoopGuardSnapshotEntry = { + /** Internal pair key containing scope, conversation, and unordered participant ids. */ key: string; + /** Number of retained events in the current window. */ recentCount: number; + /** Epoch milliseconds when cooldown ends, or zero when inactive. */ cooldownUntilMs: number; }; @@ -42,15 +53,24 @@ type PairLoopGuardEntry = { /** In-memory guard for suppressing repeated bidirectional bot pair loops. */ export type PairLoopGuard = { + /** Records one sender/receiver interaction and reports whether it enters or is inside cooldown. */ recordAndCheck: (params: { + /** Channel/account/provider scope that owns this conversation. */ scopeId: string; + /** Conversation/thread identifier where the bidirectional exchange happened. */ conversationId: string; + /** Sender id for this event; paired with receiverId without direction. */ senderId: string; + /** Receiver id for this event; paired with senderId without direction. */ receiverId: string; + /** Resolved guard thresholds for the current channel/account. */ settings: PairLoopGuardSettings; + /** Optional test/runtime clock override in epoch milliseconds. */ nowMs?: number; }) => PairLoopGuardResult; + /** Clears all tracked pair state and scheduled pruning state. */ clear: () => void; + /** Returns tracked pair counters for diagnostics and tests without exposing mutable state. */ snapshot: () => PairLoopGuardSnapshotEntry[]; }; @@ -138,6 +158,7 @@ export function resolvePairLoopGuardSettings(params: { DEFAULT_PAIR_LOOP_GUARD_CONFIG.cooldownSeconds; return { + // Channel-level capability gates can disable protection even when config/defaults enable it. enabled: params.defaultEnabled && configuredEnabled, maxEventsPerWindow, windowMs: windowSeconds * 1000, diff --git a/src/plugin-sdk/pairing-access.ts b/src/plugin-sdk/pairing-access.ts index 40c0665a532a..20d8447068a2 100644 --- a/src/plugin-sdk/pairing-access.ts +++ b/src/plugin-sdk/pairing-access.ts @@ -10,23 +10,30 @@ type ScopedUpsertInput = Omit< /** Scope pairing store operations to one channel/account pair for plugin-facing helpers. */ export function createScopedPairingAccess(params: { + /** Plugin runtime that owns the channel pairing store API. */ core: PluginRuntime; + /** Channel id permanently attached to store reads and writes from this helper. */ channel: ChannelId; + /** Channel account id normalized once before store operations. */ accountId: string; }) { const resolvedAccountId = normalizeAccountId(params.accountId); return { + /** Normalized account id used by every channel-scoped pairing store operation. */ accountId: resolvedAccountId, + /** Read allow-list entries for the scoped channel/account pair. */ readAllowFromStore: () => params.core.channel.pairing.readAllowFromStore({ channel: params.channel, accountId: resolvedAccountId, }), + /** Read another channel/account allow-list for DM policy cross-checks. */ readStoreForDmPolicy: (provider: ChannelId, accountId: string) => params.core.channel.pairing.readAllowFromStore({ channel: provider, accountId: normalizeAccountId(accountId), }), + /** Upsert a pairing request with the scoped channel/account injected. */ upsertPairingRequest: (input: ScopedUpsertInput) => params.core.channel.pairing.upsertPairingRequest({ channel: params.channel, diff --git a/src/plugin-sdk/param-readers.ts b/src/plugin-sdk/param-readers.ts index 27058ae26832..269fdcc315b6 100644 --- a/src/plugin-sdk/param-readers.ts +++ b/src/plugin-sdk/param-readers.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for typed tool parameter readers. + */ export { readFiniteNumberParam, readNonNegativeIntegerParam, diff --git a/src/plugin-sdk/persistent-dedupe.ts b/src/plugin-sdk/persistent-dedupe.ts index 65c500cd01a8..f81d08e97d99 100644 --- a/src/plugin-sdk/persistent-dedupe.ts +++ b/src/plugin-sdk/persistent-dedupe.ts @@ -7,25 +7,37 @@ import { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store. type PersistentDedupeData = Record; export type PersistentDedupeOptions = { + /** Milliseconds a recorded key remains recent; `0` keeps keys until cache pruning. */ ttlMs: number; + /** Maximum process-local cache entries used before consulting disk. */ memoryMaxSize: number; + /** Maximum persisted entries retained per namespace file. */ fileMaxEntries: number; + /** Maps a namespace to the JSON file that stores its persisted dedupe timestamps. */ resolveFilePath: (namespace: string) => string; lockOptions?: Partial; onDiskError?: (error: unknown) => void; }; export type PersistentDedupeCheckOptions = { + /** Logical bucket for the key; omitted/blank values use `global`. */ namespace?: string; + /** Test or replay timestamp override used for TTL checks and writes. */ now?: number; + /** Per-call disk error hook, overriding the helper-level hook. */ onDiskError?: (error: unknown) => void; }; export type PersistentDedupe = { + /** Returns true only when the key was not recently seen and was recorded for future checks. */ checkAndRecord: (key: string, options?: PersistentDedupeCheckOptions) => Promise; + /** Checks memory/disk recency without recording a new timestamp. */ hasRecent: (key: string, options?: PersistentDedupeCheckOptions) => Promise; + /** Loads recent disk entries into memory for one namespace and returns the loaded count. */ warmup: (namespace?: string, onError?: (error: unknown) => void) => Promise; + /** Clears only process-local memory; persisted namespace files are left intact. */ clearMemory: () => void; + /** Returns the current process-local cache size. */ memorySize: () => number; }; @@ -53,11 +65,14 @@ export type ClaimableDedupeOptions = }; export type ClaimableDedupe = { + /** Starts ownership of a key, reports duplicates, or returns the active claim's pending result. */ claim: ( key: string, options?: PersistentDedupeCheckOptions, ) => Promise; + /** Records a claimed key as handled and resolves any waiters with the recorded result. */ commit: (key: string, options?: PersistentDedupeCheckOptions) => Promise; + /** Releases an active claim without recording it, rejecting waiters with the supplied error. */ release: ( key: string, options?: { @@ -65,9 +80,13 @@ export type ClaimableDedupe = { error?: unknown; }, ) => void; + /** Checks whether the key is recent without claiming or committing it. */ hasRecent: (key: string, options?: PersistentDedupeCheckOptions) => Promise; + /** Warms persistent storage into memory when configured; memory-only guards return zero. */ warmup: (namespace?: string, onError?: (error: unknown) => void) => Promise; + /** Clears process-local caches and in-memory persistent state. */ clearMemory: () => void; + /** Returns the current process-local cache size. */ memorySize: () => number; }; diff --git a/src/plugin-sdk/plugin-config-runtime.ts b/src/plugin-sdk/plugin-config-runtime.ts index c6d898ff648b..1ed6e944cfa0 100644 --- a/src/plugin-sdk/plugin-config-runtime.ts +++ b/src/plugin-sdk/plugin-config-runtime.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/types.js"; export { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; +/** Requires an already-resolved runtime config at plugin runtime boundaries. */ export function requireRuntimeConfig(config: OpenClawConfig, context: string): OpenClawConfig { if (config) { return config; @@ -11,6 +12,7 @@ export function requireRuntimeConfig(config: OpenClawConfig, context: string): O ); } +/** Reads a plugin's object-shaped `plugins.entries[id].config` block from resolved config. */ export function resolvePluginConfigObject( config: OpenClawConfig | undefined, pluginId: string, @@ -33,6 +35,7 @@ export function resolvePluginConfigObject( : undefined; } +/** Resolves live plugin config through a loader, falling back to startup config when unavailable. */ export function resolveLivePluginConfigObject( runtimeConfigLoader: (() => OpenClawConfig | undefined) | undefined, pluginId: string, diff --git a/src/plugin-sdk/plugin-state-runtime.ts b/src/plugin-sdk/plugin-state-runtime.ts index 70178b8d3b73..26fd973739f7 100644 --- a/src/plugin-sdk/plugin-state-runtime.ts +++ b/src/plugin-sdk/plugin-state-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK type surface for plugin-scoped keyed state stores. + */ export type { OpenKeyedStoreOptions, PluginStateEntry, diff --git a/src/plugin-sdk/plugin-state-test-runtime.ts b/src/plugin-sdk/plugin-state-test-runtime.ts index 3fa25271d965..d4c24e71efd7 100644 --- a/src/plugin-sdk/plugin-state-test-runtime.ts +++ b/src/plugin-sdk/plugin-state-test-runtime.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for plugin state stores, ingress queues, and state DB helpers. + */ export { createPluginStateKeyedStore as createPluginStateKeyedStoreForTests, createPluginStateSyncKeyedStore as createPluginStateSyncKeyedStoreForTests, diff --git a/src/plugin-sdk/plugin-test-api.ts b/src/plugin-sdk/plugin-test-api.ts index a59bb00cfb3d..21c02130e579 100644 --- a/src/plugin-sdk/plugin-test-api.ts +++ b/src/plugin-sdk/plugin-test-api.ts @@ -4,8 +4,10 @@ import { } from "../plugins/api-facades.js"; import type { OpenClawPluginApi } from "./plugin-runtime.js"; +/** Partial plugin API overrides accepted by the SDK test helper. */ export type TestPluginApiInput = Partial; +/** Create a minimal plugin API object for plugin-sdk contract and unit tests. */ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPluginApi { const { agent, lifecycle, runContext, session, ...flatApi } = api; const mergedApi = { @@ -90,6 +92,9 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi on() {}, ...flatApi, } as OpenClawPluginApiWithoutFacades; + // Facades derive nested `agent`, `lifecycle`, `runContext`, and `session` + // views from the flat API; explicit overrides below let tests replace only + // the nested surface under test without rebuilding every no-op method. const withFacades = attachPluginApiFacades(mergedApi); return { ...withFacades, diff --git a/src/plugin-sdk/plugin-test-contracts.ts b/src/plugin-sdk/plugin-test-contracts.ts index 37dd3c7d0e3b..625085583131 100644 --- a/src/plugin-sdk/plugin-test-contracts.ts +++ b/src/plugin-sdk/plugin-test-contracts.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for plugin package, registration, and public surface contracts. + */ export { assertNoImportTimeSideEffects, createPluginRegistryFixture, diff --git a/src/plugin-sdk/poll-runtime.ts b/src/plugin-sdk/poll-runtime.ts index 0b39804599d9..086ca970ffcb 100644 --- a/src/plugin-sdk/poll-runtime.ts +++ b/src/plugin-sdk/poll-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for poll input normalization and selection limits. + */ export type { NormalizedPollInput, PollInput } from "../polls.js"; export { normalizePollDurationHours, diff --git a/src/plugin-sdk/private-qa-bundled-env.ts b/src/plugin-sdk/private-qa-bundled-env.ts index 7c77932dde47..9029a7ede7c9 100644 --- a/src/plugin-sdk/private-qa-bundled-env.ts +++ b/src/plugin-sdk/private-qa-bundled-env.ts @@ -1,7 +1,11 @@ +/** + * Runtime helper for private QA CLI source-checkout bundled plugin resolution. + */ import fs from "node:fs"; import path from "node:path"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +/** Returns an env override that points bundled plugin loading at source extensions. */ export function resolvePrivateQaBundledPluginsEnv( env: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv | undefined { diff --git a/src/plugin-sdk/provider-auth-api-key.ts b/src/plugin-sdk/provider-auth-api-key.ts index ddef259afd82..ed1a14312168 100644 --- a/src/plugin-sdk/provider-auth-api-key.ts +++ b/src/plugin-sdk/provider-auth-api-key.ts @@ -1,5 +1,6 @@ -// Public API-key onboarding helpers for provider plugins. - +/** + * Public SDK subpath for API-key provider auth setup and secret input handling. + */ export type { OpenClawConfig } from "../config/config.js"; export type { SecretInput } from "../config/types.secrets.js"; diff --git a/src/plugin-sdk/provider-auth-result.ts b/src/plugin-sdk/provider-auth-result.ts index 541b2e634b4b..8e2cb612ea1f 100644 --- a/src/plugin-sdk/provider-auth-result.ts +++ b/src/plugin-sdk/provider-auth-result.ts @@ -67,6 +67,8 @@ function normalizeProviderAuthConfigPatchModelRefs( let next = patch; const defaults = patch.agents?.defaults; if (defaults) { + // OAuth helpers can be called by provider setup code before config writes, so normalize + // legacy model refs here instead of letting retired ids leak into persisted defaults. let nextDefaults = defaults; if (defaults.model !== undefined) { const model = normalizeAgentModelConfigForAuthResult(defaults.model); @@ -99,6 +101,8 @@ function normalizeProviderAuthConfigPatchModelRefs( let mutated = false; const nextProviders = { ...providers }; for (const [provider, providerConfig] of Object.entries(providers)) { + // Provider catalogs embedded in auth patches need the same id normalization as top-level + // agent defaults, otherwise setup can write a mixed old/new provider catalog. const normalized = normalizeProviderConfigModelIdsForAuthResult(provider, providerConfig); if (normalized === providerConfig) { continue; @@ -118,19 +122,36 @@ function normalizeProviderAuthConfigPatchModelRefs( : next; } -/** Build the standard auth result payload for OAuth-style provider login flows. */ +/** + * Builds the standard auth result payload for OAuth-style provider login flows. + * + * The helper emits both the credential profile and the config patch expected by setup callers, + * while normalizing model refs so OAuth imports do not persist retired catalog ids. + */ export function buildOauthProviderAuthResult(params: { + /** Provider id stored on the auth profile credential and profile id. */ providerId: string; + /** Default model ref to seed into config when no explicit patch is supplied. */ defaultModel: string; + /** OAuth access token persisted in the generated auth profile. */ access: string; + /** Optional OAuth refresh token persisted when present. */ refresh?: string | null; + /** Optional expiry timestamp or date-like value normalized to Date-safe milliseconds. */ expires?: number | null; + /** Account email used for credential metadata and default profile naming. */ email?: string | null; + /** Human-readable account label stored in credential metadata. */ displayName?: string | null; + /** Explicit profile name used when deriving the auth profile id. */ profileName?: string | null; + /** Optional prefix added to the generated auth profile id. */ profilePrefix?: string; + /** Provider-specific credential fields merged into the OAuth credential. */ credentialExtra?: Record; + /** Explicit config patch to emit after model-ref normalization. */ configPatch?: Partial; + /** Optional setup notes forwarded to provider login callers. */ notes?: string[]; }): ProviderAuthResult { const email = params.email ?? undefined; diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index 2ccc0802f287..9837b9dc74ea 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -1,5 +1,3 @@ -// Public runtime auth helpers for provider plugins. - import crypto from "node:crypto"; import fs from "node:fs"; import { createServer } from "node:http"; @@ -22,15 +20,21 @@ export { export type { ProviderPreparedRuntimeAuth } from "../plugins/types.js"; export type { ResolvedProviderRuntimeAuth } from "../plugins/runtime/model-auth-types.js"; -export type OAuthCallbackResult = { code: string; state: string }; +/** + * OAuth authorization code and state captured by the local callback listener. + */ +export type OAuthCallbackResult = { + /** Authorization code returned by the OAuth provider callback. */ + code: string; + /** State value returned by the callback and validated against the expected state. */ + state: string; +}; -// IdP-host allowlist for CORS echo on the loopback OAuth callback. Plugins -// pass the hosts that may legitimately issue preflights against the redirect -// URI; everything else gets a 204 with no `Access-Control-Allow-*` headers, -// which is safe for normal browser navigation but blocks cross-origin script -// reads. The empty allowlist (default) leaves the legacy permissive SDK -// behavior in place for existing callers. +/** + * Builds the CORS origin resolver for loopback OAuth callbacks. + */ export function buildOAuthCallbackOriginResolver( + /** HTTPS IdP hosts allowed to receive a CORS echo from the loopback callback. */ allowedHosts: readonly string[] | undefined, ): (originHeader: string | string[] | undefined) => string | undefined { if (!allowedHosts || allowedHosts.length === 0) { @@ -59,14 +63,23 @@ export function buildOAuthCallbackOriginResolver( }; } +/** + * Generates a high-entropy OAuth state token for local callback validation. + */ export function generateOAuthState(): string { return crypto.randomBytes(32).toString("hex"); } +/** + * Parses a pasted OAuth redirect URL into callback code/state fields. + */ export function parseOAuthCallbackInput( + /** Full redirect URL pasted by the operator after manual OAuth completion. */ input: string, messages: { + /** Override for URLs that omit the state query parameter. */ missingState?: string; + /** Override for values that are not parseable redirect URLs. */ invalidInput?: string; } = {}, ): OAuthCallbackResult | { error: string } { @@ -91,20 +104,31 @@ export function parseOAuthCallbackInput( } } +/** + * Starts a temporary loopback HTTP listener and waits for a validated OAuth callback. + */ export async function waitForLocalOAuthCallback(params: { + /** State token that the callback must echo before the listener resolves. */ expectedState: string; + /** Maximum wait time before the listener rejects. */ timeoutMs: number; + /** Loopback port to bind for the temporary callback server. */ port: number; + /** URL path accepted as the OAuth callback endpoint. */ callbackPath: string; + /** Redirect URI shown in progress messages and provider setup flows. */ redirectUri: string; + /** HTML success heading rendered after a valid callback. */ successTitle: string; + /** Optional progress message emitted once the listener starts. */ progressMessage?: string; + /** Loopback hostname to bind; defaults to localhost. */ hostname?: string; + /** Progress callback invoked after the server begins listening. */ onProgress?: (message: string) => void; - // IdP host allowlist for CORS preflight echo. Pass the canonical authority - // host(s) (e.g. `["auth.example.com"]`) that may issue an `OPTIONS` against - // the redirect URI. When omitted, legacy permissive SDK behavior is - // preserved for existing provider login flows. + /** + * IdP hosts allowed to receive CORS echo on loopback callback preflights. + */ corsOriginAllowlist?: readonly string[]; }): Promise { const hostname = params.hostname ?? "localhost"; @@ -238,6 +262,8 @@ function applyOAuthCallbackCorsHeaders( res.setHeader("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"); } if (resolveOrigin !== undefined && !origin) { + // With an allowlist present, untrusted origins receive a bare 204 preflight + // response so browser navigation still works but scripts cannot read it. return; } @@ -298,7 +324,11 @@ async function loadRuntimeModelAuthModule(): Promise { return (await import(resolveRuntimeModelAuthModuleHref())) as RuntimeModelAuthModule; } +/** + * Resolves provider API-key auth through the runtime auth module when available. + */ export async function resolveApiKeyForProvider( + /** Provider auth lookup params forwarded to the runtime auth module. */ params: Parameters[0], ): Promise>> { const runtimeAuth = await loadRuntimeModelAuthModule(); @@ -309,7 +339,11 @@ export async function resolveApiKeyForProvider( return resolveApiKeyForProviderLocal(params); } +/** + * Resolves the prepared runtime auth payload for a concrete model request. + */ export async function getRuntimeAuthForModel( + /** Concrete model auth request forwarded to the runtime auth module. */ params: Parameters[0], ): Promise>> { const { getRuntimeAuthForModel: getRuntimeAuthForModelLocal } = diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 7dcccd1b829d..daf7da930d6a 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -1,5 +1,3 @@ -// Public auth/onboarding helpers for provider plugins. - import path from "node:path"; import { asDateTimestampMs, @@ -134,9 +132,13 @@ export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilo /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ export type CachedCopilotToken = { + /** Copilot API token returned by GitHub's internal exchange endpoint. */ token: string; + /** Absolute epoch milliseconds when the Copilot API token expires. */ expiresAt: number; + /** Absolute epoch milliseconds when this cache entry was written. */ updatedAt: number; + /** Copilot integration id that produced this cached token. */ integrationId?: string; }; @@ -216,7 +218,10 @@ function resolveCopilotProxyHost(proxyEp: string): string | null { } /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ -export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { +export function deriveCopilotApiBaseUrlFromToken( + /** Copilot API token text that may contain a `proxy-ep` attribute. */ + token: string, +): string | null { const trimmed = token.trim(); if (!trimmed) { return null; @@ -238,18 +243,30 @@ export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl; } -/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ +/** + * @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. + */ export async function resolveCopilotApiToken(params: { + /** GitHub OAuth token exchanged for a Copilot API token. */ githubToken: string; + /** Environment used to resolve the default token cache path. */ env?: NodeJS.ProcessEnv; + /** Fetch implementation used for the Copilot token exchange. */ fetchImpl?: typeof fetch; + /** Explicit cache file path for the exchanged Copilot token. */ cachePath?: string; + /** Cache reader override for tests and alternate storage backends. */ loadJsonFileImpl?: (path: string) => unknown; + /** Cache writer override for tests and alternate storage backends. */ saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void; }): Promise<{ + /** Copilot API token, from cache or fresh exchange. */ token: string; + /** Absolute epoch milliseconds when the Copilot API token expires. */ expiresAt: number; + /** Source marker identifying cache path or exchange endpoint. */ source: string; + /** Copilot API base URL derived from token metadata or default endpoint. */ baseUrl: string; }> { const env = params.env ?? process.env; @@ -258,6 +275,8 @@ export async function resolveCopilotApiToken(params: { const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile; const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined; if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { + // Token cache entries are scoped to the current Copilot integration id so + // stale tokens from older editor identities are exchanged again. if (isCopilotTokenUsable(cached)) { return { token: cached.token, @@ -300,9 +319,15 @@ export async function resolveCopilotApiToken(params: { }; } +/** + * Checks whether a provider has either env auth or matching local auth profiles configured. + */ export function isProviderApiKeyConfigured(params: { + /** Provider id to check for env auth or local auth profiles. */ provider: string; + /** Agent directory containing auth profiles. */ agentDir?: string; + /** Optional allowed profile credential types. */ profileTypes?: readonly AuthProfileCredential["type"][]; }): boolean { if (resolveEnvApiKey(params.provider)?.apiKey) { @@ -326,11 +351,19 @@ export function isProviderApiKeyConfigured(params: { }); } +/** + * Lists auth profile ids usable for a provider without throwing on missing stores or keychain access. + */ export function listUsableProviderAuthProfileIds(params: { + /** Provider id whose usable auth profiles should be listed. */ provider: string; + /** Optional runtime config used to resolve auth profile order and default agent dir. */ cfg?: OpenClawConfig; + /** Agent directory containing auth profiles. */ agentDir?: string; + /** Whether profile store reads may prompt for keychain-backed credentials. */ allowKeychainPrompt?: boolean; + /** Whether external CLI auth profiles may be discovered and included. */ includeExternalCliAuth?: boolean; }): { agentDir: string; profileIds: string[] } { try { @@ -341,21 +374,37 @@ export function listUsableProviderAuthProfileIds(params: { } } +/** + * Checks whether any usable auth profile exists for a provider. + */ export function isProviderAuthProfileConfigured(params: { + /** Provider id to check for usable auth profiles. */ provider: string; + /** Optional runtime config used to resolve auth profile order and default agent dir. */ cfg?: OpenClawConfig; + /** Agent directory containing auth profiles. */ agentDir?: string; + /** Whether profile store reads may prompt for keychain-backed credentials. */ allowKeychainPrompt?: boolean; + /** Whether external CLI auth profiles may be discovered and included. */ includeExternalCliAuth?: boolean; }): boolean { return listUsableProviderAuthProfileIds(params).profileIds.length > 0; } +/** + * Resolves the first usable auth-profile API key for a provider in configured profile order. + */ export async function resolveProviderAuthProfileApiKey(params: { + /** Provider id whose first usable auth profile should resolve to an API key. */ provider: string; + /** Optional runtime config used to resolve auth profile order and secret refs. */ cfg?: OpenClawConfig; + /** Agent directory containing auth profiles. */ agentDir?: string; + /** Whether profile store reads may prompt for keychain-backed credentials. */ allowKeychainPrompt?: boolean; + /** Whether external CLI auth profiles may be discovered and included. */ includeExternalCliAuth?: boolean; }): Promise { const { agentDir, profileIds, store } = resolveUsableProviderAuthProfiles(params); diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index c8e8ec5a6b11..a089739c6e62 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -1,8 +1,3 @@ -// Shared provider catalog helpers for provider plugins. -// -// Keep provider-owned exports out of this subpath so plugin loaders can import it -// without recursing through provider-specific facades. - import { createHash } from "node:crypto"; import { normalizeModelCatalog } from "@openclaw/model-catalog-core/model-catalog-normalize"; import type { @@ -11,14 +6,14 @@ import type { ModelCatalogTieredCost, } from "@openclaw/model-catalog-core/model-catalog-types"; import { findNormalizedProviderKey } from "@openclaw/model-catalog-core/provider-id"; -import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; -import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "../../packages/normalization-core/src/number-coercion.js"; +import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; +import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; @@ -29,12 +24,21 @@ export { findCatalogTemplate, } from "../plugins/provider-catalog.js"; +/** + * Normalized model row read from user config for provider catalog augmentation. + */ export type ConfiguredProviderCatalogEntry = { + /** Normalized model id as exposed through provider catalog discovery. */ id: string; + /** Display name from config, falling back to the normalized id. */ name: string; + /** Published provider id attached to this catalog entry. */ provider: string; + /** Optional context window copied from the configured model row when positive. */ contextWindow?: number; + /** Whether the configured model advertises reasoning support. */ reasoning?: boolean; + /** Runtime input modalities retained from the configured model row. */ input?: Array<"text" | "image" | "audio" | "video" | "document">; }; @@ -49,10 +53,17 @@ function buildLiveCatalogCacheKey(parts: readonly unknown[]): string { return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); } +/** + * Caches one live catalog load promise by stable key parts for a short TTL. + */ export async function getCachedLiveCatalogValue(params: { + /** Stable JSON-serializable values that identify one provider/config catalog load. */ keyParts: readonly unknown[]; + /** Loader for the live catalog value when no fresh cache entry exists. */ load: () => Promise; + /** Cache lifetime in milliseconds; defaults to a short provider-discovery TTL. */ ttlMs?: number; + /** Test hook for deterministic cache expiry. */ now?: () => number; }): Promise { const rawNow = params.now?.() ?? Date.now(); @@ -76,11 +87,15 @@ export async function getCachedLiveCatalogValue(params: { try { return await value; } catch (err) { + // Failed live discovery should not poison later retries for the same provider/config. liveCatalogCache.delete(key); throw err; } } +/** + * Clears the process-local live catalog cache for tests and isolated plugin probes. + */ export function clearLiveCatalogCacheForTests(): void { liveCatalogCache.clear(); } @@ -155,8 +170,13 @@ function buildManifestCatalogModel( }; } +/** + * Converts a plugin manifest modelCatalog provider into runtime provider config. + */ export function buildManifestModelProviderConfig(params: { + /** Provider id that owns the manifest catalog rows. */ providerId: string; + /** Raw manifest modelCatalog provider block to normalize into runtime config. */ catalog: unknown; }): ModelProviderConfig { const catalog = normalizeModelCatalog( @@ -217,9 +237,15 @@ function resolveConfiguredProviderModels( return Array.isArray(providerConfig.models) ? providerConfig.models : []; } +/** + * Reads user-configured provider models as catalog entries for plugin discovery output. + */ export function readConfiguredProviderCatalogEntries(params: { + /** Runtime config containing optional user-defined provider model rows. */ config?: OpenClawConfig; + /** Provider id used to locate configured model rows. */ providerId: string; + /** Provider id to publish on emitted catalog entries when it differs from lookup id. */ publishedProviderId?: string; }): ConfiguredProviderCatalogEntry[] { const provider = params.publishedProviderId ?? params.providerId; @@ -277,8 +303,13 @@ function withStreamingUsageCompat(provider: ModelProviderConfig): ModelProviderC return changed ? { ...provider, models } : provider; } +/** + * Returns whether a provider transport can report native usage while streaming. + */ export function supportsNativeStreamingUsageCompat(params: { + /** Provider id used for transport capability lookup. */ providerId: string; + /** Provider endpoint URL used to detect native streaming usage behavior. */ baseUrl: string | undefined; }): boolean { return resolveProviderRequestCapabilities({ @@ -290,8 +321,13 @@ export function supportsNativeStreamingUsageCompat(params: { }).supportsNativeStreamingUsageCompat; } +/** + * Marks models as streaming-usage compatible when provider transport capabilities allow it. + */ export function applyProviderNativeStreamingUsageCompat(params: { + /** Provider id used for transport capability lookup. */ providerId: string; + /** Runtime provider config whose model compat flags may be filled in. */ providerConfig: ModelProviderConfig; }): ModelProviderConfig { return supportsNativeStreamingUsageCompat({ diff --git a/src/plugin-sdk/provider-enable-config.test.ts b/src/plugin-sdk/provider-enable-config.test.ts index a3a8c63fcef7..b708b5b840b5 100644 --- a/src/plugin-sdk/provider-enable-config.test.ts +++ b/src/plugin-sdk/provider-enable-config.test.ts @@ -1,3 +1,6 @@ +/** + * Tests provider enablement config helpers exposed through the plugin SDK. + */ import { describe, expect, it } from "vitest"; import { enablePluginInConfig as enableFetchPluginInConfig } from "./provider-web-fetch-contract.js"; import { enablePluginInConfig as enableSearchPluginInConfig } from "./provider-web-search-contract.js"; diff --git a/src/plugin-sdk/provider-enable-config.ts b/src/plugin-sdk/provider-enable-config.ts index b657754eec56..7ec974f16b1d 100644 --- a/src/plugin-sdk/provider-enable-config.ts +++ b/src/plugin-sdk/provider-enable-config.ts @@ -1,36 +1,50 @@ import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js"; type ProviderPluginConfig = { + /** Whether this plugin entry is enabled in the persisted plugin registry. */ enabled?: boolean; }; type ProviderEnableConfigCarrier = { plugins?: { + /** Global plugin switch; false blocks provider setup from enabling entries. */ enabled?: boolean; + /** Plugin ids that provider setup must not enable. */ deny?: string[]; + /** Plugin ids allowed to load after provider setup enables them. */ allow?: string[]; + /** Per-plugin registry entries updated by provider setup flows. */ entries?: Record; }; }; export type PluginEnableResult = { + /** Config object to persist after the enable attempt. Unchanged when policy blocks the plugin. */ config: TConfig; + /** Whether the plugin was enabled and allowlisted. */ enabled: boolean; + /** Human-readable policy reason when the plugin cannot be enabled. */ reason?: string; }; /** - * Provider contract surfaces only ever enable provider plugins, so they do not - * need the built-in channel normalization path from plugins/enable.ts. + * Enables provider plugins for provider contract setup without applying channel + * normalization from the core plugin enable path. */ export function enablePluginInConfig( + /** Provider setup config object to update without channel normalization. */ cfg: TConfig, + /** Provider plugin id to enable and allowlist. */ pluginId: string, ): PluginEnableResult { if (cfg.plugins?.enabled === false) { + // Policy blocks must preserve object identity so setup flows cannot persist partial plugin + // registry edits after global plugin loading has been disabled. return { config: cfg, enabled: false, reason: "plugins disabled" }; } if (cfg.plugins?.deny?.includes(pluginId)) { + // Denylisted plugins are intentionally left untouched even when a provider setup selected + // them, allowing callers to report the policy reason without mutating config. return { config: cfg, enabled: false, reason: "blocked by denylist" }; } @@ -47,6 +61,8 @@ export function enablePluginInConfig[0]; +/** + * API-key auth options for single-provider plugins, with provider id filled in by the entry helper. + */ export type SingleProviderPluginApiKeyAuthOptions = Omit< ApiKeyAuthMethodOptions, "providerId" | "expectedProviders" | "wizard" > & { + /** + * Provider ids this auth method is allowed to satisfy; defaults to the single + * provider id declared by the plugin entry. + */ expectedProviders?: string[]; + /** + * Wizard metadata for setup flows, or `false` when the method should be + * registered without an onboarding choice. + */ wizard?: false | ProviderPluginWizardSetup; }; +/** + * Catalog configuration accepted by the single-provider entry helper. + */ export type SingleProviderPluginCatalogOptions = | { + /** + * Builds the live provider catalog through the shared API-key catalog path. + */ buildProvider: Parameters[0]["buildProvider"]; + /** + * Builds a static catalog for cheap model discovery before credentials are resolved. + */ buildStaticProvider?: Parameters[0]["buildProvider"]; + /** + * Allows operator-configured base URLs to override the provider catalog base URL. + */ allowExplicitBaseUrl?: boolean; run?: never; order?: never; staticRun?: never; } | { + /** + * Runs a fully custom provider catalog implementation. + */ run: ProviderPluginCatalog["run"]; + /** + * Optional static variant for custom catalog implementations. + */ staticRun?: ProviderPluginCatalog["run"]; + /** + * Catalog ordering contract forwarded to the core provider registry. + */ order?: ProviderPluginCatalog["order"]; buildProvider?: never; buildStaticProvider?: never; allowExplicitBaseUrl?: never; }; +/** + * Defines one provider plugin plus optional extra registration hooks. + */ export type SingleProviderPluginOptions = { + /** + * Plugin id and default provider id when `provider.id` is omitted. + */ id: string; + /** + * Display name registered for the plugin entry. + */ name: string; + /** + * Short plugin description surfaced by plugin registries and setup flows. + */ description: string; /** * @deprecated Declare exclusive plugin kind in `openclaw.plugin.json` via @@ -61,20 +105,54 @@ export type SingleProviderPluginOptions = { * fallback for older plugins. */ kind?: OpenClawPluginDefinition["kind"]; + /** + * Optional plugin configuration schema or lazy schema factory. + */ configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); + /** + * Primary provider registration. Extra provider fields are forwarded after + * the helper-owned id/auth/catalog fields are normalized. + */ provider?: { + /** + * Provider id override when the runtime provider id differs from the plugin id. + */ id?: string; + /** + * Human-readable provider label. + */ label: string; + /** + * Documentation route used by provider setup and diagnostics. + */ docsPath: string; + /** + * Alternate provider ids accepted by routing and configuration lookups. + */ aliases?: string[]; + /** + * Explicit environment variables advertised for credentials. + */ envVars?: string[]; + /** + * API-key auth methods converted through the shared provider auth helper. + */ auth?: SingleProviderPluginApiKeyAuthOptions[]; + /** + * Non-API-key auth methods appended after generated API-key methods. + */ extraAuth?: ProviderAuthMethod[]; + /** + * Live/static catalog implementation for this provider. + */ catalog: SingleProviderPluginCatalogOptions; } & Omit< ProviderPlugin, "id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog" >; + /** + * Optional hook for registering companion capabilities with the same plugin entry. + */ register?: (api: OpenClawPluginApi) => void; }; @@ -136,6 +214,9 @@ async function runUnifiedTextCatalog(params: { }); } +/** + * Builds a plugin entry for providers whose runtime exports exactly one primary model provider. + */ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOptions) { return definePluginEntry({ id: options.id, @@ -166,6 +247,8 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt acceptedProviderAuth.push(entry); return [method]; } catch { + // Fuzzed or partially unreadable auth rows should not prevent the + // provider from registering its remaining healthy auth methods. return []; } }); @@ -219,6 +302,8 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt auth, catalog, ...(staticCatalog ? { staticCatalog } : {}), + // Preserve additional provider capabilities while keeping helper-owned + // auth/catalog/id fields canonical. ...Object.fromEntries( Object.entries(provider).filter( ([key]) => diff --git a/src/plugin-sdk/provider-http-test-mocks.ts b/src/plugin-sdk/provider-http-test-mocks.ts index 80e0ba6c6642..ba1c497bdf5b 100644 --- a/src/plugin-sdk/provider-http-test-mocks.ts +++ b/src/plugin-sdk/provider-http-test-mocks.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for provider HTTP mock installation and cleanup. + */ export { getProviderHttpMocks, installProviderHttpMockCleanup, diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 4991eab1052e..c28ff606f8e2 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -1,3 +1,6 @@ +/** + * Tests shared provider model id normalization helpers. + */ import { describe, expect, it } from "vitest"; import { ANTHROPIC_BY_MODEL_REPLAY_HOOKS, diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index ee30f0603c80..4e84bf5c5f4b 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -1,8 +1,3 @@ -// Shared model/catalog helpers for provider plugins. -// -// Keep provider-owned exports out of this subpath so plugin loaders can import it -// without recursing through provider-specific facades. - import { normalizeProviderId as normalizeProviderIdCore } from "@openclaw/model-catalog-core/provider-id"; import { normalizeAntigravityPreviewModelId as normalizeAntigravityPreviewModelIdCore, @@ -85,7 +80,13 @@ export { buildStrictAnthropicReplayPolicy, }; -export function normalizeProviderId(provider: string): string { +/** + * Normalizes provider ids for config, catalog, and plugin-registry matching. + */ +export function normalizeProviderId( + /** Provider id from config, catalog, or plugin metadata. */ + provider: string, +): string { return normalizeProviderIdCore(provider); } export { @@ -127,7 +128,10 @@ function getModelProviderHint(modelId: string): string | null { } /** @deprecated Proxy provider-owned model helper; do not use from third-party plugins. */ -export function isProxyReasoningUnsupportedModelHint(modelId: string): boolean { +export function isProxyReasoningUnsupportedModelHint( + /** Model id that may include a provider prefix such as `x-ai/model`. */ + modelId: string, +): boolean { return getModelProviderHint(modelId) === "x-ai"; } @@ -145,12 +149,18 @@ function isClaudeOpus48ModelId(modelId: string): boolean { } /** @deprecated Anthropic provider-owned model helper; do not use from third-party plugins. */ -export function isClaudeAdaptiveThinkingDefaultModelId(modelId: string): boolean { +export function isClaudeAdaptiveThinkingDefaultModelId( + /** Claude model id to check against adaptive-thinking default families. */ + modelId: string, +): boolean { return matchesClaudeModelPrefix(modelId, CLAUDE_ADAPTIVE_THINKING_DEFAULT_MODEL_PREFIXES); } /** @deprecated Anthropic provider-owned model helper; do not use from third-party plugins. */ -export function resolveClaudeThinkingProfile(modelId: string): ProviderThinkingProfile { +export function resolveClaudeThinkingProfile( + /** Claude model id used to choose available thinking levels and defaults. */ + modelId: string, +): ProviderThinkingProfile { if (isClaudeOpus48ModelId(modelId)) { return { levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }], @@ -172,14 +182,29 @@ export function resolveClaudeThinkingProfile(modelId: string): ProviderThinkingP return { levels: BASE_CLAUDE_THINKING_LEVELS }; } -export function normalizeAntigravityPreviewModelId(id: string): string { +/** + * Normalizes Antigravity preview model ids to the canonical provider catalog form. + */ +export function normalizeAntigravityPreviewModelId( + /** Antigravity preview model id from config or catalog data. */ + id: string, +): string { return normalizeAntigravityPreviewModelIdCore(id); } -export function normalizeGooglePreviewModelId(id: string): string { +/** + * Normalizes Google preview model ids to the canonical provider catalog form. + */ +export function normalizeGooglePreviewModelId( + /** Google preview model id from config or catalog data. */ + id: string, +): string { return normalizeGooglePreviewModelIdCore(id); } +/** + * Shared replay-policy families reused by provider plugins with matching transcript semantics. + */ export type ProviderReplayFamily = | "openai-compatible" | "anthropic-by-model" @@ -195,19 +220,39 @@ type ProviderReplayFamilyHooks = Pick< type BuildProviderReplayFamilyHooksOptions = | { + /** OpenAI-compatible transcript family using OpenAI-style tool calls. */ family: "openai-compatible"; + /** Whether replay policy should rewrite tool call ids for provider compatibility. */ sanitizeToolCallIds?: boolean; + /** Whether replay policy should strip reasoning blocks from history. */ dropReasoningFromHistory?: boolean; } - | { family: "anthropic-by-model" } - | { family: "native-anthropic-by-model" } - | { family: "google-gemini" } - | { family: "passthrough-gemini" } | { + /** Anthropic-style transcript policy selected by Claude model id. */ + family: "anthropic-by-model"; + } + | { + /** Native Anthropic transcript policy preserving Anthropic ids/signatures. */ + family: "native-anthropic-by-model"; + } + | { + /** Google Gemini transcript policy with Gemini replay sanitation hooks. */ + family: "google-gemini"; + } + | { + /** OpenAI-compatible transport carrying Gemini-style thought signatures. */ + family: "passthrough-gemini"; + } + | { + /** Family that switches between Anthropic and OpenAI-compatible replay by request context. */ family: "hybrid-anthropic-openai"; + /** Whether Anthropic-model replay should drop thinking blocks in hybrid mode. */ anthropicModelDropThinkingBlocks?: boolean; }; +/** + * Builds provider replay hooks for a known transcript/reasoning compatibility family. + */ export function buildProviderReplayFamilyHooks( options: BuildProviderReplayFamilyHooksOptions, ): ProviderReplayFamilyHooks { diff --git a/src/plugin-sdk/provider-model-types.ts b/src/plugin-sdk/provider-model-types.ts index 690ad475b28c..e7b30b51dec2 100644 --- a/src/plugin-sdk/provider-model-types.ts +++ b/src/plugin-sdk/provider-model-types.ts @@ -1,3 +1,6 @@ +/** + * Public SDK type surface for model provider and model definition config. + */ export type { BedrockDiscoveryConfig, ModelApi, diff --git a/src/plugin-sdk/provider-oauth-runtime.ts b/src/plugin-sdk/provider-oauth-runtime.ts index 3f1607025c6c..641aced9b9f4 100644 --- a/src/plugin-sdk/provider-oauth-runtime.ts +++ b/src/plugin-sdk/provider-oauth-runtime.ts @@ -8,8 +8,11 @@ import type { Model } from "../llm/types.js"; const LOGO_SVG = ``; export type OAuthCredentials = { + /** Refresh token or provider-equivalent long-lived credential. */ refresh: string; + /** Access token or provider-equivalent bearer credential. */ access: string; + /** Absolute epoch milliseconds when the access token should be considered expired. */ expires: number; [key: string]: unknown; }; @@ -20,43 +23,61 @@ export type OAuthProviderId = string; export type OAuthProvider = OAuthProviderId; export type OAuthPrompt = { + /** Prompt text shown to the operator. */ message: string; + /** Optional placeholder for manual text entry. */ placeholder?: string; + /** Whether empty input should be accepted instead of reprompting. */ allowEmpty?: boolean; }; export type OAuthAuthorizationInput = { + /** Authorization code parsed from a callback URL, query string, or pasted code. */ code?: string; + /** Optional OAuth state parsed from callback URL, query string, or `code#state` input. */ state?: string; }; export type OAuthAuthInfo = { + /** Provider authorization URL shown to the user. */ url: string; + /** Optional provider-specific instruction text for manual flows. */ instructions?: string; }; export type OAuthSelectOption = { + /** Stable option id returned when the operator selects this entry. */ id: string; + /** Human-readable option label shown in the selector. */ label: string; }; export type OAuthSelectPrompt = { + /** Prompt text shown above the selectable options. */ message: string; + /** Options available for the operator to choose from. */ options: OAuthSelectOption[]; }; export interface OAuthLoginCallbacks { + /** Emits authorization URL/instructions to the UI before waiting for completion. */ onAuth: (info: OAuthAuthInfo) => void; + /** Prompts for manual input such as pasted callback URLs or authorization codes. */ onPrompt: (prompt: OAuthPrompt) => Promise; + /** Reports human-readable login progress without exposing secrets. */ onProgress?: (message: string) => void; + /** Optional direct manual-code entry hook used when callback-server flows cannot complete. */ onManualCodeInput?: () => Promise; /** Show an interactive selector and return the selected option id, or undefined on cancel. */ onSelect?: (prompt: OAuthSelectPrompt) => Promise; + /** Cancels pending OAuth waits and prompts when aborted. */ signal?: AbortSignal; } export interface OAuthProviderInterface { + /** Stable provider id used for credential and config routing. */ readonly id: OAuthProviderId; + /** Human-readable provider name shown in login flows. */ readonly name: string; /** Run the login flow and return credentials to persist. */ @@ -77,8 +98,11 @@ export interface OAuthProviderInterface { /** @deprecated Use OAuthProviderInterface instead. */ export interface OAuthProviderInfo { + /** Stable provider id used for credential and config routing. */ id: OAuthProviderId; + /** Human-readable provider name shown in login flows. */ name: string; + /** Whether this provider can currently start OAuth login. */ available: boolean; } @@ -178,7 +202,13 @@ function renderOAuthPage(options: { `; } -export function oauthSuccessHtml(message: string): string { +/** + * Renders the local OAuth callback success page after provider authentication completes. + */ +export function oauthSuccessHtml( + /** Success message rendered in the local OAuth completion page. */ + message: string, +): string { return renderOAuthPage({ title: "Authentication successful", heading: "Authentication successful", @@ -186,7 +216,15 @@ export function oauthSuccessHtml(message: string): string { }); } -export function oauthErrorHtml(message: string, details?: string): string { +/** + * Renders the local OAuth callback error page without exposing raw credential material. + */ +export function oauthErrorHtml( + /** Error message rendered in the local OAuth completion page. */ + message: string, + /** Optional provider-specific error details rendered below the message. */ + details?: string, +): string { return renderOAuthPage({ title: "Authentication failed", heading: "Authentication failed", @@ -203,6 +241,7 @@ function base64urlEncode(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); } +/** Generates an OAuth PKCE verifier and SHA-256 challenge using base64url encoding. */ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { const verifierBytes = new Uint8Array(32); crypto.getRandomValues(verifierBytes); @@ -216,13 +255,21 @@ export async function generatePKCE(): Promise<{ verifier: string; challenge: str return { verifier, challenge }; } +/** Generates a random base64url OAuth state value for CSRF protection. */ export function generateOAuthState(): string { const stateBytes = new Uint8Array(32); crypto.getRandomValues(stateBytes); return base64urlEncode(stateBytes); } -export function parseOAuthAuthorizationInput(input: string): OAuthAuthorizationInput { +/** + * Parses callback URLs, raw query strings, `code#state`, or plain pasted codes. + * Empty input returns an empty object so callers can keep prompting. + */ +export function parseOAuthAuthorizationInput( + /** Raw callback URL, query string, `code#state`, or pasted code. */ + input: string, +): OAuthAuthorizationInput { const value = input.trim(); if (!value) { return {}; @@ -254,13 +301,24 @@ export function parseOAuthAuthorizationInput(input: string): OAuthAuthorizationI return { code: value }; } -export function resolveOAuthTokenLifetimeMs(value: unknown): number | undefined { +/** Converts provider `expires_in` seconds into safe positive milliseconds. */ +export function resolveOAuthTokenLifetimeMs( + /** Provider `expires_in` value in seconds. */ + value: unknown, +): number | undefined { return positiveSecondsToSafeMilliseconds(value); } +/** Resolves provider token lifetime into an absolute expiry timestamp with optional refresh skew. */ export function resolveOAuthTokenExpiresAt( + /** Provider `expires_in` value in seconds. */ value: unknown, - options: { nowMs?: number; refreshSkewMs?: number } = {}, + options: { + /** Current timestamp override for deterministic expiry calculations. */ + nowMs?: number; + /** Milliseconds to subtract so refresh happens before provider expiry. */ + refreshSkewMs?: number; + } = {}, ): number | undefined { const lifetimeMs = resolveOAuthTokenLifetimeMs(value); return lifetimeMs === undefined @@ -271,19 +329,30 @@ export function resolveOAuthTokenExpiresAt( }); } +/** + * Creates the shared cancellation error used by abortable OAuth login flows. + */ export function createOAuthLoginCancelledError(): Error { return new Error("Login cancelled"); } -export function throwIfOAuthLoginAborted(signal?: AbortSignal): void { +/** Throws the shared OAuth cancellation error when a login signal is already aborted. */ +export function throwIfOAuthLoginAborted( + /** Abort signal attached to the OAuth login flow. */ + signal?: AbortSignal, +): void { if (signal?.aborted) { throw createOAuthLoginCancelledError(); } } +/** Races a pending OAuth login step against the login abort signal and normalizes rejections. */ export function withOAuthLoginAbort( + /** Pending OAuth login operation to race against abort. */ promise: Promise, + /** Abort signal attached to the OAuth login flow. */ signal?: AbortSignal, + /** Optional cleanup hook called when the login is aborted. */ onAbort?: () => void, ): Promise { if (!signal) { @@ -308,10 +377,12 @@ export function withOAuthLoginAbort( signal.addEventListener("abort", abort, { once: true }); promise.then( (value) => { + // The login step won the race; remove abort listeners so long-lived prompts do not leak. cleanup(); resolve(value); }, (error: unknown) => { + // Preserve Error rejections but wrap non-Error provider/prompt values for lint-safe callers. cleanup(); reject(toLintErrorObject(error, "Non-Error rejection")); }, @@ -319,8 +390,11 @@ export function withOAuthLoginAbort( }); } +/** Combines a caller abort signal with a bounded timeout signal for OAuth HTTP requests. */ export function buildOAuthRequestSignal(options: { + /** Optional caller-provided signal to combine with the timeout signal. */ signal?: AbortSignal; + /** Request timeout in milliseconds before the generated signal aborts. */ timeoutMs: number; }): AbortSignal { const timeoutSignal = AbortSignal.timeout(resolveTimerTimeoutMs(options.timeoutMs, 0, 0)); diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 37d516684f34..ce8805f9d19d 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -23,6 +23,7 @@ export { resolveAgentModelPrimaryValue, } from "../config/model-input.js"; +/** Alias registration accepted by provider onboarding presets. */ export type AgentModelAliasEntry = | string | { @@ -35,8 +36,10 @@ const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ "opencode-zen/claude-opus-4-5", ]); +/** Current OpenCode Zen default model ref used by onboarding and repair flows. */ export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; +/** Pair of preset appliers exposed by provider setup modules. */ export type ProviderOnboardPresetAppliers = { applyProviderConfig: (cfg: OpenClawConfig, ...args: TArgs) => OpenClawConfig; applyConfig: (cfg: OpenClawConfig, ...args: TArgs) => OpenClawConfig; @@ -97,6 +100,7 @@ function normalizeProviderModelsForConfig( const existingIndex = seenById.get(normalized.id); if (existingIndex !== undefined) { mutated = true; + // Later entries fill gaps only; earlier user/provider settings keep precedence. next[existingIndex] = { ...normalized, ...next[existingIndex] }; continue; } @@ -144,6 +148,8 @@ function resolveProviderModelMergeState( const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) ? normalizeProviderModelsForConfig(providerId, existingProvider.models) : []; + // Collapse case/alias variants into the canonical provider key before writing, + // otherwise onboarding can leave two provider blocks for the same backend. if (existingProviderKey && existingProviderKey !== providerId) { delete providers[existingProviderKey]; } @@ -235,6 +241,7 @@ function createProviderPresetAppliers< }; } +/** Merge provider alias entries into the agent default model map without clobbering existing aliases. */ export function withAgentModelAliases( existing: Record | undefined, aliases: readonly AgentModelAliasEntry[], @@ -251,6 +258,7 @@ export function withAgentModelAliases( return next; } +/** Write onboarding-auth model aliases and provider configs into the canonical config sections. */ export function applyOnboardAuthAgentModelsAndProviders( cfg: OpenClawConfig, params: { @@ -278,6 +286,7 @@ export function applyOnboardAuthAgentModelsAndProviders( }; } +/** Set the agent default primary model while preserving normalized fallbacks and provider models. */ export function applyAgentDefaultModelPrimary( cfg: OpenClawConfig, primary: string, @@ -314,6 +323,7 @@ export function applyAgentDefaultModelPrimary( }; } +/** Move configs without a primary default onto the current OpenCode Zen model. */ export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { next: OpenClawConfig; changed: boolean; @@ -332,6 +342,7 @@ export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { }; } +/** Merge a provider config and seed required default models when the provider has no matching model yet. */ export function applyProviderConfigWithDefaultModels( cfg: OpenClawConfig, params: { @@ -366,6 +377,7 @@ export function applyProviderConfigWithDefaultModels( }); } +/** Single-model wrapper around `applyProviderConfigWithDefaultModels`. */ export function applyProviderConfigWithDefaultModel( cfg: OpenClawConfig, params: { @@ -387,6 +399,7 @@ export function applyProviderConfigWithDefaultModel( }); } +/** Apply a single-model provider preset and set the primary model only when the user has none. */ export function applyProviderConfigWithDefaultModelPreset( cfg: OpenClawConfig, params: { @@ -414,6 +427,7 @@ export function applyProviderConfigWithDefaultModelPreset( : next; } +/** Build setup appliers for presets that resolve to one default provider model. */ export function createDefaultModelPresetAppliers(params: { resolveParams: ( cfg: OpenClawConfig, @@ -431,6 +445,7 @@ export function createDefaultModelPresetAppliers(params }); } +/** Apply a multi-model provider preset and set the primary model only when the user has none. */ export function applyProviderConfigWithDefaultModelsPreset( cfg: OpenClawConfig, params: { @@ -458,6 +473,7 @@ export function applyProviderConfigWithDefaultModelsPreset( : next; } +/** Build setup appliers for presets that resolve to multiple default provider models. */ export function createDefaultModelsPresetAppliers(params: { resolveParams: ( cfg: OpenClawConfig, @@ -475,6 +491,7 @@ export function createDefaultModelsPresetAppliers(param }); } +/** Merge a provider config with a catalog while preserving existing model entries first. */ export function applyProviderConfigWithModelCatalog( cfg: OpenClawConfig, params: { @@ -507,6 +524,7 @@ export function applyProviderConfigWithModelCatalog( }); } +/** Apply a catalog-backed provider preset and set the primary model only when the user has none. */ export function applyProviderConfigWithModelCatalogPreset( cfg: OpenClawConfig, params: { @@ -532,6 +550,7 @@ export function applyProviderConfigWithModelCatalogPreset( : next; } +/** Build setup appliers for presets that resolve to a provider model catalog. */ export function createModelCatalogPresetAppliers(params: { resolveParams: ( cfg: OpenClawConfig, @@ -549,6 +568,7 @@ export function createModelCatalogPresetAppliers(params }); } +/** Ensure static model allowlists include a provider model ref after onboarding. */ export function ensureModelAllowlistEntry(params: { cfg: OpenClawConfig; modelRef: string; diff --git a/src/plugin-sdk/provider-openai-chatgpt-auth.ts b/src/plugin-sdk/provider-openai-chatgpt-auth.ts index 5b887653a28f..e59bc15c9544 100644 --- a/src/plugin-sdk/provider-openai-chatgpt-auth.ts +++ b/src/plugin-sdk/provider-openai-chatgpt-auth.ts @@ -4,13 +4,31 @@ import { normalizeOptionalString } from "../../packages/normalization-core/src/s const OPENAI_CODEX_AUTH_CLAIM = "https://api.openai.com/auth"; const OPENAI_CODEX_PROFILE_CLAIM = "https://api.openai.com/profile"; +/** + * Identity metadata extracted from OpenAI Codex ChatGPT OAuth tokens. + */ export type OpenAICodexAuthIdentity = { + /** + * ChatGPT account id used to group imported profiles under the same account. + */ accountId?: string; + /** + * ChatGPT subscription plan claim captured for diagnostics and credential metadata. + */ chatgptPlanType?: string; + /** + * Profile email from the OpenAI token profile claim when available. + */ email?: string; + /** + * Stable local profile name derived from email, account-scoped subject, or fallback id. + */ profileName?: string; }; +/** + * Decodes a JWT payload without verifying signatures for local metadata extraction. + */ export function decodeOpenAICodexJwtPayload(token: string): Record | undefined { const payload = token.split(".")[1]; if (!payload) { @@ -32,8 +50,17 @@ function readRecord(value: unknown): Record { : {}; } +/** + * Resolves stable account/profile metadata from OpenAI Codex OAuth access-token claims. + */ export function resolveOpenAICodexAuthIdentity(params: { + /** + * OpenAI Codex OAuth access token containing ChatGPT auth/profile claims. + */ access: string; + /** + * Account id supplied by the import source when the access token omits one. + */ accountId?: string; }): OpenAICodexAuthIdentity { const payload = decodeOpenAICodexJwtPayload(params.access); @@ -52,6 +79,8 @@ export function resolveOpenAICodexAuthIdentity(params: { } const stableSubject = + // Prefer account-scoped user ids over generic JWT subject so imports keep + // profile names stable across token refreshes and provider migrations. normalizeOptionalString(auth.chatgpt_account_user_id) ?? normalizeOptionalString(auth.chatgpt_user_id) ?? normalizeOptionalString(auth.user_id) ?? @@ -66,12 +95,18 @@ export function resolveOpenAICodexAuthIdentity(params: { }; } +/** + * Resolves the OAuth access-token expiry timestamp in milliseconds. + */ export function resolveOpenAICodexAccessTokenExpiry(access: string): number | undefined { const payload = decodeOpenAICodexJwtPayload(access); const exp = payload?.exp; return resolveExpiresAtMsFromEpochSeconds(exp); } +/** + * Builds persisted credential metadata for OpenAI Codex OAuth profiles. + */ export function buildOpenAICodexCredentialExtra( identity: OpenAICodexAuthIdentity & { idToken?: string }, ): Record | undefined { @@ -83,8 +118,14 @@ export function buildOpenAICodexCredentialExtra( return Object.keys(extra).length > 0 ? extra : undefined; } +/** + * Picks the imported profile name used when migrating OpenAI Codex auth. + */ export function resolveOpenAICodexImportProfileName( identity: Pick, + /** + * Name to use when imported metadata does not contain an account or stable subject. + */ fallback: string, ): string { if (identity.accountId) { diff --git a/src/plugin-sdk/provider-selection-runtime.test.ts b/src/plugin-sdk/provider-selection-runtime.test.ts index 9312f749bbc0..8e082ac0148d 100644 --- a/src/plugin-sdk/provider-selection-runtime.test.ts +++ b/src/plugin-sdk/provider-selection-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests provider selection runtime helper behavior. + */ import { describe, expect, it } from "vitest"; import { resolveConfiguredCapabilityProvider, diff --git a/src/plugin-sdk/provider-selection-runtime.ts b/src/plugin-sdk/provider-selection-runtime.ts index 5c9fc8334e5d..d9368a4a1525 100644 --- a/src/plugin-sdk/provider-selection-runtime.ts +++ b/src/plugin-sdk/provider-selection-runtime.ts @@ -1,33 +1,49 @@ import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; export type AutoSelectableProvider = { + /** Provider id used for explicit config lookup and selected result metadata. */ id: string; + /** Lower values win when no explicit provider is configured. */ autoSelectOrder?: number; }; export type ProviderSelection = { + /** Normalized explicit provider id, when the caller supplied one. */ configuredProviderId?: string; + /** True when an explicit provider id was configured but no provider was registered. */ missingConfiguredProvider: boolean; + /** Selected provider, either explicit or the first auto-selectable provider. */ provider: TProvider | undefined; }; export type ResolvedConfiguredProvider = | { + /** Provider exists and passed the capability-specific configuration check. */ ok: true; + /** Normalized explicit provider id, when the caller supplied one. */ configuredProviderId?: string; + /** Selected provider plugin/descriptor. */ provider: TProvider; + /** Capability-specific provider config resolved for the selected provider. */ providerConfig: TConfig; } | { + /** Provider selection failed before a configured provider could be used. */ ok: false; + /** Stable failure code for setup/runtime callers. */ code: "missing-configured-provider" | "no-registered-provider" | "provider-not-configured"; + /** Normalized explicit provider id, when the caller supplied one. */ configuredProviderId?: string; + /** Candidate provider that existed but failed configuration checks. */ provider?: TProvider; }; export function selectConfiguredOrAutoProvider(params: { + /** Optional explicit provider id from config or user input. */ configuredProviderId?: string; + /** Lookup for an explicit provider id after normalization. */ getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; + /** Iterable of providers eligible for auto-selection. */ listProviders: () => Iterable; }): ProviderSelection { const configuredProviderId = normalizeOptionalString(params.configuredProviderId); @@ -51,8 +67,11 @@ export function selectConfiguredOrAutoProvider | undefined>; }): Record { const canonicalProviderConfig = readProviderConfig(params.providerConfigs, params.providerId); @@ -72,20 +91,32 @@ export function resolveConfiguredCapabilityProvider< TFullConfig, TProvider extends AutoSelectableProvider, >(params: { + /** Optional explicit provider id from config or user input. */ configuredProviderId?: string; + /** Provider config map used to merge canonical and selected provider settings. */ providerConfigs?: Record | undefined>; + /** Current full config used only for configured-state checks. */ cfg: TFullConfig | undefined; + /** Full config passed to provider config resolution. */ cfgForResolve: TFullConfig; + /** Lookup for an explicit provider id after normalization. */ getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined; + /** Iterable of providers eligible for auto-selection. */ listProviders: () => Iterable; resolveProviderConfig: (params: { + /** Candidate provider being resolved. */ provider: TProvider; + /** Full config passed through for capability-specific config resolution. */ cfg: TFullConfig; + /** Merged raw provider config for canonical and selected provider ids. */ rawConfig: Record; }) => TConfig; isProviderConfigured: (params: { + /** Candidate provider being checked. */ provider: TProvider; + /** Current full config used by capability-specific configured checks. */ cfg: TFullConfig | undefined; + /** Resolved capability-specific provider config. */ providerConfig: TConfig; }) => boolean; }): ResolvedConfiguredProvider { diff --git a/src/plugin-sdk/provider-stream-family.ts b/src/plugin-sdk/provider-stream-family.ts index 2efa88a89164..7689b2ab9732 100644 --- a/src/plugin-sdk/provider-stream-family.ts +++ b/src/plugin-sdk/provider-stream-family.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath for provider stream event and family helpers. + */ export * from "./provider-stream.js"; diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts index 83879ec2a7f1..887ce6321c9b 100644 --- a/src/plugin-sdk/provider-stream-shared.test.ts +++ b/src/plugin-sdk/provider-stream-shared.test.ts @@ -1,3 +1,6 @@ +/** + * Tests provider stream shared helpers and stream hook capture. + */ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it } from "vitest"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index 84db5d046a88..db7f7897022c 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import { extractStandalonePlainTextToolCallText, normalizePlainTextToolCallStreamEvents, @@ -11,17 +12,16 @@ import type { StreamFn } from "../agents/runtime/index.js"; import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; import { streamSimple } from "../llm/stream.js"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; -import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; export type ProviderStreamWrapperFactory = - | ((streamFn: StreamFn | undefined) => StreamFn | undefined) - | null - | undefined - | false; + /** Wrapper factory that can decorate, replace, or omit a provider stream function. */ + ((streamFn: StreamFn | undefined) => StreamFn | undefined) | null | undefined | false; export function composeProviderStreamWrappers( + /** Base provider stream function to pass through the wrapper chain. */ baseStreamFn: StreamFn | undefined, + /** Ordered wrapper factories; falsey entries are skipped. */ ...wrappers: ProviderStreamWrapperFactory[] ): StreamFn | undefined { return wrappers.reduce( @@ -204,7 +204,10 @@ function wrapPlainTextToolCallStream( * Provider stream wrapper for local/proxy providers that sometimes emit a * standalone textual tool-call block even when native tool calling is enabled. */ -export function createPlainTextToolCallCompatWrapper(baseStreamFn: StreamFn | undefined): StreamFn { +export function createPlainTextToolCallCompatWrapper( + /** Provider stream function to wrap; defaults to the simple stream implementation. */ + baseStreamFn: StreamFn | undefined, +): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { const maybeStream = underlying(model, context, options); @@ -219,6 +222,7 @@ export function createPlainTextToolCallCompatWrapper(baseStreamFn: StreamFn | un /** @deprecated Bundled provider stream helper; do not use from third-party plugins. */ export function defaultToolStreamExtraParams( + /** Existing provider extra params; explicit tool_stream values are preserved. */ extraParams?: Record, ): Record { if (extraParams?.tool_stream !== undefined) { @@ -231,17 +235,25 @@ export function defaultToolStreamExtraParams( } export function createPayloadPatchStreamWrapper( + /** Provider stream function whose outbound payload should be patched. */ baseStreamFn: StreamFn | undefined, patchPayload: (params: { + /** Mutable provider payload immediately before the underlying stream dispatches it. */ payload: Record; + /** Model selected for the stream call. */ model: Parameters[0]; + /** Stream context passed by the runtime. */ context: Parameters[1]; + /** Stream options passed by the runtime. */ options: Parameters[2]; }) => void, wrapperOptions?: { shouldPatch?: (params: { + /** Model selected for the stream call. */ model: Parameters[0]; + /** Stream context passed by the runtime. */ context: Parameters[1]; + /** Stream options passed by the runtime. */ options: Parameters[2]; }) => boolean; }, @@ -601,7 +613,10 @@ export function isGoogleGemini3ThinkingLevelModel(modelId: string): boolean { return isGoogleGemini3ProModel(modelId) || isGoogleGemini3FlashModel(modelId); } -/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */ +/** + * Maps legacy numeric/semantic thinking input onto Gemini 3's provider enum. + * @deprecated Google provider-owned stream helper; do not use from third-party plugins. + */ export function resolveGoogleGemini3ThinkingLevel(params: { modelId?: string; thinkingLevel?: GoogleThinkingInputLevel; @@ -672,7 +687,10 @@ export function resolveGoogleGemini3ThinkingLevel(params: { return "HIGH"; } -/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */ +/** + * Removes `thinkingBudget=0` only for Gemini models that reject disabled thinking. + * @deprecated Google provider-owned stream helper; do not use from third-party plugins. + */ export function stripInvalidGoogleThinkingBudget(params: { thinkingConfig: Record; modelId?: string; @@ -728,7 +746,10 @@ function normalizeGemma4ThinkingLevel(value: unknown): "MINIMAL" | "HIGH" | unde } } -/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */ +/** + * Normalizes Google thinking config across SDK payload shapes before provider transport. + * @deprecated Google provider-owned stream helper; do not use from third-party plugins. + */ export function sanitizeGoogleThinkingPayload(params: { payload: unknown; modelId?: string; @@ -766,6 +787,8 @@ function sanitizeGoogleThinkingConfigContainer(params: { const thinkingConfigObj = thinkingConfig as Record; if (typeof params.modelId === "string" && isGemma4Model(params.modelId)) { + // Gemma 4 accepts thinkingLevel but not thinkingBudget; map legacy budget + // inputs before deleting the unsupported numeric field. const normalizedThinkingLevel = normalizeGemma4ThinkingLevel(thinkingConfigObj.thinkingLevel); const explicitMappedLevel = mapThinkLevelToGemma4ThinkingLevel(params.thinkingLevel); const disabledViaBudget = @@ -810,6 +833,7 @@ function sanitizeGoogleThinkingConfigContainer(params: { typeof params.modelId === "string" && isGoogleGemini3ThinkingLevelModel(params.modelId) ) { + // Gemini 3 adaptive mode means omit both controls so the provider chooses. delete thinkingConfigObj.thinkingBudget; delete thinkingConfigObj.thinkingLevel; if (Object.keys(thinkingConfigObj).length === 0) { @@ -826,6 +850,7 @@ function sanitizeGoogleThinkingConfigContainer(params: { }); delete thinkingConfigObj.thinkingBudget; if (mappedLevel) { + // Gemini 3 uses thinkingLevel; leaving thinkingBudget would make mixed-mode payloads. thinkingConfigObj.thinkingLevel = mappedLevel; } if (Object.keys(thinkingConfigObj).length === 0) { diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts index e79c0a69552e..73d3f99aa7ad 100644 --- a/src/plugin-sdk/provider-stream.ts +++ b/src/plugin-sdk/provider-stream.ts @@ -45,18 +45,30 @@ export { stripTrailingAnthropicAssistantPrefillWhenThinking, } from "./provider-stream-shared.js"; +/** Named stream-wrapper bundles that provider plugins can opt into without duplicating policy. */ export type ProviderStreamFamily = + /** Applies Google thinking-level payload normalization. */ | "google-thinking" + /** Applies Kilocode proxy reasoning payload normalization. */ | "kilocode-thinking" + /** Applies Moonshot thinking type/keep normalization. */ | "moonshot-thinking" + /** Enables MiniMax high-speed model routing when requested. */ | "minimax-fast-mode" + /** Applies the default OpenAI Responses wrapper stack. */ | "openai-responses-defaults" + /** Applies OpenRouter proxy reasoning payload normalization. */ | "openrouter-thinking" + /** Enables tool-call event streaming unless explicitly disabled. */ | "tool-stream-default-on"; type ProviderStreamFamilyHooks = Pick; +/** Builds provider hook objects for one supported stream-wrapper family. */ export function buildProviderStreamFamilyHooks( + /** + * Family key selecting the exact wrapper bundle to attach to a provider. + */ family: ProviderStreamFamily, ): ProviderStreamFamilyHooks { switch (family) { @@ -96,6 +108,8 @@ export function buildProviderStreamFamilyHooks( case "openai-responses-defaults": return { wrapStreamFn: (ctx: ProviderWrapStreamFnContext) => { + // Wrapper order is observable: header/default params must be in place + // before payload-shape and context-management compatibility rewrites. let nextStreamFn = createOpenAIAttributionHeadersWrapper(ctx.streamFn); if (resolveOpenAIFastMode(ctx.extraParams)) { diff --git a/src/plugin-sdk/provider-test-contracts.ts b/src/plugin-sdk/provider-test-contracts.ts index 52e1561a7633..7d6fc8be1753 100644 --- a/src/plugin-sdk/provider-test-contracts.ts +++ b/src/plugin-sdk/provider-test-contracts.ts @@ -1,3 +1,6 @@ +/** + * Test SDK subpath for provider auth, catalog, discovery, runtime, and media contracts. + */ export { describeGithubCopilotProviderAuthContract, describeOpenAICodexProviderAuthContract, diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index a7edf8f8f3eb..3e9091e69c86 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -10,12 +10,17 @@ import type { ProviderToolSchemaDiagnostic, } from "./plugin-entry.js"; -// Shared provider-tool helpers for plugin-owned schema compatibility rewrites. export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, stripUnsupportedSchemaKeywords }; +/** + * Finds unsupported JSON-schema keywords and reports their nested schema paths. + */ export function findUnsupportedSchemaKeywords( + /** JSON schema node to inspect recursively. */ schema: unknown, + /** Dot/bracket path prefix used in returned diagnostics. */ path: string, + /** Schema keywords unsupported by the target provider family. */ unsupportedKeywords: ReadonlySet, ): string[] { if (!schema || typeof schema !== "object") { @@ -55,7 +60,11 @@ export function findUnsupportedSchemaKeywords( return violations; } +/** + * Rewrites tool schemas into Gemini-compatible JSON schema before provider dispatch. + */ export function normalizeGeminiToolSchemas( + /** Provider tool-schema normalization context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { return ctx.tools.map((tool) => { @@ -69,7 +78,11 @@ export function normalizeGeminiToolSchemas( }); } +/** + * Reports Gemini-incompatible schema keywords without mutating tool definitions. + */ export function inspectGeminiToolSchemas( + /** Provider tool-schema inspection context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { return ctx.tools.flatMap((tool, toolIndex) => { @@ -85,7 +98,11 @@ export function inspectGeminiToolSchemas( }); } +/** + * Rewrites OpenAI-native tool schemas to satisfy strict object-schema requirements. + */ export function normalizeOpenAIToolSchemas( + /** Provider tool-schema normalization context used to detect native OpenAI strict routes. */ ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { if (!shouldApplyOpenAIToolCompat(ctx)) { @@ -121,10 +138,14 @@ function shouldApplyOpenAIToolCompat(ctx: ProviderNormalizeToolSchemasContext): if (provider === "openai") { if (api === "openai-responses") { + // Strict-schema normalization is only safe for the native OpenAI endpoint; + // OpenAI-compatible proxies may accept broader schemas or define their own rules. return !baseUrl || isOpenAIResponsesBaseUrl(baseUrl); } return ( api === "openai-chatgpt-responses" && + // Codex/ChatGPT Responses uses the same strict object-schema contract as native + // OpenAI Responses, but only on the known first-party backend URLs. (!baseUrl || isOpenAIResponsesBaseUrl(baseUrl) || isOpenAICodexBaseUrl(baseUrl)) ); } @@ -275,9 +296,15 @@ function normalizeOpenAIStrictCompatSchemaRecursive( return changed ? normalized : schema; } +/** + * Finds schema paths that violate OpenAI strict tool-schema requirements. + */ export function findOpenAIStrictSchemaViolations( + /** JSON schema node to inspect recursively. */ schema: unknown, + /** Dot/bracket path prefix used in returned diagnostics. */ path: string, + /** Strictness controls for the current schema position. */ options?: { requireObjectRoot?: boolean }, ): string[] { if (Array.isArray(schema)) { @@ -348,7 +375,11 @@ export function findOpenAIStrictSchemaViolations( return violations; } +/** + * Reports OpenAI strict-schema diagnostics for transports that enforce them before dispatch. + */ export function inspectOpenAIToolSchemas( + /** Provider tool-schema inspection context used to detect native OpenAI strict routes. */ ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { if (!shouldApplyOpenAIToolCompat(ctx)) { @@ -359,6 +390,9 @@ export function inspectOpenAIToolSchemas( return []; } +/** + * DeepSeek rejects union keywords in tool schemas. + */ export const DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS = new Set(["anyOf", "oneOf"]); function isNullSchemaVariant(schema: unknown): boolean { @@ -462,7 +496,11 @@ function isStringConstVariant(entry: unknown): entry is { const: string } { return typeof record.const === "string"; } +/** + * Rewrites DeepSeek-incompatible union schemas into the closest accepted shape. + */ export function normalizeDeepSeekToolSchemas( + /** Provider tool-schema normalization context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { return ctx.tools.map((tool) => { @@ -479,7 +517,11 @@ export function normalizeDeepSeekToolSchemas( }); } +/** + * Reports DeepSeek-incompatible union schema paths without mutating tool definitions. + */ export function inspectDeepSeekToolSchemas( + /** Provider tool-schema inspection context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { return ctx.tools.flatMap((tool, toolIndex) => { @@ -495,10 +537,21 @@ export function inspectDeepSeekToolSchemas( }); } +/** + * Supported provider tool-schema compatibility families. + */ export type ProviderToolCompatFamily = "deepseek" | "gemini" | "openai"; -export function buildProviderToolCompatFamilyHooks(family: ProviderToolCompatFamily): { +/** + * Returns the normalizer and inspector pair for a provider tool-schema compatibility family. + */ +export function buildProviderToolCompatFamilyHooks( + /** Provider tool-schema compatibility family to route to normalizer/inspector hooks. */ + family: ProviderToolCompatFamily, +): { + /** Mutating-compatible hook that returns tool definitions accepted by the provider family. */ normalizeToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => AnyAgentTool[]; + /** Non-mutating hook that reports provider-family schema incompatibilities. */ inspectToolSchemas: (ctx: ProviderNormalizeToolSchemasContext) => ProviderToolSchemaDiagnostic[]; } { switch (family) { diff --git a/src/plugin-sdk/provider-transport-runtime.ts b/src/plugin-sdk/provider-transport-runtime.ts index 20c7618cc859..0326408db67a 100644 --- a/src/plugin-sdk/provider-transport-runtime.ts +++ b/src/plugin-sdk/provider-transport-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for provider transport helpers and stream primitives. + */ export { buildGuardedModelFetch } from "../agents/provider-transport-fetch.js"; export { buildOpenAICompletionsParams } from "../agents/openai-transport-stream.js"; export { stripSystemPromptCacheBoundary } from "../agents/system-prompt-cache-boundary.js"; diff --git a/src/plugin-sdk/provider-web-search-contract-fields.ts b/src/plugin-sdk/provider-web-search-contract-fields.ts index 3d2b7a9de778..de05f4edbed4 100644 --- a/src/plugin-sdk/provider-web-search-contract-fields.ts +++ b/src/plugin-sdk/provider-web-search-contract-fields.ts @@ -15,18 +15,27 @@ export type WebSearchProviderContractCredential = | { type: "top-level" } | { type: "scoped"; scopeId: string }; +/** Config location used when a provider also stores credentials in plugin config. */ export type WebSearchProviderConfiguredCredential = { + /** Plugin id whose config entry owns the credential value. */ pluginId: string; + /** Field name under the plugin config entry. Defaults to `apiKey`. */ field?: string; }; +/** Inputs for building the shared credential accessors on web-search providers. */ export type CreateWebSearchProviderContractFieldsOptions = { + /** Legacy or inactive secret path that should be reported for migration/doctor flows. */ credentialPath: string; + /** Additional inactive secret paths when a provider retired more than one location. */ inactiveSecretPaths?: string[]; + /** Search-config credential storage mode exposed through provider runtime hooks. */ searchCredential: WebSearchProviderContractCredential; + /** Optional plugin-config credential storage used by install/configuration flows. */ configuredCredential?: WebSearchProviderConfiguredCredential; }; +/** Shared provider hooks produced by the web-search credential contract helper. */ export type WebSearchProviderContractFields = Pick< WebSearchProviderPlugin, "inactiveSecretPaths" | "getCredentialValue" | "setCredentialValue" @@ -86,6 +95,7 @@ function createConfiguredCredentialFields( }; } +/** Create the common credential hooks that web-search provider plugins spread into their entry. */ export function createBaseWebSearchProviderContractFields( options: CreateWebSearchProviderContractFieldsOptions, ): WebSearchProviderContractFields { diff --git a/src/plugin-sdk/provider-web-search-contract.test.ts b/src/plugin-sdk/provider-web-search-contract.test.ts index eb37394db48f..33bdfeeab02b 100644 --- a/src/plugin-sdk/provider-web-search-contract.test.ts +++ b/src/plugin-sdk/provider-web-search-contract.test.ts @@ -1,3 +1,6 @@ +/** + * Tests provider web search contract suite behavior. + */ import { describe, expect, it } from "vitest"; import { createWebSearchProviderContractFields } from "./provider-web-search-contract.js"; diff --git a/src/plugin-sdk/provider-web-search-contract.ts b/src/plugin-sdk/provider-web-search-contract.ts index f1413fee1288..f1548a2e2875 100644 --- a/src/plugin-sdk/provider-web-search-contract.ts +++ b/src/plugin-sdk/provider-web-search-contract.ts @@ -38,9 +38,11 @@ export type { } from "./provider-web-search-contract-fields.js"; type CreateWebSearchProviderSelectionOptions = CreateWebSearchProviderContractFieldsOptions & { + /** Plugin id to enable when this provider is selected through setup/configuration flows. */ selectionPluginId?: string; }; +/** Build the public web-search provider hooks, including optional selection-time plugin enabling. */ export function createWebSearchProviderContractFields( options: CreateWebSearchProviderSelectionOptions, ): Pick< diff --git a/src/plugin-sdk/proxy-capture.ts b/src/plugin-sdk/proxy-capture.ts index 4e37dd68371d..bfa0ec9dfb03 100644 --- a/src/plugin-sdk/proxy-capture.ts +++ b/src/plugin-sdk/proxy-capture.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for debug proxy capture configuration, storage, and events. + */ export { createDebugProxyWebSocketAgent, resolveDebugProxySettings, diff --git a/src/plugin-sdk/qa-lab.test.ts b/src/plugin-sdk/qa-lab.test.ts index e5c31a9a3b4d..0242dcc088f9 100644 --- a/src/plugin-sdk/qa-lab.test.ts +++ b/src/plugin-sdk/qa-lab.test.ts @@ -1,3 +1,6 @@ +/** + * Tests QA Lab SDK facades and private runtime loading behavior. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); diff --git a/src/plugin-sdk/qa-live-transport-scenarios.ts b/src/plugin-sdk/qa-live-transport-scenarios.ts index b2d67fa94686..28c7ad1415bc 100644 --- a/src/plugin-sdk/qa-live-transport-scenarios.ts +++ b/src/plugin-sdk/qa-live-transport-scenarios.ts @@ -1,3 +1,4 @@ +/** Standard live-transport behavior buckets used to compare channel QA suites. */ export type LiveTransportStandardScenarioId = | "canary" | "mention-gating" @@ -9,10 +10,15 @@ export type LiveTransportStandardScenarioId = | "reaction-observation" | "help-command"; +/** Transport-specific live QA scenario with optional mapping to a standard behavior bucket. */ export type LiveTransportScenarioDefinition = { + /** Transport-specific scenario id accepted by CLI scenario filters. */ id: TId; + /** Optional standard coverage bucket this transport-specific scenario proves. */ standardId?: LiveTransportStandardScenarioId; + /** Per-scenario timeout for live transport execution. */ timeoutMs: number; + /** Human-readable label used in QA output. */ title: string; }; @@ -70,6 +76,7 @@ const LIVE_TRANSPORT_STANDARD_SCENARIOS: readonly LiveTransportStandardScenarioD }, ] as const; +/** Minimum standard scenarios expected from baseline live transport suites. */ export const LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS: readonly LiveTransportStandardScenarioId[] = [ "canary", @@ -85,12 +92,15 @@ const LIVE_TRANSPORT_STANDARD_SCENARIO_ID_SET = new Set( function assertKnownStandardScenarioIds(ids: readonly LiveTransportStandardScenarioId[]) { for (const id of ids) { + // Keep typoed standard ids failing at suite-definition time instead of + // silently weakening baseline coverage comparisons. if (!LIVE_TRANSPORT_STANDARD_SCENARIO_ID_SET.has(id)) { throw new Error(`unknown live transport standard scenario id: ${id}`); } } } +/** Selects requested live transport scenarios and fails fast on unknown ids. */ export function selectLiveTransportScenarios(params: { ids?: string[]; laneLabel: string; @@ -110,6 +120,7 @@ export function selectLiveTransportScenarios return selected; } +/** Collects unique standard coverage ids from always-on coverage and scenario metadata. */ export function collectLiveTransportStandardScenarioCoverage(params: { alwaysOnStandardScenarioIds?: readonly LiveTransportStandardScenarioId[]; scenarios: readonly LiveTransportScenarioDefinition[]; @@ -137,6 +148,7 @@ export function collectLiveTransportStandardScenarioCoverage return coverage; } +/** Returns expected standard scenario ids that are not covered by the supplied suite. */ export function findMissingLiveTransportStandardScenarios(params: { coveredStandardScenarioIds: readonly LiveTransportStandardScenarioId[]; expectedStandardScenarioIds: readonly LiveTransportStandardScenarioId[]; diff --git a/src/plugin-sdk/qa-runner-runtime.integration.test.ts b/src/plugin-sdk/qa-runner-runtime.integration.test.ts index db9f6f5a25e2..0bcd84a593f6 100644 --- a/src/plugin-sdk/qa-runner-runtime.integration.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.integration.test.ts @@ -1,3 +1,6 @@ +/** + * Integration tests for QA runner runtime public surface loading. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; diff --git a/src/plugin-sdk/qa-runner-runtime.test.ts b/src/plugin-sdk/qa-runner-runtime.test.ts index 7626b94826df..9b2f2e10402d 100644 --- a/src/plugin-sdk/qa-runner-runtime.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests QA runner runtime facade helpers. + */ import path from "node:path"; import type { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/plugin-sdk/qa-runtime.test-helpers.ts b/src/plugin-sdk/qa-runtime.test-helpers.ts index b48c15f26d30..957d5acd83fa 100644 --- a/src/plugin-sdk/qa-runtime.test-helpers.ts +++ b/src/plugin-sdk/qa-runtime.test-helpers.ts @@ -1,3 +1,6 @@ +/** + * Shared fixtures for QA Lab runtime facade tests. + */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,12 +12,14 @@ type QaRuntimeModule = { type SurfaceLoaderMock = ReturnType; +/** Removes temporary source roots created by QA runtime tests. */ export function cleanupTempDirs(tempDirs: string[]): void { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } } +/** Restores the private QA CLI env flag after a test mutates it. */ export function restorePrivateQaCliEnv(originalPrivateQaCli: string | undefined): void { if (originalPrivateQaCli === undefined) { delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; @@ -23,6 +28,7 @@ export function restorePrivateQaCliEnv(originalPrivateQaCli: string | undefined) } } +/** Creates a minimal source checkout shape that enables private QA runtime loading. */ export function makePrivateQaSourceRoot(tempDirs: string[], prefix: string): string { const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); tempDirs.push(sourceRoot); @@ -40,6 +46,7 @@ function makeQaRuntimeSurface() { }; } +/** Asserts that the public QA Lab runtime facade loads from the bundled plugin surface. */ export async function expectQaLabRuntimeSurfaceLoad(params: { importRuntime: () => Promise; loadBundledPluginPublicSurfaceModuleSync: SurfaceLoaderMock; @@ -56,6 +63,7 @@ export async function expectQaLabRuntimeSurfaceLoad(params: { }); } +/** Asserts private QA loading rewrites bundled plugin lookup to the source extensions root. */ export async function expectPrivateQaLabRuntimeSurfaceLoad(params: { tempDirs: string[]; importRuntime: () => Promise; diff --git a/src/plugin-sdk/qa-runtime.test.ts b/src/plugin-sdk/qa-runtime.test.ts index 5db2d9970ba3..f926e137cd45 100644 --- a/src/plugin-sdk/qa-runtime.test.ts +++ b/src/plugin-sdk/qa-runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests QA runtime command loading and private CLI gating. + */ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { diff --git a/src/plugin-sdk/realtime-bootstrap-context.ts b/src/plugin-sdk/realtime-bootstrap-context.ts index c503f80382d1..e23770e760bf 100644 --- a/src/plugin-sdk/realtime-bootstrap-context.ts +++ b/src/plugin-sdk/realtime-bootstrap-context.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for realtime bootstrap context instruction files. + */ export { REALTIME_BOOTSTRAP_CONTEXT_FILE_NAMES, resolveRealtimeBootstrapContextInstructions, diff --git a/src/plugin-sdk/realtime-transcription.ts b/src/plugin-sdk/realtime-transcription.ts index 4526276f81e7..234fb00e4e1d 100644 --- a/src/plugin-sdk/realtime-transcription.ts +++ b/src/plugin-sdk/realtime-transcription.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for realtime transcription provider types and session helpers. + */ export type { RealtimeTranscriptionProviderPlugin } from "../plugins/types.js"; export type { RealtimeTranscriptionProviderConfig, diff --git a/src/plugin-sdk/realtime-voice.ts b/src/plugin-sdk/realtime-voice.ts index 039c048ee287..5f2e8db90d8b 100644 --- a/src/plugin-sdk/realtime-voice.ts +++ b/src/plugin-sdk/realtime-voice.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for realtime voice provider types, runtime helpers, and talk events. + */ export type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; export type { RealtimeVoiceAudioFormat, diff --git a/src/plugin-sdk/reply-chunking.ts b/src/plugin-sdk/reply-chunking.ts index eae86dd00e38..3f4af1ddd920 100644 --- a/src/plugin-sdk/reply-chunking.ts +++ b/src/plugin-sdk/reply-chunking.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for reply chunking modes and silent-reply token helpers. + */ export { chunkText, chunkTextWithMode, diff --git a/src/plugin-sdk/reply-dispatch-runtime.ts b/src/plugin-sdk/reply-dispatch-runtime.ts index 6d9861921653..aa64c5a52e8e 100644 --- a/src/plugin-sdk/reply-dispatch-runtime.ts +++ b/src/plugin-sdk/reply-dispatch-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for lazy reply dispatch and inbound-context helpers. + */ export { resolveChunkMode } from "../auto-reply/chunk.js"; export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js"; export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; @@ -23,6 +26,7 @@ const loadProviderDispatcherRuntimeModule = async () => { return await providerDispatcherRuntimeModulePromise; }; +/** Dispatches a reply with buffered block support after lazy-loading the runtime dispatcher. */ export const dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher = async (params) => { const { dispatchReplyWithBufferedBlockDispatcher: dispatch } = @@ -30,6 +34,7 @@ export const dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBuffered return await dispatch(params); }; +/** Dispatches a reply through the provider dispatcher after lazy-loading runtime code. */ export const dispatchReplyWithDispatcher: DispatchReplyWithDispatcher = async (params) => { const { dispatchReplyWithDispatcher: dispatch } = await loadProviderDispatcherRuntimeModule(); return await dispatch(params); diff --git a/src/plugin-sdk/reply-payload-testing.ts b/src/plugin-sdk/reply-payload-testing.ts index 854b079dd183..fb43b06a9b96 100644 --- a/src/plugin-sdk/reply-payload-testing.ts +++ b/src/plugin-sdk/reply-payload-testing.ts @@ -1 +1,4 @@ +/** + * Test SDK subpath for attaching metadata to reply payload fixtures. + */ export { setReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index d65569a0775e..bc7f68343823 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -19,31 +19,47 @@ export { } from "../auto-reply/reply-payload.js"; export type OutboundReplyPayload = { + /** Plain text reply body. */ text?: string; + /** Ordered media attachments for channels that can send multiple media items. */ mediaUrls?: string[]; + /** Legacy single media attachment. */ mediaUrl?: string; + /** Rich presentation payload for channels that support structured replies. */ presentation?: InternalReplyPayload["presentation"]; /** * @deprecated Use presentation. Runtime support remains for legacy producers. */ interactive?: InternalReplyPayload["interactive"]; + /** Channel-specific opaque data forwarded to outbound adapters. */ channelData?: InternalReplyPayload["channelData"]; + /** Marks media as sensitive for channel-specific spoiler/safety handling. */ sensitiveMedia?: boolean; + /** Platform message id that the outbound reply should target when supported. */ replyToId?: string; }; export type ReasoningReplyPayload = { + /** Reply text that may carry hidden reasoning markers. */ text?: string; + /** Explicit reasoning flag from upstream payload producers. */ isReasoning?: boolean; }; export type SendableOutboundReplyParts = { + /** Raw text selected for delivery before trimming. */ text: string; + /** Text after trimming whitespace for sendability checks. */ trimmedText: string; + /** Normalized non-empty media URLs. */ mediaUrls: string[]; + /** Number of normalized media URLs. */ mediaCount: number; + /** Whether trimmed text is sendable. */ hasText: boolean; + /** Whether at least one media URL is sendable. */ hasMedia: boolean; + /** Whether the payload has any sendable text or media. */ hasContent: boolean; }; @@ -188,11 +204,17 @@ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, TResult, >(params: { + /** Caller context containing the loose outbound payload. */ ctx: TContext; + /** Text length limit passed to the chunker for text-only payloads. */ textChunkLimit?: number; + /** Optional text chunker used only when no media URLs are present. */ chunker?: ((text: string, limit: number) => string[]) | null; + /** Transport hook for text-only chunks. */ sendText: (ctx: TContext & { text: string }) => Promise; + /** Transport hook for media sends; first media receives the caption text. */ sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise; + /** Result returned when payload has neither text nor media. */ emptyResult: TResult; }): Promise { const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }; @@ -202,6 +224,8 @@ export async function sendPayloadWithChunkedTextAndMedia< return params.emptyResult; } if (urls.length > 0) { + // Caption-limited transports get text only on the first media item; the + // final result still represents the last platform send. let lastResult = await params.sendMedia({ ...params.ctx, text, @@ -225,13 +249,20 @@ export async function sendPayloadWithChunkedTextAndMedia< return lastResult!; } +/** Sends a media sequence with caption text on the first item and returns the last send result. */ export async function sendPayloadMediaSequence(params: { + /** Caption text attached to the first non-empty media URL only. */ text: string; + /** Ordered media URLs to send, with empty entries skipped. */ mediaUrls: readonly string[]; send: (input: { + /** Caption text for the first media send, otherwise empty. */ text: string; + /** Media URL for this send. */ mediaUrl: string; + /** Original index in `mediaUrls`. */ index: number; + /** Whether this is the first media entry in the original sequence. */ isFirst: boolean; }) => Promise; }): Promise { @@ -251,8 +282,11 @@ export async function sendPayloadMediaSequence(params: { return lastResult; } +/** Sends a media sequence or returns a fallback when no media send produces a result. */ export async function sendPayloadMediaSequenceOrFallback(params: { + /** Caption text attached to the first non-empty media URL only. */ text: string; + /** Ordered media URLs to send, with empty entries skipped. */ mediaUrls: readonly string[]; send: (input: { text: string; @@ -260,7 +294,9 @@ export async function sendPayloadMediaSequenceOrFallback(params: { index: number; isFirst: boolean; }) => Promise; + /** Result returned when no media result is available. */ fallbackResult: TResult; + /** Optional callback used instead of `fallbackResult` when there are no media URLs. */ sendNoMedia?: () => Promise; }): Promise { if (params.mediaUrls.length === 0) { @@ -269,8 +305,11 @@ export async function sendPayloadMediaSequenceOrFallback(params: { return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; } +/** Sends media when present, then always runs finalization and returns its result. */ export async function sendPayloadMediaSequenceAndFinalize(params: { + /** Caption text attached to the first non-empty media URL only. */ text: string; + /** Ordered media URLs to send before finalization. */ mediaUrls: readonly string[]; send: (input: { text: string; @@ -278,6 +317,7 @@ export async function sendPayloadMediaSequenceAndFinalize index: number; isFirst: boolean; }) => Promise; + /** Final callback whose result is returned after optional media sends. */ finalize: () => Promise; }): Promise { if (params.mediaUrls.length > 0) { @@ -286,9 +326,13 @@ export async function sendPayloadMediaSequenceAndFinalize return await params.finalize(); } +/** Sends normalized text/media payloads through a channel outbound adapter. */ export async function sendTextMediaPayload(params: { + /** Channel id used in the empty fallback result. */ channel: string; + /** Channel send payload context. */ ctx: SendPayloadContext; + /** Adapter transport hooks for text, media, and optional chunking. */ adapter: SendPayloadAdapter; }): Promise { const text = params.ctx.payload.text ?? ""; @@ -296,6 +340,8 @@ export async function sendTextMediaPayload(params: { if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } + // Reply fanout may be single-use for implicit replies, so resolve it exactly + // once per platform send rather than copying the initial id into every part. const nextReplyToId = createReplyToFanout(params.ctx); if (urls.length > 0) { const audioAsVoice = params.ctx.payload.audioAsVoice ?? params.ctx.audioAsVoice; diff --git a/src/plugin-sdk/reply-reference.ts b/src/plugin-sdk/reply-reference.ts index b792e2f26fb5..9e9b1aee6902 100644 --- a/src/plugin-sdk/reply-reference.ts +++ b/src/plugin-sdk/reply-reference.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for reply reference planning and reply threading policy. + */ export { createReplyReferencePlanner, isSingleUseReplyToMode, diff --git a/src/plugin-sdk/request-url.ts b/src/plugin-sdk/request-url.ts index a1ce22162768..beff39ff128f 100644 --- a/src/plugin-sdk/request-url.ts +++ b/src/plugin-sdk/request-url.ts @@ -6,6 +6,8 @@ export function resolveRequestUrl(input: RequestInfo | URL): string { if (input instanceof URL) { return input.toString(); } + // Avoid `instanceof Request` so tests, fetch shims, and cross-realm Request + // objects can still expose their URL through the structural `url` field. if (typeof input === "object" && input && "url" in input && typeof input.url === "string") { return input.url; } diff --git a/src/plugin-sdk/routing.ts b/src/plugin-sdk/routing.ts index 7d7dd40c4774..765434a609e0 100644 --- a/src/plugin-sdk/routing.ts +++ b/src/plugin-sdk/routing.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for session keys, account bindings, and message-channel routing. + */ export { buildAgentSessionKey, deriveLastRoutePolicy, diff --git a/src/plugin-sdk/run-command.ts b/src/plugin-sdk/run-command.ts index 9ff13034dd3d..fb5330beadbf 100644 --- a/src/plugin-sdk/run-command.ts +++ b/src/plugin-sdk/run-command.ts @@ -2,15 +2,23 @@ import { formatErrorMessage } from "../infra/errors.js"; import { runCommandWithTimeout } from "../process/exec.js"; export type PluginCommandRunResult = { + /** Process exit code, with `1` used when the command failed before spawning or did not report one. */ code: number; + /** Captured standard output as UTF-8 text. */ stdout: string; + /** Captured standard error, normalized to include timeout or thrown-error messages. */ stderr: string; }; +/** Options for commands that are launched on behalf of a plugin runtime. */ export type PluginCommandRunOptions = { + /** Executable and arguments, with the command name in the first slot. */ argv: string[]; + /** Hard execution limit in milliseconds before the command is terminated. */ timeoutMs: number; + /** Working directory for the child process. Defaults to the current process directory. */ cwd?: string; + /** Environment passed to the child process. Defaults to the current process environment. */ env?: NodeJS.ProcessEnv; }; diff --git a/src/plugin-sdk/runtime-config-snapshot.ts b/src/plugin-sdk/runtime-config-snapshot.ts index 4e3db097eb04..c4dc316a06ac 100644 --- a/src/plugin-sdk/runtime-config-snapshot.ts +++ b/src/plugin-sdk/runtime-config-snapshot.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for config snapshot and config cache access. + */ export { clearRuntimeConfigSnapshot, getRuntimeConfigSnapshot, diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index 9ed6a339b2d2..96c12e378f94 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for plugin doctor migrations, compat checks, and uninstall helpers. + */ export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; export { asObjectRecord, diff --git a/src/plugin-sdk/runtime-group-policy.ts b/src/plugin-sdk/runtime-group-policy.ts index 5d5baa2d1347..397d3d17ef32 100644 --- a/src/plugin-sdk/runtime-group-policy.ts +++ b/src/plugin-sdk/runtime-group-policy.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for provider group policy resolution. + */ export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/plugin-sdk/runtime-store.test.ts b/src/plugin-sdk/runtime-store.test.ts index c3343eae9d92..226eb1070b56 100644 --- a/src/plugin-sdk/runtime-store.test.ts +++ b/src/plugin-sdk/runtime-store.test.ts @@ -1,3 +1,6 @@ +/** + * Tests runtime store singleton behavior. + */ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, test } from "vitest"; import { createPluginRuntimeStore } from "./runtime-store.js"; diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index 98a84f686d6f..5ae3ef4e60ef 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -4,11 +4,15 @@ const pluginRuntimeStoreRegistryKey = Symbol.for("openclaw.plugin-sdk.runtime-st type PluginRuntimeStoreRegistry = Map; type PluginRuntimeStoreKeyOptions = { + /** Explicit global registry key for shared runtime slots. */ key: string; + /** Error thrown by getRuntime before setRuntime initializes this slot. */ errorMessage: string; }; type PluginRuntimeStorePluginOptions = { + /** Plugin id used to derive a stable cross-module runtime slot key. */ pluginId: string; + /** Error thrown by getRuntime before setRuntime initializes this slot. */ errorMessage: string; }; type PluginRuntimeStoreOptions = PluginRuntimeStoreKeyOptions | PluginRuntimeStorePluginOptions; @@ -68,6 +72,8 @@ export function createPluginRuntimeStore(options: string | PluginRuntimeStore typeof options === "string" ? { runtime: null } : (() => { + // Store named slots on globalThis so duplicate SDK module instances + // still share one runtime for the same plugin id or explicit key. const registry = getPluginRuntimeStoreRegistry(); let existingSlot = registry.get(resolved.key); if (!existingSlot) { diff --git a/src/plugin-sdk/runtime.test.ts b/src/plugin-sdk/runtime.test.ts index 33be75b6fa63..02c764991d24 100644 --- a/src/plugin-sdk/runtime.test.ts +++ b/src/plugin-sdk/runtime.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin SDK runtime exports, logging wrappers, and runtime env helpers. + */ import { describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { resolveRuntimeEnv } from "./runtime.js"; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index edd6b0382864..576d49879c4b 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for runtime logging, env, backup, and process helpers. + */ export type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js"; export { createNonExitingRuntime, defaultRuntime } from "../runtime.js"; export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index 23204e8658e3..bd4da8af61c7 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for sandbox backends, SSH execution, and temp workspace helpers. + */ export type { CreateSandboxBackendParams, RemoteShellSandboxHandle, diff --git a/src/plugin-sdk/schema-normalization-runtime-contract.test.ts b/src/plugin-sdk/schema-normalization-runtime-contract.test.ts index 474509016909..08f53256f17c 100644 --- a/src/plugin-sdk/schema-normalization-runtime-contract.test.ts +++ b/src/plugin-sdk/schema-normalization-runtime-contract.test.ts @@ -1,3 +1,6 @@ +/** + * Contract tests for schema normalization runtime behavior exposed to plugins. + */ import { createNativeOpenAICodexResponsesModel, createNativeOpenAIResponsesModel, diff --git a/src/plugin-sdk/secret-file-runtime.ts b/src/plugin-sdk/secret-file-runtime.ts index 2d7aebf24045..db9091fcfac1 100644 --- a/src/plugin-sdk/secret-file-runtime.ts +++ b/src/plugin-sdk/secret-file-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for private secret file reads and atomic writes. + */ export { DEFAULT_SECRET_FILE_MAX_BYTES, PRIVATE_SECRET_DIR_MODE, diff --git a/src/plugin-sdk/secret-input-runtime.ts b/src/plugin-sdk/secret-input-runtime.ts index 1a2e050fff15..e950c0ee48e3 100644 --- a/src/plugin-sdk/secret-input-runtime.ts +++ b/src/plugin-sdk/secret-input-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for secret input normalization and configured secret resolution. + */ export { coerceSecretRef, hasConfiguredSecretInput, diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts index 9e178ebbdb39..8389f92b4ee8 100644 --- a/src/plugin-sdk/secret-input-schema.ts +++ b/src/plugin-sdk/secret-input-schema.ts @@ -8,6 +8,10 @@ import { SECRET_PROVIDER_ALIAS_PATTERN, } from "../secrets/ref-contract.js"; +/** + * Returns the shared secret-input schema for plaintext values and env/file/exec refs. + * Reusing this singleton preserves sensitive-path registration for config redaction. + */ export function buildSecretInputSchema() { return secretInputSchema; } diff --git a/src/plugin-sdk/secret-input.test.ts b/src/plugin-sdk/secret-input.test.ts index 00a258997962..5c89a5fc5475 100644 --- a/src/plugin-sdk/secret-input.test.ts +++ b/src/plugin-sdk/secret-input.test.ts @@ -1,3 +1,6 @@ +/** + * Tests secret input parsing, normalization, and configured secret resolution. + */ import { describe, expect, it } from "vitest"; import { INVALID_EXEC_SECRET_REF_IDS, diff --git a/src/plugin-sdk/secret-input.ts b/src/plugin-sdk/secret-input.ts index 49c31046668b..17eac06a2175 100644 --- a/src/plugin-sdk/secret-input.ts +++ b/src/plugin-sdk/secret-input.ts @@ -26,12 +26,18 @@ export { normalizeSecretInputString, }; -/** Optional version of the shared secret-input schema. */ +/** + * Builds an optional secret-input schema for config fields that may be omitted. + * The inner schema stays shared so sensitive-path redaction still recognizes it. + */ export function buildOptionalSecretInputSchema() { return buildSecretInputSchema().optional(); } -/** Array version of the shared secret-input schema. */ +/** + * Builds an array schema for provider/channel config that accepts multiple secret inputs. + * Each element uses the shared schema so plaintext and ref validation stay identical. + */ export function buildSecretInputArraySchema() { return z.array(buildSecretInputSchema()); } diff --git a/src/plugin-sdk/secret-provider-integration.ts b/src/plugin-sdk/secret-provider-integration.ts index cfb258c0c15e..dc89b049db65 100644 --- a/src/plugin-sdk/secret-provider-integration.ts +++ b/src/plugin-sdk/secret-provider-integration.ts @@ -1,3 +1,6 @@ +/** + * Public SDK type surface for plugin-declared secret provider integrations. + */ export type { PluginManifestSecretProviderIntegration } from "../plugins/manifest.js"; export type { SecretProviderIntegrationPreset, diff --git a/src/plugin-sdk/secure-random-runtime.ts b/src/plugin-sdk/secure-random-runtime.ts index f621243ce3c6..1161eae788ca 100644 --- a/src/plugin-sdk/secure-random-runtime.ts +++ b/src/plugin-sdk/secure-random-runtime.ts @@ -1,3 +1,4 @@ -// Secure random token helpers for plugin runtime state. - +/** + * Runtime SDK subpath for secure token and UUID generation. + */ export { generateSecureToken, generateSecureUuid } from "../infra/secure-random.js"; diff --git a/src/plugin-sdk/session-key-runtime.ts b/src/plugin-sdk/session-key-runtime.ts index 84f2ce662fce..13d9ab1bc7a0 100644 --- a/src/plugin-sdk/session-key-runtime.ts +++ b/src/plugin-sdk/session-key-runtime.ts @@ -1,5 +1,6 @@ -// Narrow session-key helpers for channel hot paths that should not import the -// broader routing SDK barrel. +/** + * Runtime SDK subpath for parsing agent ids from session keys. + */ export { resolveAgentIdFromSessionKey, type ParsedAgentSessionKey, diff --git a/src/plugin-sdk/setup-runtime.ts b/src/plugin-sdk/setup-runtime.ts index bc8057be6281..609e24b9792e 100644 --- a/src/plugin-sdk/setup-runtime.ts +++ b/src/plugin-sdk/setup-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for channel setup wizards, prompts, and allowlist helpers. + */ export type { OpenClawConfig } from "../config/config.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createClackPrompter } from "../wizard/clack-prompter.js"; diff --git a/src/plugin-sdk/setup-tools.ts b/src/plugin-sdk/setup-tools.ts index bf2284e7eb2e..95fb98bdca00 100644 --- a/src/plugin-sdk/setup-tools.ts +++ b/src/plugin-sdk/setup-tools.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for setup-time command, archive, binary, and docs-link helpers. + */ export { formatCliCommand } from "../cli/command-format.js"; export { extractArchive } from "../infra/archive.js"; export { resolveBrewExecutable } from "../infra/brew.js"; diff --git a/src/plugin-sdk/simple-completion-runtime.ts b/src/plugin-sdk/simple-completion-runtime.ts index ea01a9e3ea23..c932f8060057 100644 --- a/src/plugin-sdk/simple-completion-runtime.ts +++ b/src/plugin-sdk/simple-completion-runtime.ts @@ -1,2 +1,5 @@ +/** + * Runtime SDK subpath for simple model completions and assistant text extraction. + */ export * from "../agents/simple-completion-runtime.js"; export { extractAssistantText } from "../agents/embedded-agent-utils.js"; diff --git a/src/plugin-sdk/skill-commands-runtime.ts b/src/plugin-sdk/skill-commands-runtime.ts index a3343796b62f..adb342396560 100644 --- a/src/plugin-sdk/skill-commands-runtime.ts +++ b/src/plugin-sdk/skill-commands-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for discovering skill-backed chat commands. + */ export { listSkillCommandsForAgents, listSkillCommandsForWorkspace, diff --git a/src/plugin-sdk/skills-runtime.ts b/src/plugin-sdk/skills-runtime.ts index 41dbef830b05..4196a1367800 100644 --- a/src/plugin-sdk/skills-runtime.ts +++ b/src/plugin-sdk/skills-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for skill snapshot invalidation and refresh listeners. + */ export { bumpSkillsSnapshotVersion, getSkillsSnapshotVersion, diff --git a/src/plugin-sdk/ssrf-policy.ts b/src/plugin-sdk/ssrf-policy.ts index 1c682a699284..4c47555e659a 100644 --- a/src/plugin-sdk/ssrf-policy.ts +++ b/src/plugin-sdk/ssrf-policy.ts @@ -24,15 +24,18 @@ export type PrivateNetworkOptInInput = | undefined | Pick | { + /** Canonical explicit opt-in for private/internal network targets. */ dangerouslyAllowPrivateNetwork?: boolean | null; /** @deprecated Compatibility alias; prefer dangerouslyAllowPrivateNetwork. */ allowPrivateNetwork?: boolean | null; + /** Nested channel config shape used by current plugin network settings. */ network?: | Pick | null | undefined; }; +/** Reads current and legacy private-network opt-in shapes from channel config. */ export function isPrivateNetworkOptInEnabled(input: PrivateNetworkOptInInput): boolean { if (input === true) { return true; @@ -50,23 +53,27 @@ export function isPrivateNetworkOptInEnabled(input: PrivateNetworkOptInInput): b ); } +/** Converts channel private-network opt-in config into the shared SSRF policy shape. */ export function ssrfPolicyFromPrivateNetworkOptIn( input: PrivateNetworkOptInInput, ): SsrFPolicy | undefined { return isPrivateNetworkOptInEnabled(input) ? { allowPrivateNetwork: true } : undefined; } +/** Compatibility wrapper for callers that already use the canonical dangerous flag name. */ export function ssrfPolicyFromDangerouslyAllowPrivateNetwork( dangerouslyAllowPrivateNetwork: boolean | null | undefined, ): SsrFPolicy | undefined { return ssrfPolicyFromPrivateNetworkOptIn(dangerouslyAllowPrivateNetwork); } +/** Detects the retired flat `allowPrivateNetwork` key before doctor migration. */ export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean { const entry = asNullableRecord(value); return Boolean(entry && Object.hasOwn(entry, "allowPrivateNetwork")); } +/** Moves flat private-network config into `network.dangerouslyAllowPrivateNetwork`. */ export function migrateLegacyFlatAllowPrivateNetworkAlias(params: { entry: Record; pathPrefix: string; @@ -201,12 +208,14 @@ export function createLegacyPrivateNetworkDoctorContract(params: { channelKey: s }; } +/** @deprecated Use `ssrfPolicyFromDangerouslyAllowPrivateNetwork`. */ export function ssrfPolicyFromAllowPrivateNetwork( allowPrivateNetwork: boolean | null | undefined, ): SsrFPolicy | undefined { return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork); } +/** Allows cleartext HTTP only when the target is loopback/private or DNS-pins to private IPs. */ export async function assertHttpUrlTargetsPrivateNetwork( url: string, params: { @@ -288,6 +297,7 @@ export function normalizeHostnameSuffixAllowlist( } const normalized = normalizeUniqueStringEntries(source.map(normalizeHostnameSuffix)); if (normalized.includes("*")) { + // `*` is an explicit opt-out from hostname suffix restrictions. return ["*"]; } return normalized; diff --git a/src/plugin-sdk/string-normalization-runtime.ts b/src/plugin-sdk/string-normalization-runtime.ts index 035b88652456..ccab72d4639a 100644 --- a/src/plugin-sdk/string-normalization-runtime.ts +++ b/src/plugin-sdk/string-normalization-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for shared slug and string-entry normalization helpers. + */ export { normalizeAtHashSlug, normalizeHyphenSlug, diff --git a/src/plugin-sdk/talk-config-runtime.ts b/src/plugin-sdk/talk-config-runtime.ts index 29868d05d96c..014850f35835 100644 --- a/src/plugin-sdk/talk-config-runtime.ts +++ b/src/plugin-sdk/talk-config-runtime.ts @@ -1,2 +1,5 @@ +/** + * Runtime SDK subpath for resolving active talk provider configuration. + */ export { resolveActiveTalkProviderConfig } from "../config/talk.js"; export type { OpenClawConfig } from "../config/types.js"; diff --git a/src/plugin-sdk/target-resolver-runtime.ts b/src/plugin-sdk/target-resolver-runtime.ts index 06b839165659..b2647ad523e1 100644 --- a/src/plugin-sdk/target-resolver-runtime.ts +++ b/src/plugin-sdk/target-resolver-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for resolving plugin-declared channel targets. + */ export { buildUnresolvedTargetResults, resolveTargetsWithOptionalToken, diff --git a/src/plugin-sdk/telegram-account.test.ts b/src/plugin-sdk/telegram-account.test.ts index 458b75b135e9..b25e7d3d9408 100644 --- a/src/plugin-sdk/telegram-account.test.ts +++ b/src/plugin-sdk/telegram-account.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Telegram account helper behavior. + */ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { diff --git a/src/plugin-sdk/telegram-command-config.test.ts b/src/plugin-sdk/telegram-command-config.test.ts index 088c0f72fa85..5cd438c8bc43 100644 --- a/src/plugin-sdk/telegram-command-config.test.ts +++ b/src/plugin-sdk/telegram-command-config.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Telegram command config helpers and command UI behavior. + */ import { describe, expect, it } from "vitest"; import * as telegramCommandConfig from "./telegram-command-config.js"; diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts index 2cb9ef950ca9..92d0f576787d 100644 --- a/src/plugin-sdk/telegram-command-config.ts +++ b/src/plugin-sdk/telegram-command-config.ts @@ -9,14 +9,21 @@ import { resolveCustomCommands, } from "../shared/custom-command-config.js"; +/** Raw Telegram bot command entry from config. */ export type TelegramCustomCommandInput = { + /** User-provided command name; leading slash is optional and removed during normalization. */ command?: string | null; + /** User-provided Bot API description, trimmed before validation. */ description?: string | null; }; +/** Validation issue returned for one Telegram custom command entry. */ export type TelegramCustomCommandIssue = { + /** Zero-based index of the command entry that failed validation. */ index: number; + /** Field that should be corrected in the raw command entry. */ field: "command" | "description"; + /** Operator-facing validation message with the normalized command when available. */ message: string; }; const TELEGRAM_COMMAND_NAME_PATTERN_VALUE = /^[a-z0-9_]{1,32}$/; @@ -43,30 +50,47 @@ function resolveTelegramCustomCommandsImpl(params: { commands: Array<{ command: string; description: string }>; issues: TelegramCustomCommandIssue[]; } { + // Keep the deprecated SDK subpath on the shared command validator so config, + // doctor, and plugin-local Telegram flows report the same normalized issues. return resolveCustomCommands({ ...params, config: TELEGRAM_CUSTOM_COMMAND_CONFIG, }); } +/** Returns the Telegram command-name regex accepted by Bot API menu commands. */ export function getTelegramCommandNamePattern(): RegExp { return TELEGRAM_COMMAND_NAME_PATTERN_VALUE; } +/** Telegram Bot API command-name pattern: a-z, 0-9, underscore, max 32 chars. */ export const TELEGRAM_COMMAND_NAME_PATTERN = TELEGRAM_COMMAND_NAME_PATTERN_VALUE; -export function normalizeTelegramCommandName(value: string): string { +/** Normalizes user-provided Telegram command names into Bot API form. */ +export function normalizeTelegramCommandName( + /** Raw command name; leading slash is optional and dashes become underscores. */ + value: string, +): string { return normalizeTelegramCommandNameImpl(value); } -export function normalizeTelegramCommandDescription(value: string): string { +/** Normalizes Telegram command descriptions for Bot API menu registration. */ +export function normalizeTelegramCommandDescription( + /** Raw command description, trimmed without other text rewriting. */ + value: string, +): string { return normalizeTelegramCommandDescriptionImpl(value); } +/** Validates and normalizes configured Telegram custom commands. */ export function resolveTelegramCustomCommands(params: { + /** Raw configured commands to normalize and validate in order. */ commands?: TelegramCustomCommandInput[] | null; + /** Native command names that custom commands must not shadow when reserved checks are enabled. */ reservedCommands?: Set; + /** Set false to allow names that overlap native commands. */ checkReserved?: boolean; + /** Set false to allow duplicate normalized custom command names. */ checkDuplicates?: boolean; }): { commands: Array<{ command: string; description: string }>; diff --git a/src/plugin-sdk/telegram-command-ui.ts b/src/plugin-sdk/telegram-command-ui.ts index 57225c42f7b2..2d126dd1bb00 100644 --- a/src/plugin-sdk/telegram-command-ui.ts +++ b/src/plugin-sdk/telegram-command-ui.ts @@ -1,3 +1,7 @@ +/** + * Telegram command UI helpers exposed for plugin command pagination. + */ +/** Builds an inline keyboard row for paginated Telegram command listings. */ export function buildCommandsPaginationKeyboard( currentPage: number, totalPages: number, diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index 4ad38c8aa2f9..640238a48888 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for temporary file and workspace helpers. + */ export { buildRandomTempFilePath, createTempDownloadTarget, diff --git a/src/plugin-sdk/test-helpers.ts b/src/plugin-sdk/test-helpers.ts index b4ca04180790..cdfb77050c35 100644 --- a/src/plugin-sdk/test-helpers.ts +++ b/src/plugin-sdk/test-helpers.ts @@ -1,9 +1,13 @@ +/** + * Shared test harness for plugin SDK contract tests that need temp fixtures. + */ import { mkdirSync, type RmOptions } from "node:fs"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll } from "vitest"; +/** Creates per-suite temp fixture helpers with automatic Vitest cleanup. */ export function createPluginSdkTestHarness(options?: { cleanup?: RmOptions }) { let fixtureRoot = ""; let caseId = 0; diff --git a/src/plugin-sdk/test-helpers/agents/auth-profile-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/auth-profile-runtime-contract.ts index 404356860a65..8744df749e52 100644 --- a/src/plugin-sdk/test-helpers/agents/auth-profile-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/auth-profile-runtime-contract.ts @@ -1,3 +1,6 @@ +/** + * Shared contract fixtures for agent auth profile runtime behavior. + */ import { resolveProviderIdForAuth, type ProviderAuthAliasLookupParams, diff --git a/src/plugin-sdk/test-helpers/agents/delivery-no-reply-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/delivery-no-reply-runtime-contract.ts index b8aa7f9cd0f8..d5998e929c8b 100644 --- a/src/plugin-sdk/test-helpers/agents/delivery-no-reply-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/delivery-no-reply-runtime-contract.ts @@ -1,3 +1,6 @@ +/** + * Shared contract fixture for delivery no-reply runtime handling. + */ export const DELIVERY_NO_REPLY_RUNTIME_CONTRACT = { sessionId: "session-delivery-contract", sessionKey: "agent:main:delivery-contract", diff --git a/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts index 1cab10b43c64..542b097abcf9 100644 --- a/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/outcome-fallback-runtime-contract.ts @@ -1,3 +1,6 @@ +/** + * Shared contract fixture for agent outcome fallback behavior. + */ import type { EmbeddedAgentRunResult } from "../../../agents/embedded-agent-runner/types.js"; export const OUTCOME_FALLBACK_RUNTIME_CONTRACT = { diff --git a/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts index d86e016ba496..f7e35f8f90e1 100644 --- a/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts @@ -1,3 +1,6 @@ +/** + * Shared contract fixtures for agent prompt overlay runtime behavior. + */ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ProviderSystemPromptContributionContext } from "../../../plugins/types.js"; diff --git a/src/plugin-sdk/test-helpers/bundled-channel-entry.ts b/src/plugin-sdk/test-helpers/bundled-channel-entry.ts index 26660b13b939..4e1635b1737c 100644 --- a/src/plugin-sdk/test-helpers/bundled-channel-entry.ts +++ b/src/plugin-sdk/test-helpers/bundled-channel-entry.ts @@ -1,3 +1,6 @@ +/** + * Contract helper for bundled channel public entrypoint assertions. + */ import { expect, it } from "vitest"; type BundledChannelEntry = { diff --git a/src/plugin-sdk/test-helpers/bundled-plugin-paths.ts b/src/plugin-sdk/test-helpers/bundled-plugin-paths.ts index 504efea0986a..24881c5949b9 100644 --- a/src/plugin-sdk/test-helpers/bundled-plugin-paths.ts +++ b/src/plugin-sdk/test-helpers/bundled-plugin-paths.ts @@ -1,27 +1,36 @@ +/** Source directory that contains bundled plugin packages. */ export const BUNDLED_PLUGIN_ROOT_DIR = "extensions"; +/** Repo-relative prefix for files inside bundled plugin packages. */ export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`; +/** Glob that matches bundled plugin test files in source checkouts. */ export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`; +/** Return the repo-relative source root for a bundled plugin id. */ export function bundledPluginRoot(pluginId: string): string { return `${BUNDLED_PLUGIN_PATH_PREFIX}${pluginId}`; } +/** Return a repo-relative source file path inside a bundled plugin. */ export function bundledPluginFile(pluginId: string, relativePath: string): string { return `${bundledPluginRoot(pluginId)}/${relativePath}`; } function joinRoot(baseDir: string, relativePath: string): string { + // Keep callers free to pass package roots with or without a trailing slash. return `${baseDir.replace(/\/$/, "")}/${relativePath}`; } +/** Return a repo-relative source directory prefix inside a bundled plugin. */ export function bundledPluginDirPrefix(pluginId: string, relativeDir: string): string { return `${bundledPluginRoot(pluginId)}/${relativeDir.replace(/\/$/, "")}/`; } +/** Return an absolute or caller-rooted bundled plugin source root. */ export function bundledPluginRootAt(baseDir: string, pluginId: string): string { return joinRoot(baseDir, bundledPluginRoot(pluginId)); } +/** Return an absolute or caller-rooted bundled plugin source file path. */ export function bundledPluginFileAt( baseDir: string, pluginId: string, @@ -30,18 +39,22 @@ export function bundledPluginFileAt( return joinRoot(baseDir, bundledPluginFile(pluginId, relativePath)); } +/** Return the repo-relative dist root for a bundled plugin id. */ export function bundledDistPluginRoot(pluginId: string): string { return `dist/${bundledPluginRoot(pluginId)}`; } +/** Return a repo-relative dist file path inside a bundled plugin. */ export function bundledDistPluginFile(pluginId: string, relativePath: string): string { return `${bundledDistPluginRoot(pluginId)}/${relativePath}`; } +/** Return an absolute or caller-rooted bundled plugin dist root. */ export function bundledDistPluginRootAt(baseDir: string, pluginId: string): string { return joinRoot(baseDir, bundledDistPluginRoot(pluginId)); } +/** Return an absolute or caller-rooted bundled plugin dist file path. */ export function bundledDistPluginFileAt( baseDir: string, pluginId: string, @@ -50,10 +63,12 @@ export function bundledDistPluginFileAt( return joinRoot(baseDir, bundledDistPluginFile(pluginId, relativePath)); } +/** Compatibility alias for installed bundled plugin roots under a package root. */ export function installedPluginRoot(baseDir: string, pluginId: string): string { return bundledPluginRootAt(baseDir, pluginId); } +/** Return the local install spec used by tests for repo-owned bundled plugins. */ export function repoInstallSpec(pluginId: string): string { return `./${bundledPluginRoot(pluginId)}`; } diff --git a/src/plugin-sdk/test-helpers/contracts-testkit.ts b/src/plugin-sdk/test-helpers/contracts-testkit.ts index 28f16cd96561..58cf5e5c844b 100644 --- a/src/plugin-sdk/test-helpers/contracts-testkit.ts +++ b/src/plugin-sdk/test-helpers/contracts-testkit.ts @@ -1,3 +1,6 @@ +/** + * Core plugin SDK contract-test fixture builders and registration helpers. + */ import type { PluginRegistryParams } from "../../plugins/registry-types.js"; import type { OpenClawPluginApi } from "../plugin-entry.js"; import { @@ -14,6 +17,7 @@ import { uniqueSortedStrings } from "./string-utils.js"; export { registerProviders, requireProvider, uniqueSortedStrings }; +/** Creates a minimal plugin registry fixture with quiet logger defaults. */ export function createPluginRegistryFixture( config = {} as OpenClawConfig, params: { hostServices?: PluginRegistryParams["hostServices"] } = {}, @@ -33,6 +37,7 @@ export function createPluginRegistryFixture( }; } +/** Registers one plugin record against a registry fixture and invokes its register hook. */ export function registerTestPlugin(params: { registry: ReturnType; config: OpenClawConfig; @@ -47,6 +52,7 @@ export function registerTestPlugin(params: { ); } +/** Registers a virtual plugin record for tests that do not need a real package path. */ export function registerVirtualTestPlugin(params: { registry: ReturnType; config: OpenClawConfig; diff --git a/src/plugin-sdk/test-helpers/direct-smoke.ts b/src/plugin-sdk/test-helpers/direct-smoke.ts index 517bbd576ed8..5c31ba1caf05 100644 --- a/src/plugin-sdk/test-helpers/direct-smoke.ts +++ b/src/plugin-sdk/test-helpers/direct-smoke.ts @@ -1,3 +1,6 @@ +/** + * Direct import smoke helper for plugin public artifact tests. + */ import { execFile } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/src/plugin-sdk/test-helpers/directory-ids.ts b/src/plugin-sdk/test-helpers/directory-ids.ts index 453d63a3dd5f..4542edfd12cb 100644 --- a/src/plugin-sdk/test-helpers/directory-ids.ts +++ b/src/plugin-sdk/test-helpers/directory-ids.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for channel directory id contract tests. + */ import { expect } from "vitest"; import type { ChannelDirectoryEntry } from "../channel-contract.js"; import type { OpenClawConfig } from "../config-types.js"; @@ -9,6 +12,7 @@ export type DirectoryListFn = (params: { limit?: number | null; }) => Promise; +/** Calls a directory lister and compares returned ids, optionally ignoring order. */ export async function expectDirectoryIds( listFn: DirectoryListFn, cfg: OpenClawConfig, diff --git a/src/plugin-sdk/test-helpers/directory.ts b/src/plugin-sdk/test-helpers/directory.ts index b3bf1df15304..25155abc89c5 100644 --- a/src/plugin-sdk/test-helpers/directory.ts +++ b/src/plugin-sdk/test-helpers/directory.ts @@ -1,3 +1,6 @@ +/** + * Shared directory adapter fixtures for channel contract tests. + */ import type { ChannelDirectoryAdapter } from "../channel-contract.js"; type DirectorySurface = { diff --git a/src/plugin-sdk/test-helpers/envelope-timestamp.ts b/src/plugin-sdk/test-helpers/envelope-timestamp.ts index 1c2add763d7e..b44c2ed7ce87 100644 --- a/src/plugin-sdk/test-helpers/envelope-timestamp.ts +++ b/src/plugin-sdk/test-helpers/envelope-timestamp.ts @@ -1,3 +1,6 @@ +/** + * Shared helpers for asserting envelope timestamp normalization in channel tests. + */ import { formatUtcTimestamp, formatZonedTimestamp, diff --git a/src/plugin-sdk/test-helpers/import-fresh.ts b/src/plugin-sdk/test-helpers/import-fresh.ts index 577e25cd856b..2bcde4b40ce1 100644 --- a/src/plugin-sdk/test-helpers/import-fresh.ts +++ b/src/plugin-sdk/test-helpers/import-fresh.ts @@ -1,3 +1,4 @@ +/** Import a module by URL relative to another module, preserving query-string cache busting. */ export async function importFreshModule( from: string, specifier: string, diff --git a/src/plugin-sdk/test-helpers/import-side-effects.ts b/src/plugin-sdk/test-helpers/import-side-effects.ts index af2eb0e93e10..067fcaaac076 100644 --- a/src/plugin-sdk/test-helpers/import-side-effects.ts +++ b/src/plugin-sdk/test-helpers/import-side-effects.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for import-time side effect detection. + */ function formatImportSideEffectCall(args: readonly unknown[]): string { if (args.length === 0) { return "(no args)"; diff --git a/src/plugin-sdk/test-helpers/mock-incoming-request.ts b/src/plugin-sdk/test-helpers/mock-incoming-request.ts index 208389303875..f8fb0b10002f 100644 --- a/src/plugin-sdk/test-helpers/mock-incoming-request.ts +++ b/src/plugin-sdk/test-helpers/mock-incoming-request.ts @@ -1,3 +1,6 @@ +/** + * Mock IncomingMessage builder for webhook and HTTP request tests. + */ import { EventEmitter } from "node:events"; import type { IncomingMessage } from "node:http"; diff --git a/src/plugin-sdk/test-helpers/node-builtin-mocks.child-process.test.ts b/src/plugin-sdk/test-helpers/node-builtin-mocks.child-process.test.ts index 3afd24ab6129..404bbd98e746 100644 --- a/src/plugin-sdk/test-helpers/node-builtin-mocks.child-process.test.ts +++ b/src/plugin-sdk/test-helpers/node-builtin-mocks.child-process.test.ts @@ -1,3 +1,6 @@ +/** + * Tests child_process behavior in the shared Node builtin mock helpers. + */ import { describe, expect, it, vi } from "vitest"; const spawnSyncMock = vi.hoisted(() => vi.fn()); diff --git a/src/plugin-sdk/test-helpers/node-builtin-mocks.test.ts b/src/plugin-sdk/test-helpers/node-builtin-mocks.test.ts index 7639d998296a..7d9faf498bfb 100644 --- a/src/plugin-sdk/test-helpers/node-builtin-mocks.test.ts +++ b/src/plugin-sdk/test-helpers/node-builtin-mocks.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Node builtin mock helpers exported for plugin SDK test fixtures. + */ import { describe, expect, it } from "vitest"; import { mockNodeBuiltinModule } from "./node-builtin-mocks.js"; diff --git a/src/plugin-sdk/test-helpers/node-builtin-mocks.ts b/src/plugin-sdk/test-helpers/node-builtin-mocks.ts index 83670cc743a7..c51fa243d861 100644 --- a/src/plugin-sdk/test-helpers/node-builtin-mocks.ts +++ b/src/plugin-sdk/test-helpers/node-builtin-mocks.ts @@ -1,3 +1,6 @@ +/** + * Shared Vitest mocks for Node builtin modules used by plugin tests. + */ import { vi } from "vitest"; type MockFactory = diff --git a/src/plugin-sdk/test-helpers/package-manifest-contract.ts b/src/plugin-sdk/test-helpers/package-manifest-contract.ts index eff41dbdfac9..9862936f7413 100644 --- a/src/plugin-sdk/test-helpers/package-manifest-contract.ts +++ b/src/plugin-sdk/test-helpers/package-manifest-contract.ts @@ -1,3 +1,6 @@ +/** + * Contract suite for bundled plugin package manifests and host version floors. + */ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -28,6 +31,7 @@ function bundledPluginFile(pluginId: string, relativePath: string): string { return `extensions/${pluginId}/${relativePath}`; } +/** Installs manifest contract tests for one bundled plugin package. */ export function describePackageManifestContract(params: PackageManifestContractParams) { const packagePath = bundledPluginFile(params.pluginId, "package.json"); diff --git a/src/plugin-sdk/test-helpers/pairing-reply.ts b/src/plugin-sdk/test-helpers/pairing-reply.ts index 1a781a31d4a3..1b1c9bd6bf2d 100644 --- a/src/plugin-sdk/test-helpers/pairing-reply.ts +++ b/src/plugin-sdk/test-helpers/pairing-reply.ts @@ -1,11 +1,16 @@ +/** + * Shared assertions for channel pairing reply text. + */ import { expect } from "vitest"; +/** Extracts and asserts the pairing code block from a pairing reply. */ export function extractPairingCode(text: string): string { const code = text.match(/Pairing code:\s*```[\r\n]+([A-Z2-9]{6,})/)?.[1]; expect(code).toBeDefined(); return code ?? ""; } +/** Verifies the visible pairing reply contains the expected id and approve command. */ export function expectPairingReplyText( text: string, params: { diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts index 51876172f8e7..252bd6bedf6e 100644 --- a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts +++ b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts @@ -1,3 +1,6 @@ +/** + * Installs bundled plugin registration contract cases used across provider tests. + */ import { describePluginRegistrationContract } from "./plugin-registration-contract.js"; type PluginRegistrationContractParams = Parameters[0]; diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract.ts index c895c833edb7..4843114f458d 100644 --- a/src/plugin-sdk/test-helpers/plugin-registration-contract.ts +++ b/src/plugin-sdk/test-helpers/plugin-registration-contract.ts @@ -1,3 +1,6 @@ +/** + * Contract suite for bundled plugin registration ownership and manifest auth metadata. + */ import { describe, expect, it } from "vitest"; import { loadPluginManifestRegistry, pluginRegistrationContractRegistry } from "../testing.js"; @@ -40,6 +43,7 @@ function findRegistration(pluginId: string) { return entry; } +/** Installs tests that pin a bundled plugin's registered provider/tool ownership. */ export function describePluginRegistrationContract(params: PluginRegistrationContractParams) { describe(`${params.pluginId} plugin registration contract`, () => { if (params.cliBackendIds) { diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts index c91ad9478476..3d5c300f9f2f 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts @@ -1,3 +1,6 @@ +/** + * Tests plugin runtime mock helpers stay aligned with channel runtime contracts. + */ import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import { describe, expect, it, vi } from "vitest"; diff --git a/src/plugin-sdk/test-helpers/provider-catalog.ts b/src/plugin-sdk/test-helpers/provider-catalog.ts index c2b670fec909..2fcedcd8301d 100644 --- a/src/plugin-sdk/test-helpers/provider-catalog.ts +++ b/src/plugin-sdk/test-helpers/provider-catalog.ts @@ -1,3 +1,6 @@ +/** + * Provider catalog contract assertions and expected Codex catalog fixtures. + */ export { expectAugmentedCodexCatalog, expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55, diff --git a/src/plugin-sdk/test-helpers/provider-http-mocks.ts b/src/plugin-sdk/test-helpers/provider-http-mocks.ts index 0692b629e990..8ba71b69768b 100644 --- a/src/plugin-sdk/test-helpers/provider-http-mocks.ts +++ b/src/plugin-sdk/test-helpers/provider-http-mocks.ts @@ -1,3 +1,6 @@ +/** + * Shared HTTP fetch mock helpers for provider contract tests. + */ import { afterEach, vi, type Mock } from "vitest"; import type { fetchProviderDownloadResponse, diff --git a/src/plugin-sdk/test-helpers/provider-media-capability-assertions.ts b/src/plugin-sdk/test-helpers/provider-media-capability-assertions.ts index 570a26cbbf7f..f19672a7e183 100644 --- a/src/plugin-sdk/test-helpers/provider-media-capability-assertions.ts +++ b/src/plugin-sdk/test-helpers/provider-media-capability-assertions.ts @@ -1,3 +1,6 @@ +/** + * Assertions for video and music provider media capability contracts. + */ import { expect } from "vitest"; import { listSupportedMusicGenerationModes } from "../../music-generation/capabilities.js"; import type { MusicGenerationProviderPlugin } from "../../plugins/types.js"; @@ -16,6 +19,7 @@ function hasPositiveModeLimit( ); } +/** Verifies a video provider declares coherent generate/image/video capability flags. */ export function expectExplicitVideoGenerationCapabilities( provider: VideoGenerationProviderPlugin, ): void { @@ -52,6 +56,7 @@ export function expectExplicitVideoGenerationCapabilities( } } +/** Verifies a music provider declares coherent generate/edit capability flags. */ export function expectExplicitMusicGenerationCapabilities( provider: MusicGenerationProviderPlugin, ): void { diff --git a/src/plugin-sdk/test-helpers/provider-onboard.ts b/src/plugin-sdk/test-helpers/provider-onboard.ts index 3b07a5c6dc42..2e21ba6ee1aa 100644 --- a/src/plugin-sdk/test-helpers/provider-onboard.ts +++ b/src/plugin-sdk/test-helpers/provider-onboard.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for provider onboarding config migration and fallback behavior. + */ import { expect } from "vitest"; import { resolveAgentModelFallbackValues, diff --git a/src/plugin-sdk/test-helpers/provider-replay-policy.ts b/src/plugin-sdk/test-helpers/provider-replay-policy.ts index c8010c5a5029..56582a292f6c 100644 --- a/src/plugin-sdk/test-helpers/provider-replay-policy.ts +++ b/src/plugin-sdk/test-helpers/provider-replay-policy.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for provider replay policy passthrough behavior. + */ import { expect } from "vitest"; import { registerSingleProviderPlugin } from "../plugin-test-runtime.js"; diff --git a/src/plugin-sdk/test-helpers/provider-wizard-contract-suites.ts b/src/plugin-sdk/test-helpers/provider-wizard-contract-suites.ts index 392991cf1f81..31cfdd1ec872 100644 --- a/src/plugin-sdk/test-helpers/provider-wizard-contract-suites.ts +++ b/src/plugin-sdk/test-helpers/provider-wizard-contract-suites.ts @@ -1,3 +1,6 @@ +/** + * Contract suites for provider setup wizard choices, options, and model pickers. + */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderAuthMethod } from "../plugin-entry.js"; import type { ProviderPlugin } from "../provider-model-shared.js"; diff --git a/src/plugin-sdk/test-helpers/public-artifacts.ts b/src/plugin-sdk/test-helpers/public-artifacts.ts index 645bfac4f8d6..6bc458097926 100644 --- a/src/plugin-sdk/test-helpers/public-artifacts.ts +++ b/src/plugin-sdk/test-helpers/public-artifacts.ts @@ -1,3 +1,6 @@ +/** + * Shared public artifact basename helpers for bundled plugin contracts. + */ import { uniqueStrings } from "../../../packages/normalization-core/src/string-normalization.js"; import { assertUniqueValues, diff --git a/src/plugin-sdk/test-helpers/sandbox-fixtures.ts b/src/plugin-sdk/test-helpers/sandbox-fixtures.ts index df15199c45f0..c3b8d775c3d8 100644 --- a/src/plugin-sdk/test-helpers/sandbox-fixtures.ts +++ b/src/plugin-sdk/test-helpers/sandbox-fixtures.ts @@ -1,3 +1,6 @@ +/** + * Shared sandbox backend fixtures for plugin SDK tests. + */ import type { SandboxBrowserConfig, SandboxPruneConfig, diff --git a/src/plugin-sdk/test-helpers/send-config.ts b/src/plugin-sdk/test-helpers/send-config.ts index 61c7e126b12e..6406f90f756b 100644 --- a/src/plugin-sdk/test-helpers/send-config.ts +++ b/src/plugin-sdk/test-helpers/send-config.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for channel send config derivation. + */ import { expect } from "vitest"; type MockFn = (...args: never[]) => unknown; diff --git a/src/plugin-sdk/test-helpers/start-account-context.ts b/src/plugin-sdk/test-helpers/start-account-context.ts index 4155a695fc6b..1fae0736fd21 100644 --- a/src/plugin-sdk/test-helpers/start-account-context.ts +++ b/src/plugin-sdk/test-helpers/start-account-context.ts @@ -1,3 +1,6 @@ +/** + * Test helper for constructing a channel account startup context. + */ import { vi } from "vitest"; import { createRuntimeEnv } from "../testing.js"; import type { @@ -7,6 +10,7 @@ import type { RuntimeEnv, } from "../testing.js"; +/** Creates a minimal ChannelGatewayContext with mutable status for startAccount tests. */ export function createStartAccountContext(params: { account: TAccount; abortSignal?: AbortSignal; diff --git a/src/plugin-sdk/test-helpers/start-account-lifecycle.ts b/src/plugin-sdk/test-helpers/start-account-lifecycle.ts index d6e05dceb239..3e3c9e5b12da 100644 --- a/src/plugin-sdk/test-helpers/start-account-lifecycle.ts +++ b/src/plugin-sdk/test-helpers/start-account-lifecycle.ts @@ -1,3 +1,6 @@ +/** + * Shared assertions for channel account startup lifecycle behavior. + */ import { expect, vi } from "vitest"; import type { ChannelAccountSnapshot, ChannelGatewayContext } from "../testing.js"; import { createStartAccountContext } from "./start-account-context.js"; diff --git a/src/plugin-sdk/test-helpers/status-issues.ts b/src/plugin-sdk/test-helpers/status-issues.ts index 7de3c6bcd550..c1eb7b9be087 100644 --- a/src/plugin-sdk/test-helpers/status-issues.ts +++ b/src/plugin-sdk/test-helpers/status-issues.ts @@ -1,5 +1,9 @@ +/** + * Shared assertions for channel status issue contract tests. + */ import { expect } from "vitest"; +/** Verifies that an open-DM policy issue is reported as a config issue. */ export function expectOpenDmPolicyConfigIssue(params: { collectIssues: (accounts: TAccount[]) => Array<{ kind?: string }>; account: TAccount; diff --git a/src/plugin-sdk/test-helpers/stream-hooks.ts b/src/plugin-sdk/test-helpers/stream-hooks.ts index b3b3154d036e..2dd66203858c 100644 --- a/src/plugin-sdk/test-helpers/stream-hooks.ts +++ b/src/plugin-sdk/test-helpers/stream-hooks.ts @@ -1,3 +1,6 @@ +/** + * Stream hook test helpers for capturing provider thinking config. + */ import type { StreamFn } from "../../agents/runtime/index.js"; export function createCapturedThinkingConfigStream() { diff --git a/src/plugin-sdk/test-helpers/string-utils.ts b/src/plugin-sdk/test-helpers/string-utils.ts index 8ca6f903f15e..6c8bd6b3b313 100644 --- a/src/plugin-sdk/test-helpers/string-utils.ts +++ b/src/plugin-sdk/test-helpers/string-utils.ts @@ -1,5 +1,9 @@ +/** + * Shared string helpers for plugin SDK contract tests. + */ import { sortUniqueStrings } from "../../../packages/normalization-core/src/string-normalization.js"; +/** Sorts and deduplicates string values for stable contract assertions. */ export function uniqueSortedStrings(values: readonly string[]) { return sortUniqueStrings(values); } diff --git a/src/plugin-sdk/test-helpers/unified-model-catalog-contract.ts b/src/plugin-sdk/test-helpers/unified-model-catalog-contract.ts index 464a06c2e2c6..ea90c0f44be7 100644 --- a/src/plugin-sdk/test-helpers/unified-model-catalog-contract.ts +++ b/src/plugin-sdk/test-helpers/unified-model-catalog-contract.ts @@ -1,3 +1,6 @@ +/** + * Assertions for unified model catalog provider contract tests. + */ import { expect } from "vitest"; import type { OpenClawPluginApi, @@ -11,6 +14,7 @@ type RegistrablePlugin = { register(api: OpenClawPluginApi): void; }; +/** Verifies catalog rows are normalized and owned by the expected provider/kind. */ export function expectUnifiedModelCatalogEntries( rows: readonly UnifiedModelCatalogEntry[] | null | undefined, params: { @@ -32,6 +36,7 @@ export function expectUnifiedModelCatalogEntries( } } +/** Registers a plugin and returns the matching unified model catalog provider. */ export function expectUnifiedModelCatalogProviderRegistration(params: { plugin: RegistrablePlugin; pluginId?: string; diff --git a/src/plugin-sdk/test-helpers/web-fetch-provider-contract.ts b/src/plugin-sdk/test-helpers/web-fetch-provider-contract.ts index 7ceba750975b..800ae86615e2 100644 --- a/src/plugin-sdk/test-helpers/web-fetch-provider-contract.ts +++ b/src/plugin-sdk/test-helpers/web-fetch-provider-contract.ts @@ -1,3 +1,6 @@ +/** + * Contract suite for bundled web fetch provider registration and runtime behavior. + */ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, @@ -18,6 +21,7 @@ function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unkno return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; } +/** Installs web fetch provider contract tests for all providers owned by one plugin. */ export function describeWebFetchProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) diff --git a/src/plugin-sdk/test-helpers/web-search-provider-contract.ts b/src/plugin-sdk/test-helpers/web-search-provider-contract.ts index 746298d6d2d3..a5bd37cc8a15 100644 --- a/src/plugin-sdk/test-helpers/web-search-provider-contract.ts +++ b/src/plugin-sdk/test-helpers/web-search-provider-contract.ts @@ -1,3 +1,6 @@ +/** + * Contract suite for bundled web search provider registration and runtime behavior. + */ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, diff --git a/src/plugin-sdk/text-chunking.test.ts b/src/plugin-sdk/text-chunking.test.ts index 5c9de04a50a9..1707777aced5 100644 --- a/src/plugin-sdk/text-chunking.test.ts +++ b/src/plugin-sdk/text-chunking.test.ts @@ -1,3 +1,6 @@ +/** + * Tests text and Markdown chunking helpers exported by the plugin SDK. + */ import { describe, expect, it } from "vitest"; import { chunkTextForOutbound } from "./text-chunking.js"; diff --git a/src/plugin-sdk/text-chunking.ts b/src/plugin-sdk/text-chunking.ts index 79b62e5ca886..cdb8dc527e3e 100644 --- a/src/plugin-sdk/text-chunking.ts +++ b/src/plugin-sdk/text-chunking.ts @@ -1,6 +1,10 @@ import { chunkTextByBreakResolver } from "../shared/text-chunking.js"; -/** Chunk outbound text while preferring newline boundaries over spaces. */ +/** + * Splits outbound channel text into chunks no longer than the requested limit. + * Newline boundaries win over spaces; text without usable separators falls back + * to a hard character split so channel senders always receive bounded strings. + */ export function chunkTextForOutbound(text: string, limit: number): string[] { return chunkTextByBreakResolver(text, limit, (window) => { const lastNewline = window.lastIndexOf("\n"); @@ -9,6 +13,7 @@ export function chunkTextForOutbound(text: string, limit: number): string[] { }); } +/** Markdown IR parsing and slicing primitives for plugin-owned renderers. */ export { chunkMarkdownIR, markdownToIR, @@ -21,10 +26,12 @@ export { type MarkdownStyleSpan, type MarkdownTableMeta, } from "../../packages/markdown-core/src/ir.js"; +/** Render-size-aware Markdown chunking for channel payload limits. */ export { renderMarkdownIRChunksWithinLimit, type RenderMarkdownIRChunksWithinLimitOptions, } from "../../packages/markdown-core/src/render-aware-chunking.js"; +/** Marker-based Markdown rendering hooks for channel-specific formatting. */ export { renderMarkdownWithMarkers, type RenderLink, @@ -32,7 +39,9 @@ export { type RenderStyleMap, type RenderStyleMarker, } from "../../packages/markdown-core/src/render.js"; +/** Markdown table conversion helper shared by text-only channel renderers. */ export { convertMarkdownTables } from "../../packages/markdown-core/src/tables.js"; +/** Assistant-visible text sanitizers for removing internal scaffolding before delivery. */ export { sanitizeAssistantVisibleText, sanitizeAssistantVisibleTextWithOptions, @@ -41,19 +50,26 @@ export { stripToolCallXmlTags, type AssistantVisibleTextSanitizerProfile, } from "../shared/text/assistant-visible-text.js"; +/** File-reference detection helpers for avoiding accidental autolinks. */ export { FILE_REF_EXTENSIONS_WITH_TLD, isAutoLinkedFileRef, } from "../shared/text/auto-linked-file-ref.js"; +/** Code-region helpers for markdown-aware text transformations. */ export { findCodeRegions, isInsideCode, type CodeRegion } from "../shared/text/code-regions.js"; +/** Reasoning-tag stripping helpers for public channel output. */ export { stripReasoningTagsFromText, type ReasoningTagMode, type ReasoningTagTrim, } from "../shared/text/reasoning-tags.js"; +/** Plain-text markdown stripper for transports without markdown support. */ export { stripMarkdown } from "../shared/text/strip-markdown.js"; +/** Terminal-safe text sanitizer for CLI-facing plugin output. */ export { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +/** System-message marker helpers for preserving generated status lines. */ export { SYSTEM_MARK, hasSystemMark, prefixSystemMessage } from "../infra/system-message.ts"; +/** Inline directive stripping helpers for display and delivery boundaries. */ export { stripInlineDirectiveTagsForDelivery, stripInlineDirectiveTagsForDisplay, @@ -61,4 +77,5 @@ export { type DisplayMessageWithContent, type InlineDirectiveParseResult, } from "../utils/directive-tags.js"; +/** Generic item chunker for plugin payload planning. */ export { chunkItems } from "../utils/chunk-items.js"; diff --git a/src/plugin-sdk/thread-aware-outbound-session-route.test.ts b/src/plugin-sdk/thread-aware-outbound-session-route.test.ts index 2619c55efeb8..7cfe12780249 100644 --- a/src/plugin-sdk/thread-aware-outbound-session-route.test.ts +++ b/src/plugin-sdk/thread-aware-outbound-session-route.test.ts @@ -1,3 +1,6 @@ +/** + * Tests thread-aware outbound session route helpers exposed by the SDK. + */ import { describe, expect, it } from "vitest"; import { buildThreadAwareOutboundSessionRoute, diff --git a/src/plugin-sdk/thread-bindings-session-runtime.ts b/src/plugin-sdk/thread-bindings-session-runtime.ts index cee0356c8b3b..79915f22e6f1 100644 --- a/src/plugin-sdk/thread-bindings-session-runtime.ts +++ b/src/plugin-sdk/thread-bindings-session-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for thread binding lifecycle and session binding adapters. + */ export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; export { resolveThreadBindingLifecycle, diff --git a/src/plugin-sdk/time-runtime.ts b/src/plugin-sdk/time-runtime.ts index 527d81046d1b..443d765b9938 100644 --- a/src/plugin-sdk/time-runtime.ts +++ b/src/plugin-sdk/time-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for timezone resolution and timestamp formatting. + */ export { formatUtcTimestamp, formatZonedTimestamp, diff --git a/src/plugin-sdk/tool-payload.ts b/src/plugin-sdk/tool-payload.ts index 25ef3cb183f7..c87e7bfff6ea 100644 --- a/src/plugin-sdk/tool-payload.ts +++ b/src/plugin-sdk/tool-payload.ts @@ -3,19 +3,29 @@ import { stripPlainTextToolCallBlocks as stripRepairToolCallBlocks, } from "../../packages/tool-call-repair/src/index.js"; +/** Plugin-facing plain-text tool call block with source offsets for repair. */ export type PlainTextToolCallBlock = { + /** Parsed JSON arguments object. */ arguments: Record; + /** Exclusive end offset of the parsed block. */ end: number; + /** Tool name parsed from the standalone block. */ name: string; + /** Original text slice that produced this block. */ raw: string; + /** Inclusive start offset of the parsed block. */ start: number; }; +/** Plugin-facing parser options for standalone plain-text tool calls. */ export type PlainTextToolCallParseOptions = { + /** Optional allowlist of tool names that may be accepted. */ allowedToolNames?: Iterable; + /** Maximum JSON payload size accepted for one parsed call. */ maxPayloadBytes?: number; }; +/** Parses a message made only of standalone plain-text tool call blocks. */ export function parseStandalonePlainTextToolCallBlocks( text: string, options?: PlainTextToolCallParseOptions, @@ -23,6 +33,7 @@ export function parseStandalonePlainTextToolCallBlocks( return parseStandaloneRepairToolCallBlocks(text, options); } +/** Removes full-line standalone plain-text tool call blocks from visible text. */ export function stripPlainTextToolCallBlocks(text: string): string { return stripRepairToolCallBlocks(text); } @@ -33,7 +44,9 @@ type ToolPayloadTextBlock = { }; export type ToolPayloadCarrier = { + /** Structured payload preferred over content text when present. */ details?: unknown; + /** Provider/tool content blocks or fallback payload. */ content?: unknown; }; @@ -54,6 +67,7 @@ export function extractToolPayload(result: ToolPayloadCarrier | null | undefined if (!result) { return undefined; } + // Structured details are authoritative; content text is only a compatibility fallback. if (result.details !== undefined) { return result.details; } diff --git a/src/plugin-sdk/tool-plugin.test.ts b/src/plugin-sdk/tool-plugin.test.ts index 59509d30d5c4..4e03ac3c6fda 100644 --- a/src/plugin-sdk/tool-plugin.test.ts +++ b/src/plugin-sdk/tool-plugin.test.ts @@ -1,3 +1,6 @@ +/** + * Tests tool plugin schema helpers and SDK tool registration contracts. + */ import { Type } from "typebox"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { createCapturedPluginRegistration } from "../plugins/captured-registration.js"; diff --git a/src/plugin-sdk/tool-plugin.ts b/src/plugin-sdk/tool-plugin.ts index 0d51e7489262..4733bfa07960 100644 --- a/src/plugin-sdk/tool-plugin.ts +++ b/src/plugin-sdk/tool-plugin.ts @@ -16,9 +16,13 @@ const EMPTY_TOOL_PLUGIN_CONFIG_SCHEMA = Type.Object({}, { additionalProperties: export const toolPluginMetadataSymbol = Symbol.for("openclaw.plugin-sdk.tool-plugin.metadata"); export type ToolPluginExecutionContext = { + /** Plugin runtime API for tool implementations that need OpenClaw services. */ api: OpenClawPluginApi; + /** Abort signal for the current tool call. */ signal?: AbortSignal; + /** Stable id of the current model tool call. */ toolCallId: string; + /** Optional progress callback for streaming tool status updates. */ onUpdate?: AgentToolUpdateCallback; }; @@ -31,16 +35,24 @@ type ToolPluginToolFactory = ( ) => DefinedToolPluginTool; export type ToolPluginFactoryContext = { + /** Plugin runtime API passed to context-sensitive tool factories. */ api: OpenClawPluginApi; + /** Resolved plugin config typed from the declared config schema. */ config: TConfig; + /** Runtime tool context, including sandbox/capability information. */ toolContext: OpenClawPluginToolContext; }; type ToolPluginToolDefinitionBase = { + /** Model-facing tool name. */ name: string; + /** Human-facing label; defaults to `name`. */ label?: string; + /** Model-facing tool description. */ description: string; + /** TypeBox parameter schema used for runtime validation and metadata. */ parameters: TParamsSchema; + /** Register as optional so runtimes may omit it when unsupported. */ optional?: boolean; }; @@ -50,6 +62,7 @@ export type ToolPluginToolDefinition< > = ToolPluginToolDefinitionBase & ( | { + /** Execute one concrete tool call and return either plain text or JSON-serializable data. */ execute: ( params: Static, config: TConfig, @@ -58,6 +71,7 @@ export type ToolPluginToolDefinition< factory?: never; } | { + /** Build runtime-specific tool definitions without losing static manifest metadata. */ factory: ( context: ToolPluginFactoryContext, ) => AnyAgentTool | AnyAgentTool[] | null | undefined; @@ -95,11 +109,17 @@ export type ToolPluginMetadata = { }; export type DefineToolPluginOptions = { + /** Stable plugin id used in config, manifests, and generated metadata. */ id: string; + /** Human-facing plugin name. */ name: string; + /** Human/model-facing plugin description. */ description: string; + /** Manifest activation rule; defaults to startup activation. */ activation?: PluginManifestActivation; + /** Optional TypeBox config schema; omitted means a strict empty object config. */ configSchema?: TConfigSchema; + /** Declares static tool metadata and either execute handlers or runtime factories. */ tools: ( tool: ToolPluginToolFactory>, ) => readonly DefinedToolPluginTool[]; diff --git a/src/plugin-sdk/tool-send.ts b/src/plugin-sdk/tool-send.ts index e6e1ed4e600e..6cc4e69bea80 100644 --- a/src/plugin-sdk/tool-send.ts +++ b/src/plugin-sdk/tool-send.ts @@ -4,9 +4,18 @@ export type { ChannelToolSend } from "../channels/plugins/types.public.js"; /** Extract the canonical send target fields from tool arguments when the action matches. */ export function extractToolSend( + /** Raw model tool arguments supplied to a channel action. */ args: Record, + /** Action name that should be treated as a send action. */ expectedAction = "sendMessage", -): { to: string; accountId?: string; threadId?: string } | null { +): { + /** Canonical destination id used by core send routing. */ + to: string; + /** Optional channel account/profile id when the action includes one. */ + accountId?: string; + /** Optional thread/topic id, normalized to string for channel send adapters. */ + threadId?: string; +} | null { const action = readStringValue(args.action)?.trim() ?? ""; if (action !== expectedAction) { return null; diff --git a/src/plugin-sdk/transcripts.ts b/src/plugin-sdk/transcripts.ts index 0428a872a471..64bf00aba212 100644 --- a/src/plugin-sdk/transcripts.ts +++ b/src/plugin-sdk/transcripts.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for transcript source provider types and registry lookup. + */ export type { TranscriptImportRequest, TranscriptParticipant, diff --git a/src/plugin-sdk/transport-ready-runtime.ts b/src/plugin-sdk/transport-ready-runtime.ts index 176f762a3115..7c8d03f8ed7a 100644 --- a/src/plugin-sdk/transport-ready-runtime.ts +++ b/src/plugin-sdk/transport-ready-runtime.ts @@ -1,3 +1,4 @@ -// Transport readiness wait helper for channel plugins. - +/** + * Lazy runtime SDK subpath for waiting until an outbound transport is ready. + */ export { waitForTransportReady } from "../infra/transport-ready.js"; diff --git a/src/plugin-sdk/tts-runtime.ts b/src/plugin-sdk/tts-runtime.ts index 2ec5e4f8c0b7..68dec3417b9c 100644 --- a/src/plugin-sdk/tts-runtime.ts +++ b/src/plugin-sdk/tts-runtime.ts @@ -5,8 +5,11 @@ export { TtsProviderSchema, } from "../config/zod-schema.core.js"; +/** Compatibility no-op retained for callers that prewarm facade runtimes generically. */ export function prewarmTtsRuntimeFacade(): void {} +// TTS runtime helpers are owned by speech-core; this SDK facade stays as a thin +// export barrel so public imports do not depend on bundled plugin internals. export { buildTtsSystemPromptHint, getLastTtsAttempt, diff --git a/src/plugin-sdk/types.ts b/src/plugin-sdk/types.ts index 59199d86c14b..7a5a51ee0d57 100644 --- a/src/plugin-sdk/types.ts +++ b/src/plugin-sdk/types.ts @@ -1 +1,4 @@ +/** + * Public SDK type barrel for plugin hook contracts. + */ export type * from "../plugins/hook-types.js"; diff --git a/src/plugin-sdk/video-generation-runtime.ts b/src/plugin-sdk/video-generation-runtime.ts index 53c1adde7c53..ae4b7c8d22c8 100644 --- a/src/plugin-sdk/video-generation-runtime.ts +++ b/src/plugin-sdk/video-generation-runtime.ts @@ -1,3 +1,6 @@ +/** + * Runtime SDK subpath for video generation provider access. + */ export { generateVideo, listRuntimeVideoGenerationProviders, diff --git a/src/plugin-sdk/web-content-extractor.ts b/src/plugin-sdk/web-content-extractor.ts index 3c45027c5c41..86492d2b4aa3 100644 --- a/src/plugin-sdk/web-content-extractor.ts +++ b/src/plugin-sdk/web-content-extractor.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for web content extractor plugin types and HTML cleanup helpers. + */ export type { WebContentExtractionRequest, WebContentExtractionResult, diff --git a/src/plugin-sdk/web-media.ts b/src/plugin-sdk/web-media.ts index 2e7180cd3b01..e53209d702de 100644 --- a/src/plugin-sdk/web-media.ts +++ b/src/plugin-sdk/web-media.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for loading and optimizing local or remote web media. + */ export { getDefaultLocalRoots, LocalMediaAccessError, diff --git a/src/plugin-sdk/webhook-ingress.ts b/src/plugin-sdk/webhook-ingress.ts index 8e336db9996e..8d9ffbf99c7d 100644 --- a/src/plugin-sdk/webhook-ingress.ts +++ b/src/plugin-sdk/webhook-ingress.ts @@ -1,3 +1,6 @@ +/** + * Public SDK subpath for webhook ingress guards, targets, and request helpers. + */ export { createBoundedCounter, createFixedWindowRateLimiter, diff --git a/src/plugin-sdk/webhook-memory-guards.test.ts b/src/plugin-sdk/webhook-memory-guards.test.ts index f241aee8491c..3223049e295e 100644 --- a/src/plugin-sdk/webhook-memory-guards.test.ts +++ b/src/plugin-sdk/webhook-memory-guards.test.ts @@ -1,3 +1,6 @@ +/** + * Tests webhook memory guard counters, rate limits, and anomaly tracking. + */ import { describe, expect, it } from "vitest"; import { createBoundedCounter, diff --git a/src/plugin-sdk/webhook-memory-guards.ts b/src/plugin-sdk/webhook-memory-guards.ts index c25baae37347..9927698792b1 100644 --- a/src/plugin-sdk/webhook-memory-guards.ts +++ b/src/plugin-sdk/webhook-memory-guards.ts @@ -11,49 +11,73 @@ type CounterState = { updatedAtMs: number; }; +/** In-memory fixed-window limiter used by webhook ingress handlers. */ export type FixedWindowRateLimiter = { + /** Return true once the key exceeds its allowed request count in the current window. */ isRateLimited: (key: string, nowMs?: number) => boolean; + /** Number of tracked keys currently retained in memory. */ size: () => number; + /** Drop all tracked keys and reset pruning state. */ clear: () => void; }; +/** Bounded keyed counter for sampled webhook anomaly tracking. */ export type BoundedCounter = { + /** Increment one key and return its current count, or zero for empty keys. */ increment: (key: string, nowMs?: number) => number; + /** Number of tracked keys currently retained in memory. */ size: () => number; + /** Drop all tracked keys and reset pruning state. */ clear: () => void; }; +/** Default webhook ingress rate-limit settings for plugin monitors. */ export const WEBHOOK_RATE_LIMIT_DEFAULTS = Object.freeze({ windowMs: 60_000, maxRequests: 120, maxTrackedKeys: 4_096, }); +/** Default cardinality and sampling settings for webhook anomaly counters. */ export const WEBHOOK_ANOMALY_COUNTER_DEFAULTS = Object.freeze({ maxTrackedKeys: 4_096, ttlMs: 6 * 60 * 60_000, logEvery: 25, }); +/** HTTP status codes counted as anomalous webhook request outcomes. */ export const WEBHOOK_ANOMALY_STATUS_CODES = Object.freeze([400, 401, 408, 413, 415, 429]); +/** Records repeated webhook failures and exposes bounded in-memory state controls. */ export type WebhookAnomalyTracker = { + /** Count one tracked status for a key; returns zero when the status/key is ignored. */ record: (params: { + /** Stable anomaly key, typically route plus sender or remote identity. */ key: string; + /** HTTP status to count when it is in the tracked status-code set. */ statusCode: number; + /** Build the sampled log message from the current key count. */ message: (count: number) => string; + /** Optional log sink invoked for the first hit and every sampled repeat. */ log?: (message: string) => void; + /** Clock override for deterministic tests. */ nowMs?: number; }) => number; + /** Number of tracked anomaly keys currently retained in memory. */ size: () => number; + /** Drop all tracked anomaly keys and reset pruning state. */ clear: () => void; }; /** Create a simple fixed-window rate limiter for in-memory webhook protection. */ export function createFixedWindowRateLimiter(options: { + /** Duration of one fixed window in milliseconds. */ windowMs: number; + /** Maximum accepted requests per key during one window. */ maxRequests: number; + /** Maximum number of keys retained before oldest entries are pruned. */ maxTrackedKeys: number; + /** Optional interval for expired-window pruning. Defaults to `windowMs`. */ pruneIntervalMs?: number; }): FixedWindowRateLimiter { const windowMs = resolveWebhookIntegerOption( @@ -105,12 +129,15 @@ export function createFixedWindowRateLimiter(options: { const existing = state.get(key); if (!existing || nowMs - existing.windowStartMs >= windowMs) { touch(key, { count: 1, windowStartMs: nowMs }); + // Bound key cardinality after accepting the new key so high-cardinality webhook traffic + // cannot grow this pre-auth limiter without limit. pruneMapToMaxSize(state, maxTrackedKeys); return false; } const nextCount = existing.count + 1; touch(key, { count: nextCount, windowStartMs: existing.windowStartMs }); + // Refreshing the key before pruning keeps active keys newer than stale one-off probes. pruneMapToMaxSize(state, maxTrackedKeys); return nextCount > maxRequests; }, @@ -124,8 +151,11 @@ export function createFixedWindowRateLimiter(options: { /** Count keyed events in memory with optional TTL pruning and bounded cardinality. */ export function createBoundedCounter(options: { + /** Maximum number of keys retained before oldest entries are pruned. */ maxTrackedKeys: number; + /** Optional key TTL in milliseconds; zero disables TTL expiry. */ ttlMs?: number; + /** Optional interval for TTL pruning. */ pruneIntervalMs?: number; }): BoundedCounter { const maxTrackedKeys = resolveWebhookIntegerOption( @@ -174,6 +204,7 @@ export function createBoundedCounter(options: { const baseCount = existing && !isExpired(existing, nowMs) ? existing.count : 0; const nextCount = baseCount + 1; touch(key, { count: nextCount, updatedAtMs: nowMs }); + // Counters are diagnostic only; prefer bounded memory over retaining every anomaly key. pruneMapToMaxSize(counters, maxTrackedKeys); return nextCount; }, @@ -187,9 +218,13 @@ export function createBoundedCounter(options: { /** Track repeated webhook failures and emit sampled logs for suspicious request patterns. */ export function createWebhookAnomalyTracker(options?: { + /** Maximum number of anomaly keys retained before oldest entries are pruned. */ maxTrackedKeys?: number; + /** Key TTL in milliseconds; zero disables TTL expiry. */ ttlMs?: number; + /** Log every Nth repeat after the first hit. */ logEvery?: number; + /** HTTP status codes that should be counted as anomalies. */ trackedStatusCodes?: readonly number[]; }): WebhookAnomalyTracker { const maxTrackedKeys = resolveWebhookIntegerOption( @@ -217,6 +252,7 @@ export function createWebhookAnomalyTracker(options?: { } const next = counter.increment(key, nowMs); if (log && (next === 1 || next % logEvery === 0)) { + // Log the first hit for visibility, then sample repeated failures to avoid noisy bursts. log(message(next)); } return next; diff --git a/src/plugin-sdk/webhook-numeric-options.ts b/src/plugin-sdk/webhook-numeric-options.ts index 98e49a815862..c8226d9adfa8 100644 --- a/src/plugin-sdk/webhook-numeric-options.ts +++ b/src/plugin-sdk/webhook-numeric-options.ts @@ -1,3 +1,4 @@ +/** Resolves webhook numeric options to finite integers with a minimum bound. */ export function resolveWebhookIntegerOption( value: number | undefined, fallback: number, diff --git a/src/plugin-sdk/webhook-path.ts b/src/plugin-sdk/webhook-path.ts index a3db6d20310b..b76d8377e0bb 100644 --- a/src/plugin-sdk/webhook-path.ts +++ b/src/plugin-sdk/webhook-path.ts @@ -3,7 +3,13 @@ * `openclaw/plugin-sdk/webhook-ingress` instead. */ -/** @deprecated Import from `openclaw/plugin-sdk/webhook-ingress` instead. */ +/** + * Normalizes plugin webhook paths to an absolute path without a trailing slash. + * Empty values resolve to `/` so route registration and request matching use the + * same canonical key. + * + * @deprecated Import from `openclaw/plugin-sdk/webhook-ingress` instead. + */ export function normalizeWebhookPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -16,7 +22,12 @@ export function normalizeWebhookPath(raw: string): string { return withSlash; } -/** @deprecated Import from `openclaw/plugin-sdk/webhook-ingress` instead. */ +/** + * Resolves a webhook path from explicit path config, then URL pathname, then + * caller default. Invalid webhook URLs resolve to `null` instead of guessing. + * + * @deprecated Import from `openclaw/plugin-sdk/webhook-ingress` instead. + */ export function resolveWebhookPath(params: { webhookPath?: string; webhookUrl?: string; diff --git a/src/plugin-sdk/webhook-request-guards.test.ts b/src/plugin-sdk/webhook-request-guards.test.ts index cd5ecfadb9f7..55e9422fa881 100644 --- a/src/plugin-sdk/webhook-request-guards.test.ts +++ b/src/plugin-sdk/webhook-request-guards.test.ts @@ -1,3 +1,6 @@ +/** + * Tests webhook request guard body parsing and rejection behavior. + */ import { EventEmitter } from "node:events"; import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index 3f869dbf3bd0..f23b47de0df6 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -11,6 +11,7 @@ import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; import { resolveWebhookIntegerOption } from "./webhook-numeric-options.js"; +/** Body-read profile for webhook payload limits before or after authentication. */ export type WebhookBodyReadProfile = "pre-auth" | "post-auth"; export { @@ -21,6 +22,7 @@ export { requestBodyErrorToText, } from "../infra/http-body.js"; +/** Default webhook body size/time limits for pre-auth and post-auth reads. */ export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({ preAuth: { maxBytes: 64 * 1024, @@ -32,15 +34,21 @@ export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({ }, }); +/** Default in-flight concurrency limits for webhook request pipelines. */ export const WEBHOOK_IN_FLIGHT_DEFAULTS = Object.freeze({ maxInFlightPerKey: 8, maxTrackedKeys: 4_096, }); +/** Per-key in-flight limiter used to bound concurrent webhook handlers. */ export type WebhookInFlightLimiter = { + /** Acquire one in-flight slot for a key, returning false when the key is at capacity. */ tryAcquire: (key: string) => boolean; + /** Release one slot for a key after the handler completes. */ release: (key: string) => void; + /** Number of keys with retained in-flight state. */ size: () => number; + /** Drop all retained in-flight state. */ clear: () => void; }; @@ -94,7 +102,9 @@ function respondWebhookBodyReadError(params: { /** Create an in-memory limiter that caps concurrent webhook handlers per key. */ export function createWebhookInFlightLimiter(options?: { + /** Maximum concurrent handlers allowed for one key. */ maxInFlightPerKey?: number; + /** Maximum number of keys retained before oldest entries are pruned. */ maxTrackedKeys?: number; }): WebhookInFlightLimiter { const maxInFlightPerKey = resolveWebhookIntegerOption( @@ -119,6 +129,8 @@ export function createWebhookInFlightLimiter(options?: { return false; } active.set(key, current + 1); + // Keep the limiter bounded even under key-spray attacks; pruning oldest keys may allow + // a stale key to reset, but avoids unbounded memory growth on pre-auth webhook paths. pruneMapToMaxSize(active, maxTrackedKeys); return true; }, @@ -153,12 +165,19 @@ export function isJsonContentType(value: string | string[] | undefined): boolean /** Apply method, rate-limit, and content-type guards before a webhook handler reads the body. */ export function applyBasicWebhookRequestGuards(params: { + /** Incoming request to validate before body reads or handler dispatch. */ req: IncomingMessage; + /** Response used for method, rate-limit, or content-type rejections. */ res: ServerResponse; + /** Allowed HTTP methods; empty or omitted disables the method guard. */ allowMethods?: readonly string[]; + /** Optional fixed-window limiter for pre-body request throttling. */ rateLimiter?: FixedWindowRateLimiter; + /** Key passed to the rate limiter when throttling is enabled. */ rateLimitKey?: string; + /** Clock override for deterministic limiter tests. */ nowMs?: number; + /** Require JSON content type for POST requests. */ requireJsonContentType?: boolean; }): boolean { const allowMethods = params.allowMethods?.length ? params.allowMethods : null; @@ -194,16 +213,27 @@ export function applyBasicWebhookRequestGuards(params: { /** Start the shared webhook request lifecycle and return a release hook for in-flight tracking. */ export function beginWebhookRequestPipelineOrReject(params: { + /** Incoming request to validate before acquiring in-flight capacity. */ req: IncomingMessage; + /** Response used for guard or capacity rejections. */ res: ServerResponse; + /** Allowed HTTP methods; empty or omitted disables the method guard. */ allowMethods?: readonly string[]; + /** Optional fixed-window limiter for pre-body request throttling. */ rateLimiter?: FixedWindowRateLimiter; + /** Key passed to the rate limiter when throttling is enabled. */ rateLimitKey?: string; + /** Clock override for deterministic limiter tests. */ nowMs?: number; + /** Require JSON content type for POST requests. */ requireJsonContentType?: boolean; + /** Optional per-key concurrency limiter acquired after basic guards pass. */ inFlightLimiter?: WebhookInFlightLimiter; + /** Key used for in-flight concurrency tracking. */ inFlightKey?: string; + /** Status code returned when the in-flight guard rejects. */ inFlightLimitStatusCode?: number; + /** Response body returned when the in-flight guard rejects. */ inFlightLimitMessage?: string; }): { ok: true; release: () => void } | { ok: false } { if ( @@ -229,12 +259,15 @@ export function beginWebhookRequestPipelineOrReject(params: { } let released = false; + // Acquire happens after method/rate/content-type guards so rejected requests do not require + // cleanup; successful callers must run the returned release hook in a finally block. return { ok: true, release: () => { if (released) { return; } + // Pipeline cleanup may run from multiple exits; release must stay idempotent. released = true; if (inFlightLimiter && inFlightKey) { inFlightLimiter.release(inFlightKey); @@ -245,11 +278,17 @@ export function beginWebhookRequestPipelineOrReject(params: { /** Read a webhook request body with bounded size/time limits and translate failures into responses. */ export async function readWebhookBodyOrReject(params: { + /** Incoming request body stream to read. */ req: IncomingMessage; + /** Response used for body size, timeout, close, or parse failures. */ res: ServerResponse; + /** Optional maximum body size override in bytes. */ maxBytes?: number; + /** Optional body read timeout override in milliseconds. */ timeoutMs?: number; + /** Default limit profile to use when explicit limits are omitted. */ profile?: WebhookBodyReadProfile; + /** Response body for invalid request bodies. */ invalidBodyMessage?: string; }): Promise<{ ok: true; value: string } | { ok: false }> { const limits = resolveWebhookBodyReadLimits({ @@ -279,12 +318,19 @@ export async function readWebhookBodyOrReject(params: { /** Read and parse a JSON webhook body, rejecting malformed or oversized payloads consistently. */ export async function readJsonWebhookBodyOrReject(params: { + /** Incoming request body stream to read and parse as JSON. */ req: IncomingMessage; + /** Response used for JSON parse, body size, timeout, or close failures. */ res: ServerResponse; + /** Optional maximum body size override in bytes. */ maxBytes?: number; + /** Optional body read timeout override in milliseconds. */ timeoutMs?: number; + /** Default limit profile to use when explicit limits are omitted. */ profile?: WebhookBodyReadProfile; + /** Treat an empty body as `{}` instead of rejecting it as invalid JSON. */ emptyObjectOnEmpty?: boolean; + /** Response body for malformed JSON. */ invalidJsonMessage?: string; }): Promise<{ ok: true; value: unknown } | { ok: false }> { const limits = resolveWebhookBodyReadLimits({ diff --git a/src/plugin-sdk/webhook-targets.test.ts b/src/plugin-sdk/webhook-targets.test.ts index 202194977d90..af1d575c31d3 100644 --- a/src/plugin-sdk/webhook-targets.test.ts +++ b/src/plugin-sdk/webhook-targets.test.ts @@ -1,3 +1,6 @@ +/** + * Tests webhook target registration, matching, and request pipeline helpers. + */ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/src/plugin-sdk/webhook-targets.ts b/src/plugin-sdk/webhook-targets.ts index 5aaba44490ca..3f8d0eb3368c 100644 --- a/src/plugin-sdk/webhook-targets.ts +++ b/src/plugin-sdk/webhook-targets.ts @@ -7,13 +7,19 @@ import { type WebhookInFlightLimiter, } from "./webhook-request-guards.js"; +/** Registration handle returned for one live webhook target. */ export type RegisteredWebhookTarget = { + /** Normalized target stored in the caller-owned path registry. */ target: T; + /** Idempotently remove this target and run path teardown when it was the last target. */ unregister: () => void; }; +/** Lifecycle hooks for path-level webhook target registration. */ export type RegisterWebhookTargetOptions = { + /** Called before the first target for a normalized path is stored; may return path teardown. */ onFirstPathTarget?: (params: { path: string; target: T }) => void | (() => void); + /** Called after the last target for a normalized path has been removed. */ onLastPathTargetRemoved?: (params: { path: string }) => void; }; @@ -21,6 +27,7 @@ type RegisterPluginHttpRouteParams = Parameters[ export { registerPluginHttpRoute }; +/** Plugin HTTP route options supplied when webhook paths are registered lazily. */ export type RegisterWebhookPluginRouteOptions = Omit< RegisterPluginHttpRouteParams, "path" | "fallbackPath" @@ -28,9 +35,13 @@ export type RegisterWebhookPluginRouteOptions = Omit< /** Register a webhook target and lazily install the matching plugin HTTP route on first use. */ export function registerWebhookTargetWithPluginRoute(params: { + /** Caller-owned normalized path registry shared by all targets for this plugin/runtime. */ targetsByPath: Map; + /** Target to normalize, store, and later return from the registration handle. */ target: T; + /** Plugin HTTP route configuration used when the first target for a path is registered. */ route: RegisterWebhookPluginRouteOptions; + /** Optional last-target hook forwarded to `registerWebhookTarget`. */ onLastPathTargetRemoved?: RegisterWebhookTargetOptions["onLastPathTargetRemoved"]; }): RegisteredWebhookTarget { return registerWebhookTarget(params.targetsByPath, params.target, { @@ -38,6 +49,8 @@ export function registerWebhookTargetWithPluginRoute registerPluginHttpRoute({ ...params.route, path, + // Webhook targets own this path while registered; default replacement lets + // plugin reload/setup refresh the handler without accumulating stale routes. replaceExisting: params.route.replaceExisting ?? true, }), onLastPathTargetRemoved: params.onLastPathTargetRemoved, @@ -53,6 +66,8 @@ function getPathTeardownMap(targetsByPath: Map): Map void>(); + // Teardown is scoped to the caller-owned registry map so independent plugins using the same + // path do not unregister each other's HTTP routes. pathTeardownByTargetMap.set(mapKey, created); return created; } @@ -119,18 +134,31 @@ export function resolveWebhookTargets( /** Run common webhook guards, then dispatch only when the request path resolves to live targets. */ export async function withResolvedWebhookRequestPipeline(params: { + /** Incoming HTTP request whose pathname selects the target bucket. */ req: IncomingMessage; + /** HTTP response used by guard failures before handler dispatch. */ res: ServerResponse; + /** Caller-owned target registry keyed by normalized webhook path. */ targetsByPath: Map; + /** Allowed methods for the common request guard. */ allowMethods?: readonly string[]; + /** Optional per-key fixed-window limiter shared across requests. */ rateLimiter?: FixedWindowRateLimiter; + /** Explicit rate-limit key; defaults are owned by the request guard. */ rateLimitKey?: string; + /** Clock override for deterministic limiter tests. */ nowMs?: number; + /** Require JSON content type before dispatching to the webhook handler. */ requireJsonContentType?: boolean; + /** Optional in-flight limiter to cap concurrent handling for a key. */ inFlightLimiter?: WebhookInFlightLimiter; + /** Explicit or derived key for concurrent request limiting. */ inFlightKey?: string | ((args: { req: IncomingMessage; path: string; targets: T[] }) => string); + /** Status code returned when the in-flight guard rejects. */ inFlightLimitStatusCode?: number; + /** Response body returned when the in-flight guard rejects. */ inFlightLimitMessage?: string; + /** Handler invoked only after target resolution and common guards succeed. */ handle: (args: { path: string; targets: T[] }) => Promise | boolean | void; }): Promise { const resolved = resolveWebhookTargets(params.req, params.targetsByPath); @@ -163,10 +191,13 @@ export async function withResolvedWebhookRequestPipeline(params: { await params.handle(resolved); return true; } finally { + // Release even when the handler throws; otherwise one failed webhook can pin the in-flight + // slot and permanently reject later deliveries for the same key. requestLifecycle.release(); } } +/** Result of matching a request against zero, one, or multiple webhook targets. */ export type WebhookTargetMatchResult = | { kind: "none" } | { kind: "single"; target: T } @@ -199,6 +230,8 @@ export function resolveSingleWebhookTarget( if (!isMatch(target)) { continue; } + // Stop at the second match so auth callers can reject ambiguous secrets without inspecting + // or accidentally selecting a later target. const updated = updateMatchedWebhookTarget(matched, target); if (!updated.ok) { return updated.result; @@ -229,12 +262,19 @@ export async function resolveSingleWebhookTargetAsync( /** Resolve an authorized target and send the standard unauthorized or ambiguous response on failure. */ export async function resolveWebhookTargetWithAuthOrReject(params: { + /** Candidate targets for the already-resolved webhook path. */ targets: readonly T[]; + /** HTTP response used to send unauthorized or ambiguous failures. */ res: ServerResponse; + /** Auth or routing predicate; exactly one target must match. */ isMatch: (target: T) => boolean | Promise; + /** Status code for no matching target. Defaults to 401. */ unauthorizedStatusCode?: number; + /** Response body for no matching target. */ unauthorizedMessage?: string; + /** Status code for multiple matching targets. Defaults to 401. */ ambiguousStatusCode?: number; + /** Response body for multiple matching targets. */ ambiguousMessage?: string; }): Promise { const match = await resolveSingleWebhookTargetAsync(params.targets, async (target) => @@ -245,12 +285,19 @@ export async function resolveWebhookTargetWithAuthOrReject(params: { /** Synchronous variant of webhook auth resolution for cheap in-memory match checks. */ export function resolveWebhookTargetWithAuthOrRejectSync(params: { + /** Candidate targets for the already-resolved webhook path. */ targets: readonly T[]; + /** HTTP response used to send unauthorized or ambiguous failures. */ res: ServerResponse; + /** Synchronous auth or routing predicate; exactly one target must match. */ isMatch: (target: T) => boolean; + /** Status code for no matching target. Defaults to 401. */ unauthorizedStatusCode?: number; + /** Response body for no matching target. */ unauthorizedMessage?: string; + /** Status code for multiple matching targets. Defaults to 401. */ ambiguousStatusCode?: number; + /** Response body for multiple matching targets. */ ambiguousMessage?: string; }): T | null { const match = resolveSingleWebhookTarget(params.targets, params.isMatch); diff --git a/src/plugin-sdk/windows-spawn.test.ts b/src/plugin-sdk/windows-spawn.test.ts index c76be7f7b0cd..780f41d48bf2 100644 --- a/src/plugin-sdk/windows-spawn.test.ts +++ b/src/plugin-sdk/windows-spawn.test.ts @@ -1,3 +1,6 @@ +/** + * Tests Windows spawn compatibility helpers. + */ import { writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/src/plugin-sdk/windows-spawn.ts b/src/plugin-sdk/windows-spawn.ts index 88ee6695edfa..bd095c52c531 100644 --- a/src/plugin-sdk/windows-spawn.ts +++ b/src/plugin-sdk/windows-spawn.ts @@ -6,6 +6,7 @@ import { } from "../../packages/normalization-core/src/string-coerce.js"; import { normalizeStringEntries } from "../../packages/normalization-core/src/string-normalization.js"; +/** Final execution strategy chosen for a Windows spawn command. */ export type WindowsSpawnResolution = | "direct" | "node-entrypoint" @@ -13,13 +14,20 @@ export type WindowsSpawnResolution = | "shell-fallback"; export type WindowsSpawnCandidateResolution = Exclude; + +/** Direct-spawn candidate before shell fallback policy is applied. */ export type WindowsSpawnProgramCandidate = { + /** Executable passed to child_process after wrapper resolution. */ command: string; + /** Arguments prepended before call-site argv, usually a resolved JS entrypoint. */ leadingArgv: string[]; + /** Candidate resolution path, or unresolved-wrapper when shell policy must decide. */ resolution: WindowsSpawnCandidateResolution | "unresolved-wrapper"; + /** Hide the transient Windows console for Node/exe entrypoint launches. */ windowsHide?: boolean; }; +/** Spawn program after Windows wrapper resolution and fallback policy. */ export type WindowsSpawnProgram = { command: string; leadingArgv: string[]; @@ -28,6 +36,7 @@ export type WindowsSpawnProgram = { windowsHide?: boolean; }; +/** Fully materialized child_process invocation for a resolved Windows spawn program. */ export type WindowsSpawnInvocation = { command: string; argv: string[]; @@ -36,6 +45,7 @@ export type WindowsSpawnInvocation = { windowsHide?: boolean; }; +/** Inputs used to resolve a command into a Windows-safe direct spawn program. */ export type ResolveWindowsSpawnProgramParams = { command: string; platform?: NodeJS.Platform; @@ -314,6 +324,8 @@ export function resolveWindowsSpawnProgramCandidate( }; } + // Unresolved .cmd/.bat wrappers are not passed through cmd.exe unless the + // caller explicitly accepts shell metacharacter parsing with allowShellFallback. return { command: resolvedCommand, leadingArgv: [], diff --git a/src/plugin-sdk/zod.ts b/src/plugin-sdk/zod.ts index 392f76924e71..f44a598ccf43 100644 --- a/src/plugin-sdk/zod.ts +++ b/src/plugin-sdk/zod.ts @@ -1 +1,4 @@ +/** + * Public SDK subpath that exposes the supported Zod dependency for plugin schemas. + */ export * from "zod"; diff --git a/src/plugin-state/plugin-state-store.test-helpers.ts b/src/plugin-state/plugin-state-store.test-helpers.ts index b0d2f85ec2cd..a67c43578bf5 100644 --- a/src/plugin-state/plugin-state-store.test-helpers.ts +++ b/src/plugin-state/plugin-state-store.test-helpers.ts @@ -1,5 +1,7 @@ import { seedPluginStateDatabaseEntriesForTests } from "./plugin-state-store.sqlite.js"; +// Test-only seed helpers for plugin state. Values are serialized through the +// same JSON storage path used by the production sqlite store. export type PluginStateSeedEntry = { pluginId: string; namespace: string; @@ -9,6 +11,7 @@ export type PluginStateSeedEntry = { expiresAt?: number | null; }; +/** Seeds plugin state entries for tests without opening public store handles. */ export function seedPluginStateEntriesForTests(entries: PluginStateSeedEntry[]): void { if (entries.length === 0) { return; diff --git a/src/plugin-state/plugin-state-store.ts b/src/plugin-state/plugin-state-store.ts index 92188296920e..f3244b0ea83d 100644 --- a/src/plugin-state/plugin-state-store.ts +++ b/src/plugin-state/plugin-state-store.ts @@ -21,6 +21,8 @@ import type { } from "./plugin-state-store.types.js"; import { PluginStateStoreError } from "./plugin-state-store.types.js"; +// Public plugin-state facade over the sqlite-backed store. It validates plugin +// ids, namespaces, JSON values, TTLs, and per-plugin limits before persistence. export type { OpenKeyedStoreOptions, PluginStateEntry, @@ -170,6 +172,8 @@ function assertPlainJsonValue( throw invalidInput(`plugin state object at ${path} must be a plain object`); } + // Reject accessors, symbols, sparse arrays, and non-enumerable state so stored + // values cannot execute code or round-trip differently through JSON. const descriptorEntries = Object.entries(Object.getOwnPropertyDescriptors(objectValue)); const enumerableKeys = Object.keys(objectValue); if (Object.getOwnPropertySymbols(objectValue).length > 0) { @@ -238,6 +242,8 @@ function assertConsistentOptions( existing.maxEntries !== signature.maxEntries || existing.defaultTtlMs !== signature.defaultTtlMs ) { + // A namespace is a shared storage contract. Reopening it with different + // limits would make eviction/TTL behavior depend on call order. throw invalidInput( `plugin state namespace ${namespace} for ${pluginId} was reopened with incompatible options`, "open", @@ -437,6 +443,7 @@ function createSyncKeyedStoreForPluginId( }; } +/** Opens an async plugin-state namespace for a non-core plugin id. */ export function createPluginStateKeyedStore( pluginId: string, options: OpenKeyedStoreOptions, @@ -447,6 +454,7 @@ export function createPluginStateKeyedStore( return createKeyedStoreForPluginId(pluginId, options); } +/** Opens a sync plugin-state namespace for a non-core plugin id. */ export function createPluginStateSyncKeyedStore( pluginId: string, options: OpenKeyedStoreOptions, @@ -457,23 +465,27 @@ export function createPluginStateSyncKeyedStore( return createSyncKeyedStoreForPluginId(pluginId, options); } +/** Opens an async plugin-state namespace for a trusted core owner id. */ export function createCorePluginStateKeyedStore( options: OpenKeyedStoreOptions & { ownerId: `core:${string}` }, ): PluginStateKeyedStore { return createKeyedStoreForPluginId(options.ownerId, options); } +/** Opens a sync plugin-state namespace for a trusted core owner id. */ export function createCorePluginStateSyncKeyedStore( options: OpenKeyedStoreOptions & { ownerId: `core:${string}` }, ): PluginStateSyncKeyedStore { return createSyncKeyedStoreForPluginId(options.ownerId, options); } +/** Clears plugin-state rows and option signatures for tests. */ export function clearPluginStateStoreForTests(): void { clearPluginStateDatabaseForTests(); namespaceOptionSignatures.clear(); } +/** Resets plugin-state module/database state for isolated tests. */ export function resetPluginStateStoreForTests(options: { closeDatabase?: boolean } = {}): void { if (options.closeDatabase !== false) { closePluginStateDatabase(); diff --git a/src/plugin-state/plugin-state-store.types.ts b/src/plugin-state/plugin-state-store.types.ts index 2d868b761757..82f2febb0944 100644 --- a/src/plugin-state/plugin-state-store.types.ts +++ b/src/plugin-state/plugin-state-store.types.ts @@ -1,3 +1,5 @@ +// Public plugin-state store contracts. Stores are keyed by plugin id and +// namespace, persist JSON-compatible values, and enforce per-namespace limits. export type PluginStateEntry = { key: string; value: T; @@ -5,6 +7,7 @@ export type PluginStateEntry = { expiresAt?: number; }; +/** Async plugin state API exposed to plugin runtimes. */ export type PluginStateKeyedStore = { register(key: string, value: T, opts?: { ttlMs?: number }): Promise; registerIfAbsent(key: string, value: T, opts?: { ttlMs?: number }): Promise; @@ -20,6 +23,7 @@ export type PluginStateKeyedStore = { clear(): Promise; }; +/** Sync plugin state API used by trusted core/plugin bootstrap paths. */ export type PluginStateSyncKeyedStore = { register(key: string, value: T, opts?: { ttlMs?: number }): void; registerIfAbsent(key: string, value: T, opts?: { ttlMs?: number }): boolean; @@ -35,6 +39,7 @@ export type PluginStateSyncKeyedStore = { clear(): void; }; +/** Options for opening a keyed plugin-state namespace. */ export type OpenKeyedStoreOptions = { namespace: string; maxEntries: number; @@ -72,6 +77,7 @@ export type PluginStateStoreErrorOptions = { cause?: unknown; }; +/** Typed error thrown for plugin-state validation and sqlite failures. */ export class PluginStateStoreError extends Error { readonly code: PluginStateStoreErrorCode; readonly operation: PluginStateStoreOperation; diff --git a/src/plugins/config-contract-matches.ts b/src/plugins/config-contract-matches.ts index 370dc0eb8720..64e6274369cb 100644 --- a/src/plugins/config-contract-matches.ts +++ b/src/plugins/config-contract-matches.ts @@ -3,7 +3,9 @@ import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; import { isRecord } from "../utils.js"; export type PluginConfigContractMatch = { + /** Concrete config path matched by the contract pattern. */ path: string; + /** Config value stored at the matched path. */ value: unknown; }; @@ -28,6 +30,7 @@ function parseCanonicalArrayIndex(segment: string, length: number): number | nul return index !== undefined && index < length ? index : null; } +/** Collect concrete config values that match a plugin contract path pattern. */ export function collectPluginConfigContractMatches(params: { root: unknown; pathPattern: string; @@ -42,6 +45,7 @@ export function collectPluginConfigContractMatches(params: { const nextStates: TraversalState[] = []; for (const state of states) { if (segment === "*") { + // Wildcards fan out across arrays and records so contracts can cover account maps/lists. if (Array.isArray(state.value)) { for (const [index, value] of state.value.entries()) { nextStates.push({ diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 412b9370fa58..67d8180d66a6 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -11,10 +11,13 @@ export { } from "./config-contract-matches.js"; export type PluginConfigContractMetadata = { + /** Runtime origin that supplied the contract metadata. */ origin: PluginOrigin; + /** Manifest-declared config contract paths used by secret/security/config scanners. */ configContracts: PluginManifestConfigContracts; }; +/** Resolve config contract metadata for plugin ids through the runtime registry and bundled fallback. */ export function resolvePluginConfigContractsById(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -93,6 +96,8 @@ export function resolvePluginConfigContractsById(params: { if (shouldHydrateBundledMatch) { const bundledConfigContracts = findBundledConfigContracts(pluginId); if (bundledConfigContracts) { + // Bundled metadata can carry richer contract declarations than installed registry entries; + // installed declarations still win except for bundled secret input coverage. matches.set(pluginId, { origin: fallbackBundledPluginIds.has(pluginId) ? "bundled" : existing.origin, configContracts: { diff --git a/src/plugins/config-schema.ts b/src/plugins/config-schema.ts index 6ace611d7d25..e3d3344f5996 100644 --- a/src/plugins/config-schema.ts +++ b/src/plugins/config-schema.ts @@ -119,6 +119,7 @@ function safeParseJsonSchema( }; } +/** Build a plugin config schema from JSON Schema with runtime validation/default support. */ export function buildJsonPluginConfigSchema( schema: JsonSchemaObject, options?: BuildJsonPluginConfigSchemaOptions, @@ -134,6 +135,7 @@ export function buildJsonPluginConfigSchema( }; } +/** Build a plugin config schema from Zod, exporting JSON Schema when the Zod runtime supports it. */ export function buildPluginConfigSchema( schema: ZodTypeAny, options?: BuildPluginConfigSchemaOptions, @@ -144,6 +146,7 @@ export function buildPluginConfigSchema( return { safeParse, ...(options?.uiHints ? { uiHints: options.uiHints } : {}), + // Normalize generated schema so plugin consumers see a stable draft-07-ish shape. jsonSchema: normalizeJsonSchema( schemaWithJson.toJSONSchema({ target: "draft-07", @@ -164,6 +167,7 @@ export function buildPluginConfigSchema( }; } +/** Return a schema for plugins that intentionally accept no config keys. */ export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema { return { safeParse(value: unknown): SafeParseResult { diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index f987d26e9ac6..b6a0bbe07556 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -6,6 +6,7 @@ import type { PluginManifestContracts } from "./manifest.js"; import type { PluginRecord, PluginRegistry } from "./registry.js"; import type { PluginLogger } from "./types.js"; +/** Builds the registry record shape shared by plugin loading, status, and diagnostics. */ export function createPluginRecord(params: { id: string; name?: string; @@ -50,6 +51,7 @@ export function createPluginRecord(params: { activationSource: params.activationState?.source, activationReason: params.activationState?.reason, syntheticAuthRefs: params.syntheticAuthRefs ?? [], + // Disabled records still enter the registry so status/doctor can explain why they are inactive. status: params.enabled ? "loaded" : "disabled", toolNames: [], hookNames: [], @@ -84,12 +86,14 @@ export function createPluginRecord(params: { }; } +/** Marks a discovered plugin inactive without discarding its metadata record. */ export function markPluginActivationDisabled(record: PluginRecord, reason?: string): void { record.activated = false; record.activationSource = "disabled"; record.activationReason = reason; } +/** Joins auto-enable reasons into the single registry field shown by status surfaces. */ export function formatAutoEnabledActivationReason( reasons: readonly string[] | undefined, ): string | undefined { @@ -99,6 +103,7 @@ export function formatAutoEnabledActivationReason( return reasons.join("; "); } +/** Records a loader failure in the registry, diagnostics list, and operator log consistently. */ export function recordPluginError(params: { logger: PluginLogger; registry: PluginRegistry; @@ -121,6 +126,7 @@ export function recordPluginError(params: { errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function") ? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes" : null; + // Rewrite the common removed-API failure into an actionable migration hint while preserving detail. const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText; params.logger.error(`${params.logPrefix}${displayError}`); params.record.status = "error"; @@ -137,6 +143,7 @@ export function recordPluginError(params: { }); } +/** Groups failed plugin ids by loader phase for compact startup summaries. */ export function formatPluginFailureSummary(failedPlugins: PluginRecord[]): string { const grouped = new Map, string[]>(); for (const plugin of failedPlugins) { diff --git a/src/plugins/plugin-cache-primitives.ts b/src/plugins/plugin-cache-primitives.ts index 412027eb2443..a8c98dbd09ae 100644 --- a/src/plugins/plugin-cache-primitives.ts +++ b/src/plugins/plugin-cache-primitives.ts @@ -1,7 +1,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Result shape for cache lookups that need to distinguish a miss from cached `undefined`. */ export type PluginLruCacheResult = { hit: true; value: T } | { hit: false }; +/** Small process-local LRU cache used for stable plugin metadata and loader artifacts. */ export class PluginLruCache { readonly #defaultMaxEntries: number; #maxEntries: number; @@ -32,11 +34,13 @@ export class PluginLruCache { this.#entries.clear(); } + /** Returns a cached value and refreshes its recency when present. */ get(cacheKey: string): T | undefined { const cached = this.getResult(cacheKey); return cached.hit ? cached.value : undefined; } + /** Returns a hit/miss result and promotes hits to the newest LRU position. */ getResult(cacheKey: string): PluginLruCacheResult { if (!this.#entries.has(cacheKey)) { return { hit: false }; @@ -47,6 +51,7 @@ export class PluginLruCache { return { hit: true, value: cached }; } + /** Stores a value as the newest entry and evicts oldest entries past capacity. */ set(cacheKey: string, value: T): void { if (this.#entries.has(cacheKey)) { this.#entries.delete(cacheKey); @@ -66,13 +71,16 @@ export class PluginLruCache { } } +/** Runtime cache partitioned by config object identity so request-scoped configs do not collide. */ export type ConfigScopedRuntimeCache = WeakMap>; +/** Promise loader that coalesces concurrent loads per config object and for the default scope. */ export type ConfigScopedPromiseLoader = { load(config?: OpenClawConfig): Promise; clear(): void; }; +/** Resolves a config-scoped cached value; calls without config intentionally bypass caching. */ export function resolveConfigScopedRuntimeCacheValue(params: { cache: ConfigScopedRuntimeCache; config?: OpenClawConfig; @@ -95,10 +103,12 @@ export function resolveConfigScopedRuntimeCacheValue(params: { return loaded; } +/** Encodes structured cache dimensions without separator ambiguity. */ export function createPluginCacheKey(parts: readonly unknown[]): string { return JSON.stringify(parts); } +/** Creates a config-scoped promise cache that drops rejected loads so callers can retry. */ export function createConfigScopedPromiseLoader( load: (config?: OpenClawConfig) => T | Promise, ): ConfigScopedPromiseLoader { diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index e6110bc31e84..b6e39eba116d 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -25,6 +25,10 @@ type CachedValidator = { schemaFingerprint: string; }; +/** + * JSON Schema document accepted by plugin config and SDK runtime validation. + * Boolean schemas are valid draft-style schemas and must remain accepted here. + */ export type JsonSchemaValue = JsonSchemaObject | boolean; const schemaCache = new PluginLruCache(512); @@ -152,6 +156,10 @@ function isDefaultActivatedConditionalFailure(params: { return checkSchema(originalValidator, params.originalValue) === null; } +/** + * Sanitized validation error surfaced to config diagnostics, gateway hooks, and SDK callers. + * `path`/`message` stay raw for programmatic handling; `text` is terminal-safe display text. + */ export type JsonSchemaValidationError = { path: string; message: string; @@ -317,6 +325,10 @@ function formatValidationErrors( }); } +/** + * Validate a plugin-owned value against a JSON Schema, optionally hydrating schema defaults. + * The cache key is caller-owned so repeated plugin/schema validations can reuse compiled TypeBox validators. + */ export function validateJsonSchemaValue(params: { schema: JsonSchemaValue; cacheKey: string; @@ -349,6 +361,8 @@ export function validateJsonSchemaValue(params: { defaultedValue: value, }) ) { + // Defaults can select a conditional branch that requires the defaulted property; + // keep the hydrated value when the original input was valid before hydration. return { ok: true, value }; } return { ok: false, errors: formatValidationErrors(errors) }; @@ -391,6 +405,7 @@ export function validateJsonSchemaValue(params: { defaultedValue: value, }) ) { + // Same conditional-default exception as the uncached path; cache only changes validator reuse. return { ok: true, value }; } return { ok: false, errors: formatValidationErrors(errors) }; diff --git a/src/plugins/web-provider-resolution-shared.ts b/src/plugins/web-provider-resolution-shared.ts index ecc345dc175b..0a2ad185c425 100644 --- a/src/plugins/web-provider-resolution-shared.ts +++ b/src/plugins/web-provider-resolution-shared.ts @@ -7,6 +7,7 @@ import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.j export type WebProviderContract = "webSearchProviders" | "webFetchProviders"; export type WebProviderConfigKey = "webSearch" | "webFetch"; +/** Manifest-backed plugin id candidates for a web provider family. */ export type WebProviderCandidateResolution = { pluginIds: string[] | undefined; manifestRecords?: readonly PluginManifestRecord[]; @@ -31,6 +32,7 @@ export function sortPluginProviders( providers: T[], ): T[] { @@ -75,6 +77,7 @@ function loadInstalledWebProviderManifestRecords(params: { return pluginIdSet ? records.filter((plugin) => pluginIdSet.has(plugin.id)) : records; } +/** Returns only plugin ids for manifest-declared web provider candidates. */ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: { contract: WebProviderContract; configKey: WebProviderConfigKey; @@ -87,6 +90,7 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: { return resolveManifestDeclaredWebProviderCandidates(params).pluginIds; } +/** Resolves manifest-declared web provider candidates without importing plugin runtime code. */ export function resolveManifestDeclaredWebProviderCandidates(params: { contract: WebProviderContract; configKey: WebProviderConfigKey; @@ -122,6 +126,8 @@ export function resolveManifestDeclaredWebProviderCandidates(params: { if (ids.length > 0) { return { pluginIds: ids, manifestRecords }; } + // Unscoped resolution falls back to runtime registry loading; scoped/origin-filtered + // calls must return an explicit empty candidate set instead. if (params.origin || scopedPluginIds !== undefined) { return { pluginIds: [], manifestRecords }; } @@ -143,6 +149,7 @@ function resolveBundledWebProviderCompatPluginIds(params: { .toSorted((left, right) => left.localeCompare(right)); } +/** Builds bundled-plugin activation config for provider families with legacy enablement defaults. */ export function resolveBundledWebProviderResolutionConfig(params: { contract: WebProviderContract; config?: PluginLoadOptions["config"]; @@ -176,6 +183,7 @@ export function resolveBundledWebProviderResolutionConfig(params: { }; } +/** Adds plugin ids to registry provider records, applies an optional plugin scope, then sorts. */ export function mapRegistryProviders(params: { entries: readonly { pluginId: string; provider: TProvider }[]; onlyPluginIds?: readonly string[]; diff --git a/src/process/command-queue.types.ts b/src/process/command-queue.types.ts index 3db42611362e..e46f31d45a11 100644 --- a/src/process/command-queue.types.ts +++ b/src/process/command-queue.types.ts @@ -1,3 +1,7 @@ +/** + * Public enqueue knobs shared by command-lane callers and narrower injection + * points that should not import the full queue implementation. + */ export type CommandQueueEnqueueOptions = { warnAfterMs?: number; onWait?: (waitMs: number, queuedAhead: number) => void; @@ -6,6 +10,7 @@ export type CommandQueueEnqueueOptions = { priority?: "foreground" | "normal" | "background"; }; +/** Minimal queue function contract used by code that only needs to schedule work. */ export type CommandQueueEnqueueFn = ( task: () => Promise, opts?: CommandQueueEnqueueOptions, diff --git a/src/process/kill-tree.ts b/src/process/kill-tree.ts index a56c4b91f252..7f36dee0d713 100644 --- a/src/process/kill-tree.ts +++ b/src/process/kill-tree.ts @@ -1,3 +1,8 @@ +/** + * Compatibility barrel for process-tree termination helpers owned by agent-core. + * Keep callers on this local path while the underlying harness package owns + * platform-specific traversal and signal behavior. + */ export { killProcessTree, signalProcessTree, diff --git a/src/process/lanes.ts b/src/process/lanes.ts index 6326c73f6072..3e421275345d 100644 --- a/src/process/lanes.ts +++ b/src/process/lanes.ts @@ -1,3 +1,4 @@ +/** Named queue lanes for work that must not interleave with the main command stream. */ export const enum CommandLane { Main = "main", Cron = "cron", diff --git a/src/process/supervisor/adapters/env.ts b/src/process/supervisor/adapters/env.ts index 4930c58f5c20..632c956de8bc 100644 --- a/src/process/supervisor/adapters/env.ts +++ b/src/process/supervisor/adapters/env.ts @@ -1,3 +1,4 @@ +/** Convert Node's optional env values into the concrete string map spawn adapters expect. */ export function toStringEnv(env?: NodeJS.ProcessEnv): Record { if (!env) { return {}; diff --git a/src/process/supervisor/adapters/test-support.ts b/src/process/supervisor/adapters/test-support.ts index 18a55119b9b4..a27376d2b24d 100644 --- a/src/process/supervisor/adapters/test-support.ts +++ b/src/process/supervisor/adapters/test-support.ts @@ -1,10 +1,16 @@ import { expect, vi } from "vitest"; +/** + * Shared supervisor adapter assertions for the SIGTERM -> SIGKILL fallback + * contract. Kept outside individual adapter tests so child and pty backends + * prove the same timer semantics. + */ type WaitResult = { code: number | null; signal: number | NodeJS.Signals | null; }; +/** Assert fallback SIGKILL resolves only after the grace timer expires. */ export async function expectWaitStaysPendingUntilSigkillFallback( waitPromise: Promise, triggerKill: () => void, @@ -24,6 +30,7 @@ export async function expectWaitStaysPendingUntilSigkillFallback( await expect(waitPromise).resolves.toEqual({ code: null, signal: "SIGKILL" }); } +/** Assert a real process exit beats the fallback timer and stays idempotent. */ export async function expectRealExitWinsOverSigkillFallback(params: { waitPromise: Promise; triggerKill: () => void; diff --git a/src/process/supervisor/index.ts b/src/process/supervisor/index.ts index ea9ef44b5824..b34dcaae3831 100644 --- a/src/process/supervisor/index.ts +++ b/src/process/supervisor/index.ts @@ -3,6 +3,7 @@ import type { ProcessSupervisor } from "./types.js"; let singleton: ProcessSupervisor | null = null; +/** Return the process-wide supervisor used by runtime code that does not inject one. */ export function getProcessSupervisor(): ProcessSupervisor { if (singleton) { return singleton; diff --git a/src/process/supervisor/registry.ts b/src/process/supervisor/registry.ts index c5a212d7eb20..620655178347 100644 --- a/src/process/supervisor/registry.ts +++ b/src/process/supervisor/registry.ts @@ -1,5 +1,6 @@ import type { RunRecord, RunState, TerminationReason } from "./types.js"; +/** In-memory run index for the supervisor; callers receive detached snapshots. */ function nowMs() { return Date.now(); } @@ -35,6 +36,10 @@ export type RunRegistry = { delete: (runId: string) => void; }; +/** + * Create the supervisor's mutable run registry. Exited records are retained + * only for diagnostics, so the cap bounds memory without touching live runs. + */ export function createRunRegistry(options?: { maxExitedRecords?: number }): RunRegistry { const records = new Map(); const maxExitedRecords = resolveMaxExitedRecords(options?.maxExitedRecords); @@ -53,6 +58,7 @@ export function createRunRegistry(options?: { maxExitedRecords?: number }): RunR return; } let remove = exited - maxExitedRecords; + // Map insertion order is the retention policy: oldest exited records leave first. for (const [runId, record] of records.entries()) { if (remove <= 0) { break; @@ -127,6 +133,8 @@ export function createRunRegistry(options?: { maxExitedRecords?: number }): RunR const next: RunRecord = { ...current, state: "exited", + // First terminal observation wins; late fallback timers must not rewrite + // the exit reason or signal after a real process exit has been recorded. terminationReason: current.terminationReason ?? exit.reason, exitCode: current.exitCode !== undefined ? current.exitCode : exit.exitCode, exitSignal: current.exitSignal !== undefined ? current.exitSignal : exit.exitSignal, diff --git a/src/process/supervisor/supervisor-log.runtime.ts b/src/process/supervisor/supervisor-log.runtime.ts index 3f9bcdcf3c55..b2308cca642f 100644 --- a/src/process/supervisor/supervisor-log.runtime.ts +++ b/src/process/supervisor/supervisor-log.runtime.ts @@ -1,7 +1,9 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; +/** Runtime logging boundary for lazy supervisor paths and focused test mocks. */ const log = createSubsystemLogger("process/supervisor"); +/** Report spawn failures without importing the full logging subsystem in tests. */ export function warnProcessSupervisorSpawnFailure(message: string) { log.warn(message); } diff --git a/src/process/windows-command.ts b/src/process/windows-command.ts index 6332129c55a2..29f014a6c8cf 100644 --- a/src/process/windows-command.ts +++ b/src/process/windows-command.ts @@ -2,6 +2,10 @@ import path from "node:path"; import process from "node:process"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** + * Resolve package-manager commands that Windows exposes through .cmd shims. + * Explicit extensions are preserved so callers can pass already-resolved tools. + */ export function resolveWindowsCommandShim(params: { command: string; cmdCommands: readonly string[]; diff --git a/src/proxy-capture/blob-store.ts b/src/proxy-capture/blob-store.ts index c4f5bb6a3646..dd8b8160fa62 100644 --- a/src/proxy-capture/blob-store.ts +++ b/src/proxy-capture/blob-store.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { gzipSync, gunzipSync } from "node:zlib"; import type { CaptureBlobRecord } from "./types.js"; +// Capture blobs store request/response bodies by content hash, gzip-compressed +// on disk, so repeated payloads can share one file across events. function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } @@ -30,6 +32,8 @@ export function writeCaptureBlob(params: { }; } +// Debug CLI reads blobs as UTF-8 previews. Binary payloads still remain +// available via the compressed file path recorded in the blob metadata. export function readCaptureBlobText(blobPath: string): string { return gunzipSync(fs.readFileSync(blobPath)).toString("utf8"); } diff --git a/src/proxy-capture/ca.ts b/src/proxy-capture/ca.ts index 5b99f9031357..8ae1a17f6761 100644 --- a/src/proxy-capture/ca.ts +++ b/src/proxy-capture/ca.ts @@ -6,6 +6,8 @@ import { resolveSystemBin } from "../infra/resolve-system-bin.js"; const execFileAsync = promisify(execFile); +// Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs +// are reused within the cert dir so repeated starts do not prompt regeneration. export async function ensureDebugProxyCa(certDir: string): Promise<{ certPath: string; keyPath: string; diff --git a/src/proxy-capture/coverage.ts b/src/proxy-capture/coverage.ts index 4a235bcd4fca..dffafcd7c534 100644 --- a/src/proxy-capture/coverage.ts +++ b/src/proxy-capture/coverage.ts @@ -2,6 +2,8 @@ import process from "node:process"; import { resolveDebugProxySettings, type DebugProxySettings } from "./env.js"; import type { CaptureProtocol } from "./types.js"; +// Debug-proxy coverage records which transport seams are fully captured versus +// merely routed through a proxy, so operators know where packet evidence is weak. export type DebugProxyCoverageStatus = "captured" | "proxy-only" | "uncovered"; export type DebugProxyCoverageEntry = { @@ -123,6 +125,7 @@ const DEBUG_PROXY_COVERAGE_ENTRIES: readonly DebugProxyCoverageEntry[] = [ let warnedCoverageSessionKey: string | null = null; export function listDebugProxyCoverageEntries(): DebugProxyCoverageEntry[] { + // Return copies because callers may render/sort/filter entries for CLI output. return DEBUG_PROXY_COVERAGE_ENTRIES.map((entry) => ({ ...entry, protocols: [...entry.protocols], @@ -170,6 +173,8 @@ export function maybeWarnAboutDebugProxyCoverage( return; } const sessionKey = `${settings.sessionId}:${settings.proxyUrl ?? ""}`; + // Warn once per capture session/proxy URL; the gaps are static enough that + // repeating them during a run only adds noise. if (warnedCoverageSessionKey === sessionKey) { return; } diff --git a/src/proxy-capture/env.ts b/src/proxy-capture/env.ts index 2332a24321bd..fa1d65a3e868 100644 --- a/src/proxy-capture/env.ts +++ b/src/proxy-capture/env.ts @@ -8,6 +8,8 @@ import { resolveDebugProxyDbPath, } from "./paths.js"; +// Environment contract for debug proxy capture. These vars are passed to child +// processes and provider transports so capture sessions share one store/proxy. export const OPENCLAW_DEBUG_PROXY_ENABLED = "OPENCLAW_DEBUG_PROXY_ENABLED"; export const OPENCLAW_DEBUG_PROXY_URL = "OPENCLAW_DEBUG_PROXY_URL"; export const OPENCLAW_DEBUG_PROXY_DB_PATH = "OPENCLAW_DEBUG_PROXY_DB_PATH"; @@ -38,6 +40,8 @@ export function resolveDebugProxySettings( ): DebugProxySettings { const enabled = isTruthy(env[OPENCLAW_DEBUG_PROXY_ENABLED]); const explicitSessionId = env[OPENCLAW_DEBUG_PROXY_SESSION_ID]?.trim() || undefined; + // Local implicit sessions stay stable within one process so repeated callers + // write to the same capture session until an explicit id overrides it. const sessionId = explicitSessionId ?? (cachedImplicitSessionId ??= randomUUID()); return { enabled, @@ -61,6 +65,8 @@ export function applyDebugProxyEnv( certDir?: string; }, ): NodeJS.ProcessEnv { + // Child process env forces proxy capture and standard proxy variables while + // preserving unrelated environment values. return { ...env, [OPENCLAW_DEBUG_PROXY_ENABLED]: "1", @@ -95,6 +101,8 @@ export function createDebugProxyWebSocketAgent(settings: DebugProxySettings): Ag }) as Agent | undefined; } +// Configured URLs win over ambient capture settings; callers use this when a +// channel/provider already exposes an explicit proxy option. export function resolveEffectiveDebugProxyUrl(configuredProxyUrl?: string): string | undefined { const explicit = configuredProxyUrl?.trim(); if (explicit) { diff --git a/src/proxy-capture/paths.ts b/src/proxy-capture/paths.ts index 00e19422646c..d3c09f0c415e 100644 --- a/src/proxy-capture/paths.ts +++ b/src/proxy-capture/paths.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +// Debug proxy capture artifacts live under OpenClaw state so DB, blobs, and CA +// files are grouped and easy to purge. function resolveDebugProxyRootDir(env: NodeJS.ProcessEnv = process.env): string { return path.join(resolveStateDir(env), "debug-proxy"); } diff --git a/src/proxy-capture/runtime.ts b/src/proxy-capture/runtime.ts index f98bcd3f4fbe..55bf1ebca355 100644 --- a/src/proxy-capture/runtime.ts +++ b/src/proxy-capture/runtime.ts @@ -40,6 +40,8 @@ const SENSITIVE_CAPTURE_HEADER_NAME_FRAGMENTS = [ "session", ]; +// Runtime capture records HTTP/fetch and websocket events into the SQLite store, +// redacting sensitive headers and persisting bodies through the blob store. type GlobalFetchPatchedState = { originalFetch: typeof globalThis.fetch; }; @@ -129,6 +131,8 @@ function redactedCaptureHeaders( headers instanceof Headers ? Array.from(headers.entries()) : Object.entries(headers); const redacted: Record = {}; for (const [name, value] of entries) { + // Header names are matched exactly and by sensitive fragments because + // providers use many token/key naming variants. redacted[name] = isSensitiveCaptureHeaderName(name) ? REDACTED_CAPTURE_HEADER_VALUE : value; } return redacted; @@ -171,6 +175,8 @@ function installDebugProxyGlobalFetchPatch( if (fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY]) { return; } + // Patch only once per target and keep the original fetch for deterministic + // teardown in tests and nested capture sessions. const fetchImpl = fetchTarget.fetch; const originalFetch = fetchImpl.bind(fetchTarget); fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch }; @@ -242,6 +248,7 @@ function installDebugProxyGlobalFetchPatch( }; const mockState = (fetchImpl as typeof globalThis.fetch & { mock?: unknown }).mock; if (typeof mockState === "object" && mockState !== null) { + // Preserve Vitest mock metadata when patching mocked fetch targets. (patchedFetch as typeof globalThis.fetch & { mock?: unknown }).mock = mockState; } fetchTarget.fetch = patchedFetch as typeof globalThis.fetch; @@ -283,6 +290,8 @@ export function initializeDebugProxyCapture( installDebugProxyGlobalFetchPatch(settings, deps); } +// Finalization closes the session and restores the fetch patch before closing +// the cached store, preventing later normal requests from being captured. export function finalizeDebugProxyCapture( resolved?: DebugProxySettings, deps: DebugProxyCaptureRuntimeDeps = {}, @@ -354,6 +363,8 @@ export function captureHttpExchange( typeof params.response.clone === "function" && typeof params.response.arrayBuffer === "function"; if (!cloneable) { + // Some Response-like objects cannot be cloned. Still record status/headers + // rather than forcing capture to consume or mutate the original response. store.recordEvent({ ...createHttpCaptureEventBase({ settings, @@ -421,6 +432,8 @@ export function captureHttpExchange( }); } +// Websocket seams call this directly because Node fetch patching cannot observe +// frame traffic. export function captureWsEvent(params: { url: string; direction: "outbound" | "inbound" | "local"; diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 520eaacb3fdc..a085dfab7165 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -17,6 +17,8 @@ import type { CaptureSessionSummary, } from "./types.js"; +// SQLite-backed debug proxy store. Metadata stays in SQLite; large payloads are +// compressed into the blob directory and referenced by hash. function ensureParentDir(filePath: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } @@ -77,6 +79,8 @@ function serializeJson(value: unknown): string | null { return value == null ? null : JSON.stringify(value); } +// Metadata is optional and user/tool supplied, so parse defensively for coverage +// summaries instead of assuming every event has valid JSON. function parseMetaJson(metaJson: unknown): Record | null { if (typeof metaJson !== "string" || metaJson.trim().length === 0) { return null; @@ -259,6 +263,8 @@ export class DebugProxyCaptureStore { } if (host) { hosts.set(host, (hosts.get(host) ?? 0) + 1); + // Local model/provider endpoints are useful to surface separately when + // debugging why cloud-provider labels are absent. if ( host === "127.0.0.1:11434" || host.startsWith("127.0.0.1:") || @@ -295,6 +301,8 @@ export class DebugProxyCaptureStore { const sessionWhere = sessionId ? "AND session_id = ?" : ""; const args = sessionId ? [sessionId] : []; switch (preset) { + // Presets are intentionally SQL-only summaries so the CLI can query large + // capture sessions without loading every event into memory. case "double-sends": return this.db .prepare( @@ -431,6 +439,7 @@ export class DebugProxyCaptureStore { .map((row) => row.blobId?.trim()) .filter((blobId): blobId is string => Boolean(blobId)); const remainingBlobRefs = + // Shared blobs are deleted only when no surviving event references them. candidateBlobIds.length > 0 ? new Set( ( @@ -487,6 +496,8 @@ export function closeDebugProxyCaptureStore(): void { cachedStoreLeases = 0; } +// Lease API keeps one cached synchronous SQLite connection alive across related +// capture operations, then closes it when the last owner releases. export function acquireDebugProxyCaptureStore( dbPath: string, blobDir: string, @@ -519,6 +530,8 @@ export function persistEventPayload( } const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data); const previewLimit = params.previewLimit ?? 8192; + // Store the whole payload as a blob but keep a small UTF-8 preview inline for + // fast CLI listings and query output. const blob = store.persistPayload(buffer, params.contentType); return { dataText: buffer.subarray(0, previewLimit).toString("utf8"), diff --git a/src/proxy-capture/types.ts b/src/proxy-capture/types.ts index 122ec5bdbf21..4ec6fc4e90a1 100644 --- a/src/proxy-capture/types.ts +++ b/src/proxy-capture/types.ts @@ -1,3 +1,5 @@ +// Shared debug-proxy capture schema. These records back the SQLite store, CLI +// reports, and runtime capture events for HTTP/SSE/websocket traffic. export type CaptureProtocol = "http" | "https" | "sse" | "ws" | "wss" | "connect"; export type CaptureDirection = "outbound" | "inbound" | "local"; diff --git a/src/realtime-transcription/provider-registry.ts b/src/realtime-transcription/provider-registry.ts index 7a7eccb6b29d..6402d104a9fb 100644 --- a/src/realtime-transcription/provider-registry.ts +++ b/src/realtime-transcription/provider-registry.ts @@ -10,6 +10,8 @@ import { import type { RealtimeTranscriptionProviderPlugin } from "../plugins/types.js"; import type { RealtimeTranscriptionProviderId } from "./provider-types.js"; +// Provider registry helpers for realtime transcription. Plugin ids and aliases +// share the generic capability-provider registry machinery. export function normalizeRealtimeTranscriptionProviderId( providerId: string | undefined, ): RealtimeTranscriptionProviderId | undefined { @@ -32,12 +34,14 @@ function buildProviderMaps(cfg?: OpenClawConfig): { return buildCapabilityProviderMaps(resolveRealtimeTranscriptionProviderEntries(cfg)); } +/** Lists canonical realtime transcription providers for the active config. */ export function listRealtimeTranscriptionProviders( cfg?: OpenClawConfig, ): RealtimeTranscriptionProviderPlugin[] { return [...buildProviderMaps(cfg).canonical.values()]; } +/** Resolves a realtime transcription provider by id or alias. */ export function getRealtimeTranscriptionProvider( providerId: string | undefined, cfg?: OpenClawConfig, @@ -57,6 +61,7 @@ export function getRealtimeTranscriptionProvider( return buildProviderMaps(cfg).aliases.get(normalized); } +/** Canonicalizes a configured provider id while preserving unknown ids. */ export function canonicalizeRealtimeTranscriptionProviderId( providerId: string | undefined, cfg?: OpenClawConfig, diff --git a/src/realtime-transcription/provider-types.ts b/src/realtime-transcription/provider-types.ts index eadcb0c9c911..446c620edd9d 100644 --- a/src/realtime-transcription/provider-types.ts +++ b/src/realtime-transcription/provider-types.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Public contracts for realtime transcription provider plugins and sessions. +// Providers own config resolution; core owns session lifecycle shape. export type RealtimeTranscriptionProviderId = string; export type RealtimeTranscriptionProviderConfig = Record; @@ -14,6 +16,7 @@ export type RealtimeTranscriptionProviderConfiguredContext = { providerConfig: RealtimeTranscriptionProviderConfig; }; +/** Callback hooks emitted by realtime transcription sessions. */ export type RealtimeTranscriptionSessionCallbacks = { onPartial?: (partial: string) => void; onTranscript?: (transcript: string) => void; @@ -21,11 +24,13 @@ export type RealtimeTranscriptionSessionCallbacks = { onError?: (error: Error) => void; }; +/** Inputs passed to a provider when creating a transcription session. */ export type RealtimeTranscriptionSessionCreateRequest = RealtimeTranscriptionSessionCallbacks & { cfg?: OpenClawConfig; providerConfig: RealtimeTranscriptionProviderConfig; }; +/** Runtime control surface for a realtime transcription session. */ export type RealtimeTranscriptionSession = { connect(): Promise; sendAudio(audio: Buffer): void; diff --git a/src/realtime-transcription/websocket-session.ts b/src/realtime-transcription/websocket-session.ts index 082da5215b92..f76386f81fe3 100644 --- a/src/realtime-transcription/websocket-session.ts +++ b/src/realtime-transcription/websocket-session.ts @@ -7,6 +7,8 @@ import type { RealtimeTranscriptionSessionCallbacks, } from "./provider-types.js"; +// Generic websocket-backed realtime transcription session. Providers supply URL, +// protocol messages, and audio framing while core owns reconnection and queues. export type RealtimeTranscriptionWebSocketTransport = { readonly callbacks: RealtimeTranscriptionSessionCallbacks; closeNow(): void; @@ -18,6 +20,7 @@ export type RealtimeTranscriptionWebSocketTransport = { sendJson(payload: unknown): boolean; }; +/** Provider-specific hooks for creating a websocket transcription session. */ export type RealtimeTranscriptionWebSocketSessionOptions = { callbacks: RealtimeTranscriptionSessionCallbacks; connectClosedBeforeReadyMessage?: string; @@ -115,6 +118,8 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript this.options.sendAudio(audio, this.transport); return; } + // Audio may arrive before provider-specific readiness. Queue bounded bytes + // instead of dropping early microphone frames during connect/reconnect. this.queueAudio(audio); } @@ -370,6 +375,8 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript this.queuedAudio.push(Buffer.from(audio)); this.queuedBytes += audio.byteLength; while (this.queuedBytes > this.maxQueuedBytes && this.queuedAudio.length > 0) { + // Keep the most recent audio when reconnects stall; old buffered audio is + // less useful than avoiding unbounded memory growth. const dropped = this.queuedAudio.shift(); this.queuedBytes -= dropped?.byteLength ?? 0; } @@ -467,6 +474,7 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript } } +/** Creates a reusable websocket session wrapper for a provider implementation. */ export function createRealtimeTranscriptionWebSocketSession( options: RealtimeTranscriptionWebSocketSessionOptions, ): RealtimeTranscriptionSession { diff --git a/src/routing/account-id.ts b/src/routing/account-id.ts index 70ea9f6f8a31..f2a4df099e41 100644 --- a/src/routing/account-id.ts +++ b/src/routing/account-id.ts @@ -3,6 +3,8 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; export const DEFAULT_ACCOUNT_ID = "default"; +// Account ids are config/session keys, not display names. Normalize them into +// short lowercase safe keys and reject prototype-like object keys. const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const INVALID_CHARS_RE = /[^a-z0-9_-]+/g; const LEADING_DASH_RE = /^-+/; @@ -46,6 +48,8 @@ export function normalizeAccountId(value: string | undefined | null): string { return normalized; } +// Optional variant for config fields where absence is meaningful. Invalid ids +// return undefined instead of silently selecting the default account. export function normalizeOptionalAccountId(value: string | undefined | null): string | undefined { const trimmed = (value ?? "").trim(); if (!trimmed) { @@ -64,6 +68,8 @@ function setNormalizeCache(cache: Map, key: string, value: T): voi if (cache.size <= ACCOUNT_ID_CACHE_MAX) { return; } + // Bounded FIFO-ish cache avoids unbounded growth from user/channel input + // while keeping hot account ids cheap during routing. const oldest = cache.keys().next(); if (!oldest.done) { cache.delete(oldest.value); diff --git a/src/routing/account-lookup.ts b/src/routing/account-lookup.ts index 781e4669c445..ac558437d55e 100644 --- a/src/routing/account-lookup.ts +++ b/src/routing/account-lookup.ts @@ -1,5 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +// Case-insensitive account lookup for config maps that may preserve user +// casing. Exact keys win so callers can still distinguish intentional entries. export function resolveAccountEntry( accounts: Record | undefined, accountId: string, @@ -17,6 +19,8 @@ export function resolveAccountEntry( return matchKey ? accounts[matchKey] : undefined; } +// Lookup variant for account ids with a channel-specific normalization rule. +// Used when config keys should match the same canonical id as routing state. export function resolveNormalizedAccountEntry( accounts: Record | undefined, accountId: string, diff --git a/src/routing/binding-scope.ts b/src/routing/binding-scope.ts index 8e9c2428b5a0..a816a7a6b0cf 100644 --- a/src/routing/binding-scope.ts +++ b/src/routing/binding-scope.ts @@ -3,6 +3,8 @@ import { normalizeChatChannelId } from "../channels/ids.js"; import type { AgentRouteBinding } from "../config/types.agents.js"; import { normalizeAccountId, normalizeAgentId } from "./session-key.js"; +// Route binding scopes constrain a configured agent/account binding to a guild, +// team, group space, and optionally channel/platform role ids. export type RouteBindingScopeConstraint = { guildId?: string | null; teamId?: string | null; @@ -45,6 +47,8 @@ export function normalizeRouteBindingChannelId(raw?: string | null): string | nu return fallback || null; } +// Convert a binding match into the same canonical ids used by session routing. +// Wildcard/malformed account matches are ignored because they are not concrete. export function resolveNormalizedRouteBindingMatch( binding: AgentRouteBinding, ): NormalizedRouteBindingMatch | null { @@ -97,6 +101,8 @@ function hasAnyRouteBindingRole( if (hasRoleLookup(memberRoleIds)) { return roles.some((role) => memberRoleIds.has(role)); } + // Most callers pass a Set, but arrays/iterables from channel adapters are + // accepted to avoid forcing allocation at every routing call site. const memberRoleIdSet = new Set(memberRoleIds); return roles.some((role) => memberRoleIdSet.has(role)); } diff --git a/src/routing/bindings.ts b/src/routing/bindings.ts index 4e9b1c40929b..d58ab582d63c 100644 --- a/src/routing/bindings.ts +++ b/src/routing/bindings.ts @@ -8,6 +8,8 @@ import { } from "./binding-scope.js"; import { normalizeAgentId } from "./session-key.js"; +// Public binding helpers used by routing UI/diagnostics. They expose concrete +// account ids derived from configured agent route bindings. export function listBindings(cfg: OpenClawConfig): AgentRouteBinding[] { return listRouteBindings(cfg); } @@ -58,6 +60,8 @@ export function buildChannelAccountBindings(cfg: OpenClawConfig) { if (!resolved) { continue; } + // Map shape is channel -> agent -> accounts so callers can answer both + // "what accounts exist here" and "which accounts are bound to this agent". const byAgent = map.get(resolved.channelId) ?? new Map(); const list = byAgent.get(resolved.agentId) ?? []; if (!list.includes(resolved.accountId)) { diff --git a/src/routing/channel-route-targets.ts b/src/routing/channel-route-targets.ts index 052901b345a9..054177c7ecfa 100644 --- a/src/routing/channel-route-targets.ts +++ b/src/routing/channel-route-targets.ts @@ -6,6 +6,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveAgentRoute } from "./resolve-route.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeAgentId } from "./session-key.js"; +// Agent-to-channel coverage summary for diagnostics and background checks. It +// samples configured channels/accounts and explicit bindings. export type ChannelRouteTarget = { agentId: string; channels: string[]; @@ -74,6 +76,8 @@ export function collectChannelRouteTargets(cfg: OpenClawConfig): ChannelRouteTar for (const channel of listConfiguredChannelIds(cfg)) { const accountIds = listConfiguredChannelAccountIds(cfg, channel); + // Channels with no explicit accounts still have an implicit default account + // route, so sample it to discover the effective agent target. const sampledAccountIds = accountIds.length > 0 ? accountIds : [DEFAULT_ACCOUNT_ID]; for (const accountId of sampledAccountIds) { const route = resolveAgentRoute({ diff --git a/src/routing/default-account-warnings.ts b/src/routing/default-account-warnings.ts index fd4770ca606d..a263d6b40af8 100644 --- a/src/routing/default-account-warnings.ts +++ b/src/routing/default-account-warnings.ts @@ -1,3 +1,5 @@ +// Shared warning text builders for channels that rely on implicit default +// accounts. Keep paths centralized so doctor/setup messages stay consistent. function formatChannelDefaultAccountPath(channelKey: string): string { return `channels.${channelKey}.defaultAccount`; } @@ -10,6 +12,8 @@ export function formatSetExplicitDefaultInstruction(channelKey: string): string return `Set ${formatChannelDefaultAccountPath(channelKey)} or add ${formatChannelAccountsDefaultPath(channelKey)}`; } +// Variant used when a channel already has configured accounts and should point +// the operator at one of them instead of suggesting a generic default. export function formatSetExplicitDefaultToConfiguredInstruction(params: { channelKey: string; }): string { diff --git a/src/routing/peer-kind-match.ts b/src/routing/peer-kind-match.ts index 7fdeb03fd08a..fe4df6ab0cac 100644 --- a/src/routing/peer-kind-match.ts +++ b/src/routing/peer-kind-match.ts @@ -1,5 +1,7 @@ import type { ChatType } from "../channels/chat-type.js"; +// Routing treats group and channel peers as compatible because several chat +// platforms expose broadcast-like group spaces with either label. export function peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean { if (bindingKind === scopeKind) { return true; diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index ef760df41624..f4f488d942f2 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -84,6 +84,7 @@ type MutableAuthProfileStore = Record & { profiles: Record; }; +/** Result summary for a secrets apply dry-run or write run. */ export type SecretsApplyResult = { mode: "dry-run" | "write"; changed: boolean; @@ -754,6 +755,7 @@ function toJsonWrite(pathname: string, value: Record): ApplyWri }; } +/** Applies or dry-runs a validated secrets plan across config, auth stores, and scrub targets. */ export async function runSecretsApply(params: { plan: SecretsApplyPlan; env?: NodeJS.ProcessEnv; @@ -844,6 +846,8 @@ export async function runSecretsApply(params: { writeTextFileAtomic(writeLocal.path, writeLocal.content, writeLocal.mode); } } catch (err) { + // Apply can touch multiple files; restore captured snapshots so partial writes do not leave + // config/auth/env stores out of sync when a later write fails. for (const [pathname, snapshot] of snapshots.entries()) { try { restoreFileSnapshot(pathname, snapshot); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 8daf39708213..c02c0e44eb35 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -38,14 +38,17 @@ import { } from "./storage-scan.js"; import { discoverConfigSecretTargets } from "./target-registry.js"; +/** Stable finding codes emitted by `openclaw secrets audit`. */ export type SecretsAuditCode = | "PLAINTEXT_FOUND" | "REF_UNRESOLVED" | "REF_SHADOWED" | "LEGACY_RESIDUE"; +/** Audit severity used for CLI output and check-mode exit behavior. */ export type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret +/** One secret audit finding with file/path context. */ export type SecretsAuditFinding = { code: SecretsAuditCode; severity: SecretsAuditSeverity; @@ -56,8 +59,10 @@ export type SecretsAuditFinding = { profileId?: string; }; +/** Overall audit state derived from findings and unresolved refs. */ export type SecretsAuditStatus = "clean" | "findings" | "unresolved"; // pragma: allowlist secret +/** Structured report returned by the secrets audit command. */ export type SecretsAuditReport = { version: 1; status: SecretsAuditStatus; @@ -496,6 +501,8 @@ async function collectUnresolvedRefFindings(params: { // Fall back to per-ref resolution for provider-specific pinpoint errors. } + // Batch resolution is cheaper, but individual fallback gives path-specific diagnostics when + // a provider returns mixed id failures. const tasks = selectedRefs.refsToResolve.map( (ref) => async (): Promise<{ key: string; resolved: unknown }> => ({ key: secretRefKey(ref), @@ -606,6 +613,7 @@ function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport[ }; } +/** Runs local storage/config audit and returns a structured report. */ export async function runSecretsAudit( params: { env?: NodeJS.ProcessEnv; @@ -704,6 +712,7 @@ export async function runSecretsAudit( }; } +/** Maps audit results to CLI exit codes. */ export function resolveSecretsAuditExitCode(report: SecretsAuditReport, check: boolean): number { if (report.summary.unresolvedRefCount > 0) { return 2; diff --git a/src/secrets/auth-profiles-scan.ts b/src/secrets/auth-profiles-scan.ts index fa3092416d3d..e2d3c13ee5f3 100644 --- a/src/secrets/auth-profiles-scan.ts +++ b/src/secrets/auth-profiles-scan.ts @@ -1,6 +1,7 @@ import { isNonEmptyString, isRecord } from "./shared.js"; import { listAuthProfileSecretTargetEntries } from "./target-registry.js"; +/** Auth-profile credential kinds that can carry SecretRef-backed values. */ export type AuthProfileCredentialType = "api_key" | "token"; type AuthProfileFieldSpec = { @@ -12,8 +13,11 @@ type ApiKeyCredentialVisit = { kind: "api_key"; profileId: string; provider: string; + /** Original mutable profile record from auth-profiles.json. */ profile: Record; + /** Plaintext value field name derived from the secret target registry. */ valueField: string; + /** SecretRef sibling field name derived from the secret target registry. */ refField: string; value: unknown; refValue: unknown; @@ -23,8 +27,11 @@ type TokenCredentialVisit = { kind: "token"; profileId: string; provider: string; + /** Original mutable profile record from auth-profiles.json. */ profile: Record; + /** Plaintext value field name derived from the secret target registry. */ valueField: string; + /** SecretRef sibling field name derived from the secret target registry. */ refField: string; value: unknown; refValue: unknown; @@ -35,7 +42,9 @@ type OauthCredentialVisit = { profileId: string; provider: string; profile: Record; + /** Whether the profile currently stores a materialized OAuth access token. */ hasAccess: boolean; + /** Whether the profile currently stores a materialized OAuth refresh token. */ hasRefresh: boolean; }; @@ -58,6 +67,8 @@ const AUTH_PROFILE_FIELD_SPEC_BY_TYPE = (() => { if (!target.authProfileType) { continue; } + // Target registry owns shipped auth-profile field names; derive scan fields from it so + // policy checks and runtime collection cannot drift when a ref path changes. defaults[target.authProfileType] = { valueField: getAuthProfileFieldName(target.pathPattern), refField: @@ -69,6 +80,7 @@ const AUTH_PROFILE_FIELD_SPEC_BY_TYPE = (() => { return defaults; })(); +/** Returns the value/ref field names for one auth-profile credential type. */ export function getAuthProfileFieldSpec(type: AuthProfileCredentialType): AuthProfileFieldSpec { return AUTH_PROFILE_FIELD_SPEC_BY_TYPE[type]; } @@ -92,6 +104,7 @@ function toSecretCredentialVisit(params: { }; } +/** Iterates credential-bearing auth profiles with normalized field metadata for audit/apply. */ export function* iterateAuthProfileCredentials( profiles: Record, ): Iterable { diff --git a/src/secrets/auth-store-paths.ts b/src/secrets/auth-store-paths.ts index b2d68468e666..a6f67819b65c 100644 --- a/src/secrets/auth-store-paths.ts +++ b/src/secrets/auth-store-paths.ts @@ -5,6 +5,10 @@ import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; +/** + * Lists deduplicated auth-profile store paths that may contain SecretRefs. + * Covers implicit main, discovered state-dir agents, and config-declared agent dirs. + */ export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { const paths = new Set(); // Scope default auth store discovery to the provided stateDir instead of @@ -21,6 +25,7 @@ export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: stri } } + // Configured agent dirs may live outside stateDir; include them after state-dir discovery. for (const agentId of listAgentIds(config)) { if (agentId === "main") { paths.add( diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 41d961dad8ee..577cdf7bc45b 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -75,6 +75,7 @@ export type BundledChannelSecretContractApi = Pick< "collectRuntimeConfigAssignments" | "secretTargetRegistryEntries" >; +/** Loads a bundled channel secret contract from its public artifact bundle. */ export function loadBundledChannelSecretContractApi( channelId: string, ): BundledChannelSecretContractApi | undefined { @@ -198,6 +199,7 @@ function listChannelSecretContractRecords(params: { }); } +/** Loads the first channel secret contract for a channel, preferring bundled metadata. */ export function loadChannelSecretContractApi(params: { channelId: string; config: OpenClawConfig; @@ -208,6 +210,8 @@ export function loadChannelSecretContractApi(params: { if (bundled) { return bundled; } + // External contracts are considered only after bundled artifacts so core channels keep their + // shipped metadata stable even when similarly named plugins are installed. const env = params.env ?? process.env; for (const record of listChannelSecretContractRecords({ channelId: params.channelId, @@ -223,6 +227,7 @@ export function loadChannelSecretContractApi(params: { return undefined; } +/** Loads a channel secret contract directly from a manifest record. */ export function loadChannelSecretContractApiForRecord( record: PluginManifestRecord, ): BundledChannelSecretContractApi | undefined { @@ -237,6 +242,7 @@ export type BundledChannelSecurityContractApi = Pick< "unsupportedSecretRefSurfacePatterns" | "collectUnsupportedSecretRefConfigCandidates" >; +/** Loads bundled channel security metadata used to reject unsupported SecretRef surfaces. */ export function loadBundledChannelSecurityContractApi( channelId: string, ): BundledChannelSecurityContractApi | undefined { diff --git a/src/secrets/channel-env-var-names.ts b/src/secrets/channel-env-var-names.ts index e4e717d0d8cb..35421e40f117 100644 --- a/src/secrets/channel-env-var-names.ts +++ b/src/secrets/channel-env-var-names.ts @@ -1,3 +1,4 @@ +/** Ambient process env names that are too common to imply channel configuration. */ const UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES = new Set([ "CI", "HOME", @@ -18,8 +19,12 @@ const UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES = new Set([ "USER", ]); +/** + * Returns whether a channel env var name is safe to treat as a credential/config trigger. + */ export function isSafeChannelEnvVarTriggerName(key: string): boolean { const normalized = key.trim().toUpperCase(); + // Common process env names are too noisy; channel scans should only react to explicit secrets. return ( /^[A-Z][A-Z0-9_]*$/.test(normalized) && !UNSAFE_CHANNEL_ENV_VAR_TRIGGER_NAMES.has(normalized) ); diff --git a/src/secrets/channel-env-vars.ts b/src/secrets/channel-env-vars.ts index da32fa6bbb44..933ae91731ba 100644 --- a/src/secrets/channel-env-vars.ts +++ b/src/secrets/channel-env-vars.ts @@ -4,8 +4,11 @@ import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot. export { isSafeChannelEnvVarTriggerName } from "./channel-env-var-names.js"; type ChannelEnvVarLookupParams = { + /** Config snapshot used to discover enabled/installed plugin manifests. */ config?: OpenClawConfig; + /** Workspace root used for local plugin metadata discovery. */ workspaceDir?: string; + /** Env snapshot used by metadata loading; defaults to process env. */ env?: NodeJS.ProcessEnv; }; @@ -30,6 +33,10 @@ function appendUniqueEnvVarCandidates( } } +/** + * Resolves plugin-declared channel environment variable names keyed by channel id. + * The result is deterministic so env-shell docs and prompt snapshots stay stable. + */ export function resolveChannelEnvVars( params?: ChannelEnvVarLookupParams, ): Record { @@ -43,6 +50,7 @@ export function resolveChannelEnvVars( if (!plugin.channelEnvVars) { continue; } + // Sort channel ids before merging so prompt/test snapshots do not depend on manifest order. for (const [channelId, keys] of Object.entries(plugin.channelEnvVars).toSorted( ([left], [right]) => left.localeCompare(right), )) { @@ -52,12 +60,18 @@ export function resolveChannelEnvVars( return candidates; } +/** + * Returns the declared env var names for one channel id. + */ export function getChannelEnvVars(channelId: string, params?: ChannelEnvVarLookupParams): string[] { const channelEnvVars = resolveChannelEnvVars(params); const envVars = Object.hasOwn(channelEnvVars, channelId) ? channelEnvVars[channelId] : undefined; return Array.isArray(envVars) ? [...envVars] : []; } +/** + * Lists every known channel env var name across installed plugin metadata. + */ export function listKnownChannelEnvVarNames(params?: ChannelEnvVarLookupParams): string[] { return uniqueStrings(Object.values(resolveChannelEnvVars(params)).flat()); } diff --git a/src/secrets/channel-secret-basic-runtime.ts b/src/secrets/channel-secret-basic-runtime.ts index 6d3fe7054bb7..8fcc43527d00 100644 --- a/src/secrets/channel-secret-basic-runtime.ts +++ b/src/secrets/channel-secret-basic-runtime.ts @@ -15,14 +15,17 @@ export type ChannelAccountEntry = { enabled: boolean; }; +/** Resolved view of a channel config, including synthetic default-account fallback. */ export type ChannelAccountSurface = { hasExplicitAccounts: boolean; channelEnabled: boolean; accounts: ChannelAccountEntry[]; }; +/** Predicate used by channel helpers to decide whether an account-owned secret is active. */ export type ChannelAccountPredicate = (entry: ChannelAccountEntry) => boolean; +/** Reads a channel config block when it exists as an object. */ export function getChannelRecord( config: { channels?: Record }, channelKey: string, @@ -35,6 +38,7 @@ export function getChannelRecord( return isRecord(channel) ? channel : undefined; } +/** Reads a channel config and its resolved account surface in one step. */ export function getChannelSurface( config: { channels?: Record }, channelKey: string, @@ -49,6 +53,7 @@ export function getChannelSurface( }; } +/** Resolves explicit channel accounts or creates a default account backed by the channel root. */ export function resolveChannelAccountSurface( channel: Record, ): ChannelAccountSurface { @@ -89,15 +94,18 @@ export function isBaseFieldActiveForChannelSurface( if (!surface.hasExplicitAccounts) { return true; } + // Top-level channel fields are inherited by enabled accounts that do not override that field. return surface.accounts.some( ({ account, enabled }) => enabled && !hasOwnProperty(account, rootKey), ); } +/** Normalizes optional channel secret strings before deciding whether a value is configured. */ export function normalizeSecretStringValue(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +/** Returns true when a channel value contains plaintext or a SecretRef-compatible value. */ export function hasConfiguredSecretInputValue( value: unknown, defaults: SecretDefaults | undefined, @@ -105,6 +113,7 @@ export function hasConfiguredSecretInputValue( return normalizeSecretStringValue(value).length > 0 || coerceSecretRef(value, defaults) !== null; } +/** Collects a simple channel field from the channel root and explicit account overrides. */ export function collectSimpleChannelFieldAssignments(params: { channelKey: string; field: string; @@ -163,6 +172,7 @@ function isConditionalTopLevelFieldActive(params: { return params.surface.accounts.some(params.inheritedAccountActive); } +/** Collects a channel field whose active state depends on caller-provided account predicates. */ export function collectConditionalChannelFieldAssignments(params: { channelKey: string; field: string; @@ -217,6 +227,7 @@ export function collectConditionalChannelFieldAssignments(params: { } } +/** Collects a nested channel field from root and account-specific nested config blocks. */ export function collectNestedChannelFieldAssignments(params: { channelKey: string; nestedKey: string; diff --git a/src/secrets/channel-secret-collector-runtime.ts b/src/secrets/channel-secret-collector-runtime.ts index 4fdb89765cf1..7a166d7e3b4f 100644 --- a/src/secrets/channel-secret-collector-runtime.ts +++ b/src/secrets/channel-secret-collector-runtime.ts @@ -1,3 +1,7 @@ +/** + * Runtime barrel for channel secret collectors used by bundled channel contracts. + * Keep channel packages on this narrow surface instead of deep runtime modules. + */ export { collectConditionalChannelFieldAssignments, collectNestedChannelFieldAssignments, diff --git a/src/secrets/channel-secret-tts-runtime.ts b/src/secrets/channel-secret-tts-runtime.ts index bc6afe015626..f8ce7d020999 100644 --- a/src/secrets/channel-secret-tts-runtime.ts +++ b/src/secrets/channel-secret-tts-runtime.ts @@ -6,15 +6,20 @@ import { collectTtsApiKeyAssignments } from "./runtime-config-collectors-tts.js" import type { ResolverContext, SecretDefaults } from "./runtime-shared.js"; import { isRecord } from "./shared.js"; +/** Collects nested TTS provider SecretRefs from channel root and account-specific blocks. */ export function collectNestedChannelTtsAssignments(params: { + /** Channel config key used in runtime warning/assignment paths. */ channelKey: string; + /** Nested channel config field that owns the `tts` block, such as `outbound`. */ nestedKey: string; channel: Record; surface: ChannelAccountSurface; defaults: SecretDefaults | undefined; context: ResolverContext; + /** Whether the top-level nested `tts` block can affect runtime behavior. */ topLevelActive: boolean; topInactiveReason: string; + /** Per-account activity predicate for account-specific nested `tts` blocks. */ accountActive: ChannelAccountPredicate; accountInactiveReason: | string diff --git a/src/secrets/command-config.ts b/src/secrets/command-config.ts index 1e961986129d..27dee7b75a9d 100644 --- a/src/secrets/command-config.ts +++ b/src/secrets/command-config.ts @@ -4,22 +4,26 @@ import { getPath } from "./path-utils.js"; import { isExpectedResolvedSecretValue } from "./secret-value.js"; import { discoverConfigSecretTargetsByIds } from "./target-registry.js"; +/** One resolved SecretRef value ready to inject into a command-scoped config view. */ export type CommandSecretAssignment = { path: string; pathSegments: string[]; value: unknown; }; +/** Resolved command assignments plus non-fatal diagnostics. */ export type ResolveAssignmentsFromSnapshotResult = { assignments: CommandSecretAssignment[]; diagnostics: string[]; }; +/** Active or inactive command target that could not be materialized. */ export type UnresolvedCommandSecretAssignment = { path: string; pathSegments: string[]; }; +/** Full command assignment analysis before unresolved active refs are rejected. */ export type AnalyzeAssignmentsFromSnapshotResult = { assignments: CommandSecretAssignment[]; diagnostics: string[]; @@ -27,6 +31,9 @@ export type AnalyzeAssignmentsFromSnapshotResult = { inactive: UnresolvedCommandSecretAssignment[]; }; +/** + * Compares source SecretRefs with the active resolved snapshot for command-time assignments. + */ export function analyzeCommandSecretAssignmentsFromSnapshot(params: { sourceConfig: OpenClawConfig; resolvedConfig: OpenClawConfig; @@ -56,6 +63,8 @@ export function analyzeCommandSecretAssignmentsFromSnapshot(params: { const resolved = getPath(params.resolvedConfig, target.pathSegments); if (!isExpectedResolvedSecretValue(resolved, target.entry.expectedResolvedValue)) { + // Inactive surfaces are diagnostics, not hard failures; active unresolved refs block the + // command because the runtime snapshot promised that target was usable. if (params.inactiveRefPaths?.has(target.path)) { diagnostics.push( `${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`, @@ -82,6 +91,7 @@ export function analyzeCommandSecretAssignmentsFromSnapshot(params: { const hasCompetingSiblingRef = target.entry.secretShape === "sibling_ref" && explicitRef && inlineCandidateRef; // pragma: allowlist secret if (hasCompetingSiblingRef) { + // Sibling refs are the canonical target for these surfaces; inline refs are legacy overlap. diagnostics.push( `${target.path}: both inline and sibling ref were present; sibling ref took precedence.`, ); @@ -91,6 +101,9 @@ export function analyzeCommandSecretAssignmentsFromSnapshot(params: { return { assignments, diagnostics, unresolved, inactive }; } +/** + * Returns resolved command assignments and throws when an active required ref is unresolved. + */ export function collectCommandSecretAssignmentsFromSnapshot(params: { sourceConfig: OpenClawConfig; resolvedConfig: OpenClawConfig; diff --git a/src/secrets/config-io.ts b/src/secrets/config-io.ts index 1dafcac92530..154857d02c0c 100644 --- a/src/secrets/config-io.ts +++ b/src/secrets/config-io.ts @@ -5,6 +5,9 @@ const silentConfigIoLogger = { warn: () => {}, } as const; +/** + * Creates config I/O for secrets commands with config-loader logging suppressed. + */ export function createSecretsConfigIO(params: { env: NodeJS.ProcessEnv }) { // Secrets command output is owned by the CLI command so --json stays machine-parseable. return createConfigIO({ diff --git a/src/secrets/configure-plan.ts b/src/secrets/configure-plan.ts index 5cf0173e1e66..f1c54e4b802d 100644 --- a/src/secrets/configure-plan.ts +++ b/src/secrets/configure-plan.ts @@ -14,6 +14,7 @@ import { discoverConfigSecretTargets, } from "./target-registry.js"; +/** Credential target shown by `openclaw secrets configure` before a SecretRef is selected. */ export type ConfigureCandidate = { type: string; path: string; @@ -29,10 +30,12 @@ export type ConfigureCandidate = { authProfileProvider?: string; }; +/** Configure candidate after the operator chooses the SecretRef to write. */ export type ConfigureSelectedTarget = ConfigureCandidate & { ref: SecretRef; }; +/** Provider config mutations collected while building a secrets configure plan. */ export type ConfigureProviderChanges = { upserts: Record; deletes: string[]; @@ -45,6 +48,7 @@ function getSecretProviders(config: OpenClawConfig): Record 0 ? provider : undefined; } +/** Builds configure candidates for OpenClaw config plus an optional auth-profile scope. */ export function buildConfigureCandidatesForScope(params: { config: OpenClawConfig; authoredOpenClawConfig?: OpenClawConfig; @@ -98,6 +103,8 @@ export function buildConfigureCandidatesForScope(params: { const refPathExists = entry.refPathSegments ? hasPathInAuthoredConfig(entry.refPathSegments) : false; + // Generated/defaulted target paths are still configurable, but mark them derived so + // prompts can distinguish authored config from normalized aliases. return Object.assign( { type: entry.entry.targetType, @@ -128,6 +135,7 @@ export function buildConfigureCandidatesForScope(params: { authProfiles.store, entry.pathSegments, ); + // Auth-profile apply can create missing profiles only when the provider is known. const resolved = resolveSecretInputRef({ value: entry.value, refValue: entry.refValue, @@ -185,6 +193,7 @@ function hasPath(root: unknown, segments: string[]): boolean { return false; } +/** Computes provider upserts/deletes between original and edited config. */ export function collectConfigureProviderChanges(params: { original: OpenClawConfig; next: OpenClawConfig; @@ -215,6 +224,7 @@ export function collectConfigureProviderChanges(params: { }; } +/** Returns true when selected targets or provider mutations would produce a plan. */ export function hasConfigurePlanChanges(params: { selectedTargets: ReadonlyMap; providerChanges: ConfigureProviderChanges; @@ -226,6 +236,7 @@ export function hasConfigurePlanChanges(params: { ); } +/** Builds the serializable secrets apply plan from configure selections. */ export function buildSecretsConfigurePlan(params: { selectedTargets: ReadonlyMap; providerChanges: ConfigureProviderChanges; diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 8f9f5ea4a989..0a955017bf33 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -47,6 +47,7 @@ import { resolveSecretRefValue } from "./resolve.js"; import { assertExpectedResolvedSecretValue } from "./secret-value.js"; import { isRecord } from "./shared.js"; +/** Result returned after interactive secrets configure builds and preflights an apply plan. */ export type SecretsConfigureResult = { plan: SecretsApplyPlan; preflight: SecretsApplyResult; diff --git a/src/secrets/credential-matrix.ts b/src/secrets/credential-matrix.ts index baa133405ebb..3750ff013cdc 100644 --- a/src/secrets/credential-matrix.ts +++ b/src/secrets/credential-matrix.ts @@ -21,11 +21,14 @@ export type SecretRefCredentialMatrixDocument = { entries: CredentialMatrixEntry[]; }; +/** Builds the public SecretRef credential matrix from the source target registry. */ export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocument { const entriesByKey = new Map(); for (const entry of getSourceSecretTargetRegistry()) { const isCanonicalFirecrawlWebFetchEntry = entry.id === "plugins.entries.firecrawl.config.webFetch.apiKey"; + // Firecrawl web fetch moved to the plugin-owned path, but matrix docs keep the public + // tools.web.fetch.firecrawl path as the canonical operator-facing surface. const canonicalId = isCanonicalFirecrawlWebFetchEntry ? "tools.web.fetch.firecrawl.apiKey" : entry.id; diff --git a/src/secrets/exec-resolution-policy.ts b/src/secrets/exec-resolution-policy.ts index 37d3e04697c7..3586f0f92570 100644 --- a/src/secrets/exec-resolution-policy.ts +++ b/src/secrets/exec-resolution-policy.ts @@ -2,6 +2,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SecretRef } from "../config/types.secrets.js"; import { formatExecSecretRefIdValidationMessage, isValidExecSecretRefId } from "./ref-contract.js"; +/** + * Splits refs by whether the current audit/apply mode is allowed to execute secret providers. + */ export function selectRefsForExecPolicy(params: { refs: SecretRef[]; allowExec: boolean }): { refsToResolve: SecretRef[]; skippedExecRefs: SecretRef[]; @@ -10,6 +13,7 @@ export function selectRefsForExecPolicy(params: { refs: SecretRef[]; allowExec: const skippedExecRefs: SecretRef[] = []; for (const ref of params.refs) { if (ref.source === "exec" && !params.allowExec) { + // Dry-run preflight can still report static exec-ref problems without invoking commands. skippedExecRefs.push(ref); continue; } @@ -18,6 +22,9 @@ export function selectRefsForExecPolicy(params: { refs: SecretRef[]; allowExec: return { refsToResolve, skippedExecRefs }; } +/** + * Returns static validation errors for skipped exec refs without resolving the provider command. + */ export function getSkippedExecRefStaticError(params: { ref: SecretRef; config: OpenClawConfig; diff --git a/src/secrets/json-pointer.ts b/src/secrets/json-pointer.ts index 89024e6abfcf..74f1384d81df 100644 --- a/src/secrets/json-pointer.ts +++ b/src/secrets/json-pointer.ts @@ -12,10 +12,17 @@ function decodeJsonPointerToken(token: string): string { return token.replace(/~1/g, "/").replace(/~0/g, "~"); } +/** + * Encodes one JSON Pointer path token using RFC 6901 escaping. + */ export function encodeJsonPointerToken(token: string): string { return token.replace(/~/g, "~0").replace(/\//g, "~1"); } +/** + * Reads a value from a JSON-like document using an absolute JSON Pointer. + * Missing segments throw by default; `onMissing: "undefined"` is for optional probes. + */ export function readJsonPointer( root: unknown, pointer: string, @@ -38,6 +45,7 @@ export function readJsonPointer( let current: unknown = root; for (const token of tokens) { if (Array.isArray(current)) { + // Array segments must be canonical non-negative indexes, not partial parses like "1abc". const index = parseConfigPathArrayIndex(token); if (index === undefined || index >= current.length) { return failOrUndefined({ diff --git a/src/secrets/legacy-secretref-env-marker.ts b/src/secrets/legacy-secretref-env-marker.ts index a851eeb24a1c..4306babdebc6 100644 --- a/src/secrets/legacy-secretref-env-marker.ts +++ b/src/secrets/legacy-secretref-env-marker.ts @@ -10,6 +10,7 @@ import { type DiscoveredConfigSecretTarget, } from "./target-registry.js"; +/** Legacy marker string found on a registered secret target, with parsed ref when possible. */ export type LegacySecretRefEnvMarkerCandidate = { path: string; pathSegments: string[]; @@ -36,6 +37,9 @@ function toCandidate( }; } +/** + * Finds legacy env marker strings on registered secret targets without mutating config. + */ export function collectLegacySecretRefEnvMarkerCandidates( config: OpenClawConfig, ): LegacySecretRefEnvMarkerCandidate[] { @@ -45,6 +49,9 @@ export function collectLegacySecretRefEnvMarkerCandidates( .filter((candidate): candidate is LegacySecretRefEnvMarkerCandidate => candidate !== null); } +/** + * Converts parseable legacy env marker strings into structured env SecretRef objects. + */ export function migrateLegacySecretRefEnvMarkers(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; @@ -63,6 +70,7 @@ export function migrateLegacySecretRefEnvMarkers(config: OpenClawConfig): { if (!ref) { continue; } + // Only registered existing paths are rewritten; malformed markers remain for explicit repair. if (setPathExistingStrict(next, candidate.pathSegments, ref)) { changes.push( `Moved ${candidate.path} ${LEGACY_SECRETREF_ENV_MARKER_PREFIX}${ref.id} marker → structured env SecretRef.`, diff --git a/src/secrets/model-provider-header-policy.ts b/src/secrets/model-provider-header-policy.ts index cc2ecb668de1..b2d6d1af705b 100644 --- a/src/secrets/model-provider-header-policy.ts +++ b/src/secrets/model-provider-header-policy.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Exact header names that always carry credential material for model provider requests. */ const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ "authorization", "proxy-authorization", @@ -14,6 +15,8 @@ const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ "secret-key", ]); +// Substring matching catches provider-specific auth headers without forcing every plugin to +// register its own spelling in the shared plaintext-secret audit. const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ "api-key", "apikey", @@ -23,6 +26,10 @@ const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ "credential", ]; +/** + * Returns whether a model-provider header name should be treated as secret-bearing. + * This is intentionally conservative: false positives are audit noise, false negatives leak keys. + */ export function isLikelySensitiveModelProviderHeaderName(value: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(value); if (!normalized) { diff --git a/src/secrets/path-utils.ts b/src/secrets/path-utils.ts index a962e935ec8e..5a1844c6f390 100644 --- a/src/secrets/path-utils.ts +++ b/src/secrets/path-utils.ts @@ -47,6 +47,7 @@ function traverseToLeafParent(params: { const segment = params.segments[index] ?? ""; if (Array.isArray(cursor)) { const arrayIndex = requireArrayIndexSegment(segment, params.segments.join(".")); + // Existing-path mutations must fail before the leaf so callers do not create partial config. if (params.requireExistingSegment && (arrayIndex < 0 || arrayIndex >= cursor.length)) { throw new Error( `Path segment does not exist at ${params.segments.slice(0, index + 1).join(".")}.`, @@ -71,6 +72,10 @@ function traverseToLeafParent(params: { return cursor; } +/** + * Reads a config path from object/array containers. + * Missing containers, invalid array indexes, and scalar parents resolve to undefined. + */ export function getPath(root: unknown, segments: string[]): unknown { if (segments.length === 0) { return undefined; @@ -93,6 +98,10 @@ export function getPath(root: unknown, segments: string[]): unknown { return cursor; } +/** + * Sets a config path, creating missing object or array containers from the next path segment. + * Existing non-container parents fail so callers cannot silently change config shape. + */ export function setPathCreateStrict( root: Record, segments: string[], @@ -109,6 +118,8 @@ export function setPathCreateStrict( const nextSegment = segments[index + 1] ?? ""; const needs = expectedContainer(nextSegment); + // Numeric next segments create arrays; named next segments create objects. + // This keeps registry wildcard paths and config array paths materialized consistently. if (Array.isArray(cursor)) { const arrayIndex = requireArrayIndexSegment(segment, segments.join(".")); const existing = cursor[arrayIndex]; @@ -154,6 +165,10 @@ export function setPathCreateStrict( return changed; } +/** + * Sets an existing config path and throws if any parent or leaf segment is missing. + * Used by runtime resolution paths that must only replace values proven by source discovery. + */ export function setPathExistingStrict( root: Record, segments: string[], @@ -186,6 +201,10 @@ export function setPathExistingStrict( return false; } +/** + * Deletes an existing config path, returning whether anything was removed. + * Array deletes compact with splice; object deletes remove only the concrete leaf key. + */ export function deletePathStrict(root: Record, segments: string[]): boolean { const cursor = traverseToLeafParent({ root, segments, requireExistingSegment: false }); diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index b2b92023883d..4e3867ea8b13 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -6,8 +6,10 @@ import { isValidExecSecretRefId, isValidSecretProviderAlias } from "./ref-contra import { parseDotPath, toDotPath } from "./shared.js"; import { resolvePlanTargetAgainstRegistry, type ResolvedPlanTarget } from "./target-registry.js"; +/** Registry target id accepted by a secrets apply plan. */ export type SecretsPlanTargetType = string; +/** One planned SecretRef mutation against config or auth-profile storage. */ export type SecretsPlanTarget = { type: SecretsPlanTargetType; /** @@ -41,6 +43,7 @@ export type SecretsPlanTarget = { authProfileProvider?: string; }; +/** Serialized plan produced by `openclaw secrets configure` or supplied manually. */ export type SecretsApplyPlan = { version: 1; protocolVersion: 1; @@ -89,6 +92,8 @@ export function resolveValidatedPlanTarget(candidate: { if (segments.length === 0 || hasForbiddenPathSegment(segments) || path !== toDotPath(segments)) { return null; } + // Registry resolution is the ownership gate; caller-provided paths must map to a known + // mutable SecretRef target before apply code can write anything. return resolvePlanTargetAgainstRegistry({ type: candidate.type, pathSegments: segments, @@ -97,6 +102,7 @@ export function resolveValidatedPlanTarget(candidate: { }); } +/** Validates the external secrets apply plan shape and every target/provider mutation. */ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; @@ -176,6 +182,7 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { return true; } +/** Normalizes omitted plan options to the apply-time defaults. */ export function normalizeSecretsPlanOptions( options: SecretsApplyPlan["options"] | undefined, ): Required> { diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 5de1cddca4a6..4a8bdea03a33 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -39,6 +39,7 @@ export type ProviderEnvVarLookupParams = { metadataSnapshot?: PluginMetadataSnapshot; }; +/** Manifest-provided evidence that a provider auth credential exists outside config. */ export type ProviderAuthEvidence = { type: "local-file-with-env"; fileEnvVar?: string; @@ -49,6 +50,7 @@ export type ProviderAuthEvidence = { source?: string; }; +/** Provider auth lookup maps resolved from plugin metadata and core fallback rules. */ export type ProviderAuthLookupMaps = { aliasMap: Readonly>; envCandidateMap: Readonly>; @@ -76,6 +78,8 @@ function shouldUsePluginProviderEnvVars( if (plugin.origin !== "workspace" || params?.includeUntrustedWorkspacePlugins !== false) { return true; } + // Env-var candidates are hints for lookup/scrubbing, but callers can opt into the same + // workspace trust filter used for stronger auth evidence when probing scoped workspaces. return isWorkspacePluginTrustedForProviderEnvVars(plugin, params?.config); } @@ -86,6 +90,8 @@ function shouldUsePluginProviderAuthEvidence( if (plugin.origin !== "workspace") { return true; } + // Auth evidence can point at local credential files, so workspace plugins must be explicitly + // trusted through config before their evidence participates in auth discovery. return isWorkspacePluginTrustedForProviderEnvVars(plugin, params?.config); } @@ -166,6 +172,8 @@ function resolveProviderMetadataSnapshot( return current; } if (config && normalizePluginsConfig(config.plugins).loadPaths.length === 0) { + // Configs without explicit load paths can reuse the process-scoped snapshot; plugin-scoped + // configs need fresh metadata so workspace allow/deny decisions are not bypassed. const unscopedCurrent = getCurrentPluginMetadataSnapshot({ env, ...(params?.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), @@ -285,6 +293,7 @@ function resolveManifestSetupProviderFallbackRefsFromSnapshot( if (plugin.setup?.requiresRuntime === false) { continue; } + // Setup fallback refs are only useful for providers that may be reached at runtime. if (plugin.setup?.providers === undefined && plugin.providers === undefined) { continue; } @@ -300,6 +309,7 @@ function resolveManifestSetupProviderFallbackRefsFromSnapshot( return [...refs].toSorted((a, b) => a.localeCompare(b)); } +/** Resolves provider env-var candidates used by generic auth lookup. */ export function resolveProviderAuthEnvVarCandidates( params?: ProviderEnvVarLookupParams, ): Record { @@ -309,12 +319,14 @@ export function resolveProviderAuthEnvVarCandidates( }; } +/** Resolves non-env evidence that provider auth may already be configured. */ export function resolveProviderAuthEvidence( params?: ProviderEnvVarLookupParams, ): Record { return resolveManifestProviderAuthEvidence(params); } +/** Resolves all provider auth lookup maps from a single metadata snapshot. */ export function resolveProviderAuthLookupMaps( params?: ProviderEnvVarLookupParams, ): ProviderAuthLookupMaps { @@ -339,6 +351,7 @@ export function resolveProviderAuthLookupMaps( }; } +/** Resolves env vars used by setup, default SecretRefs, and broad secret scrubbing. */ export function resolveProviderEnvVars( params?: ProviderEnvVarLookupParams, ): Record { @@ -436,6 +449,7 @@ export function getProviderEnvVars( // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. +/** Lists known provider auth env vars without bridge-only env vars. */ export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupParams): string[] { return uniqueStrings([ ...Object.values(resolveProviderAuthEnvVarCandidates(params)).flat(), @@ -443,10 +457,12 @@ export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupPa ]); } +/** Lists env vars that may contain provider secrets for broad scrubbing. */ export function listKnownSecretEnvVarNames(params?: ProviderEnvVarLookupParams): string[] { return uniqueStrings(Object.values(resolveProviderEnvVars(params)).flat()); } +/** Returns a copy of an env object with denied keys removed case-insensitively. */ export function omitEnvKeysCaseInsensitive( baseEnv: NodeJS.ProcessEnv, keys: Iterable, diff --git a/src/secrets/provider-integrations.ts b/src/secrets/provider-integrations.ts index 58a02bd18abc..8464465bb205 100644 --- a/src/secrets/provider-integrations.ts +++ b/src/secrets/provider-integrations.ts @@ -13,6 +13,7 @@ import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/ma import type { PluginManifestSecretProviderIntegration } from "../plugins/manifest.js"; import { isValidSecretProviderAlias } from "./ref-contract.js"; +/** Secret provider preset exposed by an active trusted plugin integration. */ export type SecretProviderIntegrationPreset = { id: string; pluginId: string; @@ -22,6 +23,7 @@ export type SecretProviderIntegrationPreset = { providerConfig: PluginIntegrationSecretProviderConfig; }; +/** Result of materializing a plugin integration into a manual exec provider config. */ export type SecretProviderIntegrationResolution = | { ok: true; @@ -60,6 +62,8 @@ function resolveArg(arg: string, pluginRoot: string): string | undefined { } function withNodeCommandTrustedDir(command: string, pluginRoot: string): string[] { + // The ${node} placeholder executes the current Node binary with a plugin-owned entrypoint. + // Trust both the Node binary dir and plugin root so resolver path checks accept that shape. return command === NODE_COMMAND_PLACEHOLDER ? [...new Set([path.dirname(process.execPath), pluginRoot])] : [pluginRoot]; @@ -112,6 +116,8 @@ function isSecurePluginEntrypointPath(params: { return false; } + // Validate both lexical and realpath parent chains. The lexical chain catches symlink tricks + // inside the plugin tree; the realpath chain catches world-writable resolved directories. let originalDir = path.resolve(params.pluginRoot); for (const [index, segment] of ["", ...originalSegments].entries()) { if (segment) { @@ -292,6 +298,7 @@ function isValidPluginIntegrationProviderId(value: string): boolean { return value.length > 0 && value.length <= PLUGIN_INTEGRATION_PROVIDER_ID_MAX_LENGTH; } +/** Narrows a secret provider config to the plugin-integration exec shape. */ export function isPluginIntegrationSecretProviderConfig( value: unknown, ): value is PluginIntegrationSecretProviderConfig { @@ -312,6 +319,7 @@ export function isPluginIntegrationSecretProviderConfig( ); } +/** Materializes an active trusted plugin secret-provider integration into an exec provider. */ export function resolveSecretProviderIntegrationConfig(params: { manifestRegistry: Pick; providerAlias: string; @@ -362,6 +370,7 @@ export function resolveSecretProviderIntegrationConfig(params: { }; } +/** Lists plugin secret-provider presets available to interactive configure flows. */ export function listSecretProviderIntegrationPresets(params: { manifestRegistry: Pick; config?: OpenClawConfig; diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts index ddef2347d7df..a5d62f2a314b 100644 --- a/src/secrets/ref-contract.ts +++ b/src/secrets/ref-contract.ts @@ -4,18 +4,30 @@ import { type SecretRefSource, } from "../config/types.secrets.js"; +/** + * Runtime secret-reference grammar shared by config parsing, plugin SDK schemas, + * gateway parity checks, and resolver planning. + */ + const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; +/** Shared alias grammar for env/file/exec secret provider names. */ export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/; +/** Canonical id for file secret providers that expose exactly one value. */ export const SINGLE_VALUE_FILE_REF_ID = "value"; +/** JSON-schema fragment that rejects absolute file secret ref ids. */ export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/"; +/** JSON-schema fragment that rejects invalid JSON-pointer escape sequences. */ export const FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN = "~(?:[^01]|$)"; +/** JSON-schema pattern for exec secret ref ids, excluding dot-path traversal. */ export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN = "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$"; +/** Failure class returned when an exec secret ref id is syntactically invalid. */ export type ExecSecretRefIdValidationReason = "pattern" | "traversal-segment"; +/** Result for callers that need to distinguish grammar failures from traversal attempts. */ export type ExecSecretRefIdValidationResult = | { ok: true } | { @@ -23,21 +35,30 @@ export type ExecSecretRefIdValidationResult = reason: ExecSecretRefIdValidationReason; }; +/** Minimal config shape needed to resolve default provider aliases for a secret source. */ export type SecretRefDefaultsCarrier = { + /** Secrets config subset; callers pass full config objects or narrow test doubles. */ secrets?: { + /** Explicit per-source provider aliases selected by the operator. */ defaults?: { + /** Default provider alias for environment-variable secret refs. */ env?: string; + /** Default provider alias for file-backed secret refs. */ file?: string; + /** Default provider alias for exec-backed secret refs. */ exec?: string; }; + /** Provider declarations used only when callers ask to prefer the first matching source. */ providers?: Record; }; }; +/** Builds the stable map key used to cache or compare resolved secret refs. */ export function secretRefKey(ref: SecretRef): string { return `${ref.source}:${ref.provider}:${ref.id}`; } +/** Resolves the default provider alias for one source, falling back to the built-in alias. */ export function resolveDefaultSecretProviderAlias( config: SecretRefDefaultsCarrier, source: SecretRefSource, @@ -56,6 +77,8 @@ export function resolveDefaultSecretProviderAlias( if (options?.preferFirstProviderForSource) { const providers = config.secrets?.providers; if (providers) { + // Preserve config insertion order: interactive setup uses this as a + // deterministic fallback only when no explicit source default exists. for (const [providerName, provider] of Object.entries(providers)) { if (provider?.source === source) { return providerName; @@ -67,6 +90,7 @@ export function resolveDefaultSecretProviderAlias( return DEFAULT_SECRET_PROVIDER_ALIAS; } +/** Validates file secret ref ids against the shared JSON-pointer-style contract. */ export function isValidFileSecretRefId(value: string): boolean { if (value === SINGLE_VALUE_FILE_REF_ID) { return true; @@ -74,20 +98,26 @@ export function isValidFileSecretRefId(value: string): boolean { if (!value.startsWith("/")) { return false; } + // File refs mirror JSON Pointer segment escaping; keep this in parity with gateway/schema + // patterns so config, plugin SDK, and remote gateway validation accept the same ids. return value .slice(1) .split("/") .every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment)); } +/** Validates a secret provider alias against the shared config/gateway grammar. */ export function isValidSecretProviderAlias(value: string): boolean { return SECRET_PROVIDER_ALIAS_PATTERN.test(value); } +/** Validates exec secret ref ids and reports why invalid ids failed. */ export function validateExecSecretRefId(value: string): ExecSecretRefIdValidationResult { if (!EXEC_SECRET_REF_ID_PATTERN.test(value)) { return { ok: false, reason: "pattern" }; } + // The JSON schema uses a negative lookahead for traversal. Runtime validation keeps the same + // rule explicit so UI/doctor flows can explain the safer failure class. for (const segment of value.split("/")) { if (segment === "." || segment === "..") { return { ok: false, reason: "traversal-segment" }; @@ -96,10 +126,12 @@ export function validateExecSecretRefId(value: string): ExecSecretRefIdValidatio return { ok: true }; } +/** Boolean convenience wrapper for callers that only need accept/reject behavior. */ export function isValidExecSecretRefId(value: string): boolean { return validateExecSecretRefId(value).ok; } +/** Formats the user-facing validation message for rejected exec secret ref ids. */ export function formatExecSecretRefIdValidationMessage(): string { return [ "Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/", diff --git a/src/secrets/resolve-secret-input-string.ts b/src/secrets/resolve-secret-input-string.ts index 6f80868a968d..d20b18aa661c 100644 --- a/src/secrets/resolve-secret-input-string.ts +++ b/src/secrets/resolve-secret-input-string.ts @@ -8,12 +8,22 @@ import { resolveSecretRefString } from "./resolve.js"; type SecretDefaults = NonNullable["defaults"]; +/** + * Resolves a config value that may be either an inline string or a SecretRef object. + * + * Plugin and gateway callers can override normalization and convert SecretRef resolution errors + * into surface-specific failures without duplicating provider lookup behavior. + */ export async function resolveSecretInputString(params: { config: OpenClawConfig; + /** Inline string, SecretInput object, or SecretRef object from config/plugin settings. */ value: unknown; env: NodeJS.ProcessEnv; + /** SecretRef defaults used when `value` omits source/provider aliases. */ defaults?: SecretDefaults; + /** Surface-specific normalization for resolved or inline values. */ normalize?: (value: unknown) => string | undefined; + /** Converts provider resolution failures into caller-specific errors. */ onResolveRefError?: (error: unknown, ref: SecretRef) => never; }): Promise { const normalize = params.normalize ?? normalizeSecretInputString; diff --git a/src/secrets/resolve-types.ts b/src/secrets/resolve-types.ts index feec3f595ada..19c892d4a6c0 100644 --- a/src/secrets/resolve-types.ts +++ b/src/secrets/resolve-types.ts @@ -1,4 +1,7 @@ +/** Shared per-runtime cache for resolved SecretRefs and file provider payloads. */ export type SecretRefResolveCache = { + /** In-flight or completed resolution promise keyed by `secretRefKey(ref)`. */ resolvedByRefKey?: Map>; + /** In-flight or completed parsed file-provider payload keyed by provider alias. */ filePayloadByProvider?: Map>; }; diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index a7eacb75f12b..d977101915c1 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -68,6 +68,7 @@ type ResolutionLimits = { type ProviderResolutionOutput = Map; +/** Error for failures that affect an entire configured secret provider. */ export class SecretProviderResolutionError extends Error { readonly scope = "provider" as const; readonly source: SecretRefSource; @@ -86,6 +87,7 @@ export class SecretProviderResolutionError extends Error { } } +/** Error for failures limited to one SecretRef id under a provider. */ export class SecretRefResolutionError extends Error { readonly scope = "ref" as const; readonly source: SecretRefSource; @@ -107,6 +109,7 @@ export class SecretRefResolutionError extends Error { } } +/** Type guard for provider-scoped secret resolution failures. */ export function isProviderScopedSecretResolutionError( value: unknown, ): value is SecretProviderResolutionError { @@ -366,6 +369,8 @@ async function readFileProviderPayload(params: { })(); if (cache) { + // Cache the in-flight read, not just the fulfilled payload, so concurrent refs share one + // permission-checked file read and observe the same provider error. cache.filePayloadByProvider ??= new Map(); cache.filePayloadByProvider.set(cacheKey, readPromise); } @@ -871,6 +876,7 @@ async function resolveProviderRefs(params: { } } +/** Resolves a batch of SecretRefs, grouped by provider for bounded provider concurrency. */ export async function resolveSecretRefValues( refs: SecretRef[], options: ResolveSecretRefOptions, @@ -898,6 +904,8 @@ export async function resolveSecretRefValues( { source: SecretRefSource; providerName: string; refs: SecretRef[] } >(); for (const ref of uniqueRefs.values()) { + // Provider calls are batched by source/provider so exec providers receive one request for + // many ids and file providers parse once per payload. const key = toProviderKey(ref.source, ref.provider); const existing = grouped.get(key); if (existing) { @@ -960,6 +968,7 @@ export async function resolveSecretRefValues( return resolved; } +/** Resolves one SecretRef, using the optional shared runtime cache. */ export async function resolveSecretRefValue( ref: SecretRef, options: ResolveSecretRefOptions, @@ -985,12 +994,14 @@ export async function resolveSecretRefValue( })(); if (cache) { + // Store the in-flight promise so repeated callers do not race duplicate provider work. cache.resolvedByRefKey ??= new Map(); cache.resolvedByRefKey.set(key, promise); } return await promise; } +/** Resolves one SecretRef and requires a non-empty string result. */ export async function resolveSecretRefString( ref: SecretRef, options: ResolveSecretRefOptions, diff --git a/src/secrets/runtime-auth-collectors.ts b/src/secrets/runtime-auth-collectors.ts index 38649cc2e70a..36e5a9cb756d 100644 --- a/src/secrets/runtime-auth-collectors.ts +++ b/src/secrets/runtime-auth-collectors.ts @@ -40,6 +40,8 @@ function collectApiKeyProfileAssignment(params: { if (!resolvedKeyRef) { return; } + // Inline SecretRefs are normalized into keyRef so runtime snapshots preserve the + // explicit auth-profile ref surface instead of leaving a template string in key. if (!keyRef && inlineKeyRef) { params.profile.keyRef = inlineKeyRef; } @@ -79,6 +81,8 @@ function collectTokenProfileAssignment(params: { if (!resolvedTokenRef) { return; } + // Token profiles follow the same precedence contract as API keys: explicit refs win over + // plaintext and inline refs are promoted to the dedicated ref field. if (!tokenRef && inlineTokenRef) { params.profile.tokenRef = inlineTokenRef; } @@ -99,6 +103,7 @@ function collectTokenProfileAssignment(params: { }); } +/** Collects SecretRef assignments from agent auth-profile stores for runtime materialization. */ export function collectAuthStoreAssignments(params: { store: AuthProfileStore; context: ResolverContext; diff --git a/src/secrets/runtime-auth.integration.test-helpers.ts b/src/secrets/runtime-auth.integration.test-helpers.ts index 4e9f8e15e80e..81a39ea80e0f 100644 --- a/src/secrets/runtime-auth.integration.test-helpers.ts +++ b/src/secrets/runtime-auth.integration.test-helpers.ts @@ -2,6 +2,8 @@ import { vi } from "vitest"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { captureEnv } from "../test-utils/env.js"; import type { SecretsRuntimeEnvSnapshot } from "./runtime-openai-file-fixture.test-helper.js"; + +/** Shared integration helpers for auth-profile backed secrets runtime tests. */ export { asConfig, createOpenAIFileRuntimeConfig, @@ -20,6 +22,8 @@ const secretsRuntimePluginMocks = vi.hoisted(() => ({ resolvePluginWebSearchProvidersMock: vi.fn(() => []), })); +// Mock plugin-provided auth/web surfaces so auth integration tests only cover +// the configured stores and fixtures they explicitly install. vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: secretsRuntimePluginMocks.resolvePluginWebSearchProvidersMock, })); @@ -29,6 +33,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ secretsRuntimePluginMocks.resolveExternalAuthProfilesWithPluginsMock, })); +/** Start an isolated secrets runtime test with plugin auth/web discovery disabled. */ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot { secretsRuntimePluginMocks.resolveExternalAuthProfilesWithPluginsMock.mockReset(); secretsRuntimePluginMocks.resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); @@ -44,6 +49,7 @@ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot return envSnapshot; } +/** Restore env, mocks, config caches, and secrets runtime snapshot state. */ export function endSecretsRuntimeIsolationForTest(envSnapshot: SecretsRuntimeEnvSnapshot) { vi.restoreAllMocks(); envSnapshot.restore(); diff --git a/src/secrets/runtime-channel-inactive-variants.test-support.ts b/src/secrets/runtime-channel-inactive-variants.test-support.ts index 836477547188..03e6a5c70b98 100644 --- a/src/secrets/runtime-channel-inactive-variants.test-support.ts +++ b/src/secrets/runtime-channel-inactive-variants.test-support.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for inactive channel secret surface variants. */ const googleChatSecrets = loadBundledChannelSecretContractApi("googlechat"); const ircSecrets = loadBundledChannelSecretContractApi("irc"); const slackSecrets = loadBundledChannelSecretContractApi("slack"); @@ -30,6 +31,8 @@ function resolveAssignments(id: string) { return undefined; } +// Runtime collectors resolve bootstrap channel contracts by id. This mock keeps +// the tested inactive variants on real bundled contracts without loading plugins. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => { const collectRuntimeConfigAssignments = resolveAssignments(id); diff --git a/src/secrets/runtime-command-secrets.ts b/src/secrets/runtime-command-secrets.ts index 93b0dd92ea46..83197d786e68 100644 --- a/src/secrets/runtime-command-secrets.ts +++ b/src/secrets/runtime-command-secrets.ts @@ -19,8 +19,11 @@ import { discoverConfigSecretTargetsByIds } from "./target-registry.js"; export type { CommandSecretAssignment } from "./command-config.js"; +/** Provider selections applied only while resolving command-scoped web secrets. */ export type CommandSecretProviderOverrides = { + /** Temporary web-search provider id for this command request. */ webSearch?: string; + /** Temporary web-fetch provider id for this command request. */ webFetch?: string; }; @@ -179,6 +182,8 @@ function restoreInactiveWebCommandSecretTargets(params: { if (!isWebCommandSecretPath(target.path)) { continue; } + // Provider overrides can make a web SecretRef active for this command only. Other web refs + // must be restored from source config so assignment analysis keeps them inactive. const { ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, @@ -260,6 +265,8 @@ function mirrorResolvedProviderCredentialToDirectPath(params: { if (directValue === undefined) { return; } + // Legacy direct provider targets still exist for command assignment discovery; mirror the + // plugin-owned resolved value only when the source config declares that direct path. const resolvedValue = getPath(params.resolvedConfig, [ "plugins", "entries", @@ -392,11 +399,20 @@ async function resolveForcedActiveCommandSecretTargets(params: { } } +/** + * Resolves command-scoped SecretRef assignments from the active runtime snapshot. + * Provider overrides are evaluated against cloned snapshot config. + */ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: { + /** Command name used in diagnostics returned to gateway/tool callers. */ commandName: string; + /** Secret target registry ids the command is allowed to resolve. */ targetIds: ReadonlySet; + /** Optional exact config paths allowed inside `targetIds`. */ allowedPaths?: ReadonlySet; + /** Inactive paths to force active because command-local provider overrides select them. */ forcedActivePaths?: ReadonlySet; + /** Inactive paths that may stay unresolved without diagnostics. */ optionalActivePaths?: ReadonlySet; providerOverrides?: CommandSecretProviderOverrides; }): Promise<{ diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index a6739056dcfe..6caa358e564e 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -4,10 +4,13 @@ import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import { loadChannelSecretContractApi } from "./channel-contract-api.js"; import type { ResolverContext, SecretDefaults } from "./runtime-shared.js"; +/** Collects SecretRef assignments declared by active channel/plugin channel contracts. */ export function collectChannelConfigAssignments(params: { config: OpenClawConfig; + /** Defaults from the source config, used before assignment writes mutate config. */ defaults: SecretDefaults | undefined; context: ResolverContext; + /** Optional installed plugin roots for external channel contract loading. */ loadablePluginOrigins?: ReadonlyMap; }): void { const channelIds = Object.keys(params.config.channels ?? {}); @@ -24,6 +27,7 @@ export function collectChannelConfigAssignments(params: { const collectRuntimeConfigAssignments = contract?.collectRuntimeConfigAssignments ?? getBootstrapChannelSecrets(channelId)?.collectRuntimeConfigAssignments; + // Bootstrap contracts cover built-in channels before plugin contract loading is available. collectRuntimeConfigAssignments?.(params); } } diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 983a18b2c8a2..b19dd07848cf 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -378,6 +378,8 @@ function collectProviderRequestAssignments(params: { }; if (params.collectTransportSecrets !== false) { + // Transport credentials can live below direct TLS or proxy TLS config; model-provider + // request surfaces opt out when those nested transport secrets are owned elsewhere. collectTlsAssignments( isRecord(params.request.tls) ? params.request.tls : undefined, `${params.pathPrefix}.tls`, @@ -440,6 +442,8 @@ function collectMediaRequestAssignments(params: { collectModelAssignments(media.models, "tools.media.models", (rawModel) => { const entry = rawModel as MediaUnderstandingModelConfig; const configuredCapabilities = resolveConfiguredMediaEntryCapabilities(entry); + // Shared models are active only for enabled capabilities; when the config omits explicit + // capabilities, provider metadata is the contract for which media sections can use it. const capabilities = configuredCapabilities ?? resolveEffectiveMediaEntryCapabilities({ @@ -605,6 +609,7 @@ function collectSandboxSshAssignments(params: { }, }); } else if (active) { + // Defaults are active when at least one enabled SSH agent inherits this material. inheritedDefaultsUsage[key] = true; } } @@ -635,6 +640,7 @@ function collectSandboxSshAssignments(params: { } } +/** Collects SecretRef assignments from core-owned config surfaces. */ export function collectCoreConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index c3fac2351ef5..6383cad103d0 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -31,9 +31,13 @@ function parsePluginConfigArrayIndex(segment: string): number | undefined { * non-loadable plugins from blocking startup or preflight validation. */ export function collectPluginConfigAssignments(params: { + /** Mutable config snapshot whose plugin config values will receive resolved secrets. */ config: OpenClawConfig; + /** Defaults from the source config, used while matching manifest-declared SecretInput paths. */ defaults: SecretDefaults | undefined; + /** Resolver context that receives assignments and inactive-surface warnings. */ context: ResolverContext; + /** Optional installed plugin roots; missing IDs are treated as stale inactive config. */ loadablePluginOrigins?: ReadonlyMap; }): void { const entries = params.config.plugins?.entries; @@ -167,6 +171,7 @@ function createPluginConfigAssignmentApply( relativePath: string, ): (value: unknown) => void { return (value) => { + // Manifest paths use dotted/bracket notation; assignment writes need concrete object/array steps. const segments = normalizeStringEntries(relativePath.replace(/\[(\d+)\]/g, ".$1").split(".")); if (segments.length === 0) { return; diff --git a/src/secrets/runtime-config-collectors-tts.ts b/src/secrets/runtime-config-collectors-tts.ts index 32eff5fd09db..12c0c0abc302 100644 --- a/src/secrets/runtime-config-collectors-tts.ts +++ b/src/secrets/runtime-config-collectors-tts.ts @@ -28,6 +28,7 @@ function collectProviderApiKeyAssignment(params: { }); } +/** Collects provider API key SecretRefs from a TTS config block. */ export function collectTtsApiKeyAssignments(params: { tts: Record; pathPrefix: string; diff --git a/src/secrets/runtime-config-collectors.ts b/src/secrets/runtime-config-collectors.ts index 342fbb43aaa9..7e0ee3dc9355 100644 --- a/src/secrets/runtime-config-collectors.ts +++ b/src/secrets/runtime-config-collectors.ts @@ -5,9 +5,13 @@ import { collectCoreConfigAssignments } from "./runtime-config-collectors-core.j import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js"; import type { ResolverContext } from "./runtime-shared.js"; +/** Collects every config-backed SecretRef assignment before runtime values are materialized. */ export function collectConfigAssignments(params: { + /** Mutable config snapshot that resolved secret values will be written back into. */ config: OpenClawConfig; + /** Resolver context carrying source config, env, cache, assignments, and warnings. */ context: ResolverContext; + /** Optional installed plugin roots for channel/plugin contract lookup. */ loadablePluginOrigins?: ReadonlyMap; }): void { const defaults = params.context.sourceConfig.secrets?.defaults; diff --git a/src/secrets/runtime-discord.test-support.ts b/src/secrets/runtime-discord.test-support.ts index d7019d62f608..8ab9cf2cf9bf 100644 --- a/src/secrets/runtime-discord.test-support.ts +++ b/src/secrets/runtime-discord.test-support.ts @@ -1,12 +1,14 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for Discord secret surface tests. */ const discordSecrets = loadBundledChannelSecretContractApi("discord"); if (!discordSecrets?.collectRuntimeConfigAssignments) { throw new Error("Missing Discord secret contract api"); } const discordAssignments = discordSecrets.collectRuntimeConfigAssignments; +// Use the real bundled Discord secret contract while avoiding plugin bootstrap. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => id === "discord" diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index 59eebda7b773..809a5def534d 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -37,6 +37,9 @@ const RUNTIME_PATH_ENV_KEYS = [ "OPENCLAW_TEST_FAST", ] as const; +/** + * Merges caller env with process path env needed for config and agent-dir resolution. + */ export function mergeSecretsRuntimeEnv( env: NodeJS.ProcessEnv | Record | undefined, ): Record { @@ -45,6 +48,7 @@ export function mergeSecretsRuntimeEnv( if (merged[key] !== undefined) { continue; } + // Tests often pass narrow env objects; path resolution still needs host path variables. const processValue = process.env[key]; if (processValue !== undefined) { merged[key] = processValue; @@ -53,6 +57,9 @@ export function mergeSecretsRuntimeEnv( return merged; } +/** + * Collects default and named agent directories that may contain auth profile stores. + */ export function collectCandidateAgentDirs( config: OpenClawConfig, env: NodeJS.ProcessEnv | Record = process.env, @@ -65,6 +72,9 @@ export function collectCandidateAgentDirs( return [...dirs]; } +/** + * Combines explicit refresh agent dirs with config-derived dirs for runtime refresh. + */ export function resolveRefreshAgentDirs( config: OpenClawConfig, context: SecretsRuntimeRefreshContext, @@ -94,6 +104,9 @@ function hasCandidateAuthProfileStoreSource(agentDir: string): boolean { ); } +/** + * Returns whether auth profile files or OAuth state exist for candidate agent dirs. + */ export function hasCandidateAuthProfileStoreSources(params: { config: OpenClawConfig; env: NodeJS.ProcessEnv | Record; @@ -108,6 +121,9 @@ export function hasCandidateAuthProfileStoreSources(params: { ); } +/** + * Creates empty web-tool metadata for snapshots that do not need secret resolution. + */ export function createEmptyRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata { return { search: { @@ -178,6 +194,9 @@ function hasRuntimeWebToolConfigSurface(config: OpenClawConfig): boolean { }); } +/** + * Returns whether a snapshot can skip full SecretRef/web-tool resolution. + */ export function canUseSecretsRuntimeFastPath(params: { sourceConfig: OpenClawConfig; authStores: Array<{ agentDir: string; store: AuthProfileStore }>; @@ -192,6 +211,9 @@ export function canUseSecretsRuntimeFastPath(params: { return !params.authStores.some((entry) => hasSecretRefCandidate(entry.store, defaults)); } +/** + * Prepares a runtime snapshot without resolving refs when config and auth stores contain none. + */ export function prepareSecretsRuntimeFastPathSnapshot(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; diff --git a/src/secrets/runtime-gateway-auth-surfaces.ts b/src/secrets/runtime-gateway-auth-surfaces.ts index bdef895a2af1..a9b396ab98ba 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.ts @@ -3,6 +3,7 @@ import { createGatewayCredentialPlan } from "../gateway/credential-planner.js"; import type { SecretDefaults } from "./runtime-shared.js"; import { isRecord } from "./shared.js"; +/** Stable evaluation order for gateway credential surfaces that may hold SecretRefs. */ export const GATEWAY_AUTH_SURFACE_PATHS = [ "gateway.auth.token", "gateway.auth.password", @@ -12,6 +13,7 @@ export const GATEWAY_AUTH_SURFACE_PATHS = [ export type GatewayAuthSurfacePath = (typeof GATEWAY_AUTH_SURFACE_PATHS)[number]; +/** Active/inactive decision for one gateway credential SecretRef surface. */ export type GatewayAuthSurfaceState = { path: GatewayAuthSurfacePath; active: boolean; @@ -19,6 +21,7 @@ export type GatewayAuthSurfaceState = { hasSecretRef: boolean; }; +/** Complete state map keyed by every known gateway credential surface path. */ export type GatewayAuthSurfaceStateMap = Record; function formatAuthMode(mode: string | undefined): string { @@ -57,6 +60,7 @@ function createState(params: { }; } +/** Evaluates which gateway credential SecretRefs can affect the effective auth plan. */ export function evaluateGatewayAuthSurfaceStates(params: { config: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -171,6 +175,8 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (plan.remoteTokenFallbackActive) { return "local token auth can win and no env/auth token is configured."; } + // Remote credentials also act as local auth fallbacks when no stronger source wins. + // Keep fallback diagnostics separate from explicit remote exposure diagnostics. if (!plan.localTokenCanWin) { return `token auth cannot win with gateway.auth.mode="${formatAuthMode(plan.authMode)}".`; } @@ -193,6 +199,8 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (plan.remotePasswordFallbackActive) { return "password auth can win and no env/auth password is configured."; } + // Password fallback is suppressed by token-capable modes and stronger local sources. + // The inactive reason feeds audit warnings, so report the winning auth decision. if (!plan.passwordCanWin) { if ( plan.authMode === "token" || diff --git a/src/secrets/runtime-manifest.runtime.ts b/src/secrets/runtime-manifest.runtime.ts index e55e8b2b322b..1c74af018813 100644 --- a/src/secrets/runtime-manifest.runtime.ts +++ b/src/secrets/runtime-manifest.runtime.ts @@ -1,3 +1,7 @@ +/** + * Lazy runtime facade for plugin metadata snapshot reads used by secrets runtime. + * Isolating it keeps tests able to mock manifest discovery without loading plugins. + */ export { listPluginOriginsFromMetadataSnapshot, loadPluginMetadataSnapshot, diff --git a/src/secrets/runtime-matrix.test-support.ts b/src/secrets/runtime-matrix.test-support.ts index a529553c8968..773165e1110f 100644 --- a/src/secrets/runtime-matrix.test-support.ts +++ b/src/secrets/runtime-matrix.test-support.ts @@ -1,12 +1,14 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for Matrix secret surface tests. */ const matrixSecrets = loadBundledChannelSecretContractApi("matrix"); if (!matrixSecrets?.collectRuntimeConfigAssignments) { throw new Error("Missing Matrix secret contract api"); } const matrixAssignments = matrixSecrets.collectRuntimeConfigAssignments; +// Use the real bundled Matrix secret contract while avoiding plugin bootstrap. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => id === "matrix" diff --git a/src/secrets/runtime-nextcloud-talk.test-support.ts b/src/secrets/runtime-nextcloud-talk.test-support.ts index c1e7c1427de1..6046eed3c4dc 100644 --- a/src/secrets/runtime-nextcloud-talk.test-support.ts +++ b/src/secrets/runtime-nextcloud-talk.test-support.ts @@ -1,12 +1,14 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for Nextcloud Talk secret surface tests. */ const nextcloudTalkSecrets = loadBundledChannelSecretContractApi("nextcloud-talk"); if (!nextcloudTalkSecrets?.collectRuntimeConfigAssignments) { throw new Error("Missing Nextcloud Talk secret contract api"); } const nextcloudTalkAssignments = nextcloudTalkSecrets.collectRuntimeConfigAssignments; +// Use the real bundled Nextcloud Talk contract while avoiding plugin bootstrap. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => id === "nextcloud-talk" diff --git a/src/secrets/runtime-prepare.runtime.ts b/src/secrets/runtime-prepare.runtime.ts index 2712b4bb8ea1..7cc83aaff815 100644 --- a/src/secrets/runtime-prepare.runtime.ts +++ b/src/secrets/runtime-prepare.runtime.ts @@ -1,3 +1,7 @@ +/** + * Lazy runtime facade for preparing a secrets snapshot. Runtime callers import + * this compact boundary to avoid pulling CLI/configure-only helpers. + */ export { resolveSecretRefValues } from "./resolve.js"; export { collectAuthStoreAssignments } from "./runtime-auth-collectors.js"; export { collectConfigAssignments } from "./runtime-config-collectors.js"; diff --git a/src/secrets/runtime-secret-scan.ts b/src/secrets/runtime-secret-scan.ts index ceebbb10fab1..2aa94c042d32 100644 --- a/src/secrets/runtime-secret-scan.ts +++ b/src/secrets/runtime-secret-scan.ts @@ -1,6 +1,7 @@ import { coerceSecretRef } from "../config/types.secrets.js"; import type { SecretDefaults } from "./runtime-shared.js"; +/** Field names treated as credential-bearing even before a value is converted to SecretRef. */ const CREDENTIAL_FIELD_NAMES = new Set(["apikey", "key", "token", "secret", "password"]); function hasRecursiveSecretValue(params: { @@ -16,6 +17,7 @@ function hasRecursiveSecretValue(params: { return false; } if (params.seen.has(params.value)) { + // Config-like objects can be caller-constructed; avoid cycles while scanning recursively. return false; } params.seen.add(params.value); @@ -30,6 +32,10 @@ function hasRecursiveSecretValue(params: { }); } +/** + * Returns whether a value tree contains anything coercible to a SecretRef. + * `seen` may be shared across sibling probes to preserve cycle safety. + */ export function hasSecretRefCandidate( value: unknown, defaults: SecretDefaults | undefined, @@ -38,6 +44,10 @@ export function hasSecretRefCandidate( return hasRecursiveSecretValue({ value, defaults, seen }); } +/** + * Returns whether a value tree contains SecretRefs or non-empty credential-looking fields. + * Used before runtime fast-paths so enabled web tools do not skip secret-aware preparation. + */ export function hasCredentialBearingObjectValue( value: unknown, defaults: SecretDefaults | undefined, diff --git a/src/secrets/runtime-shared.ts b/src/secrets/runtime-shared.ts index 0d969c42a292..6c007ddd914f 100644 --- a/src/secrets/runtime-shared.ts +++ b/src/secrets/runtime-shared.ts @@ -44,6 +44,9 @@ export type ResolverContext = { export type SecretDefaults = NonNullable["defaults"]; export type { SecretRefResolveCache } from "./resolve-types.js"; +/** + * Creates the mutable collection context used while preparing a secrets runtime snapshot. + */ export function createResolverContext(params: { sourceConfig: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -60,10 +63,16 @@ export function createResolverContext(params: { }; } +/** + * Records a SecretRef assignment that should be resolved and applied later. + */ export function pushAssignment(context: ResolverContext, assignment: SecretAssignment): void { context.assignments.push(assignment); } +/** + * Records a resolver warning once per code/path/message tuple. + */ export function pushWarning(context: ResolverContext, warning: SecretResolverWarning): void { const warningKey = `${warning.code}:${warning.path}:${warning.message}`; if (context.warningKeys.has(warningKey)) { @@ -73,6 +82,9 @@ export function pushWarning(context: ResolverContext, warning: SecretResolverWar context.warnings.push(warning); } +/** + * Emits the standard warning for refs configured on currently inactive surfaces. + */ export function pushInactiveSurfaceWarning(params: { context: ResolverContext; path: string; @@ -88,6 +100,9 @@ export function pushInactiveSurfaceWarning(params: { }); } +/** + * Converts an inline SecretInput value into a deferred assignment when its surface is active. + */ export function collectSecretInputAssignment(params: { value: unknown; path: string; @@ -118,6 +133,9 @@ export function collectSecretInputAssignment(params: { }); } +/** + * Applies resolved SecretRef values to their collected config targets with shape validation. + */ export function applyResolvedAssignments(params: { assignments: SecretAssignment[]; resolved: Map; @@ -140,10 +158,16 @@ export function applyResolvedAssignments(params: { } } +/** + * Own-property helper used by config collectors that receive unknown object shapes. + */ export function hasOwnProperty(record: Record, key: string): boolean { return Object.hasOwn(record, key); } +/** + * Treats missing or non-object enabled state as enabled by default. + */ export function isEnabledFlag(value: unknown): boolean { if (!isRecord(value)) { return true; @@ -151,6 +175,9 @@ export function isEnabledFlag(value: unknown): boolean { return value.enabled !== false; } +/** + * Returns whether both a channel and one account are enabled for secret resolution. + */ export function isChannelAccountEffectivelyEnabled( channel: Record, account: Record, diff --git a/src/secrets/runtime-state.ts b/src/secrets/runtime-state.ts index f86250d06f54..6892682ec10e 100644 --- a/src/secrets/runtime-state.ts +++ b/src/secrets/runtime-state.ts @@ -46,6 +46,9 @@ const preparedSnapshotRefreshContext = new WeakMap< SecretsRuntimeRefreshContext >(); +/** + * Clones refresh context while preserving callback identity and isolating mutable maps/config. + */ export function cloneSecretsRuntimeRefreshContext( context: SecretsRuntimeRefreshContext, ): SecretsRuntimeRefreshContext { @@ -77,6 +80,9 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret }; } +/** + * Associates a prepared snapshot with the refresh context needed after activation. + */ export function setPreparedSecretsRuntimeSnapshotRefreshContext( snapshot: PreparedSecretsRuntimeSnapshot, context: SecretsRuntimeRefreshContext, @@ -84,6 +90,9 @@ export function setPreparedSecretsRuntimeSnapshotRefreshContext( preparedSnapshotRefreshContext.set(snapshot, cloneSecretsRuntimeRefreshContext(context)); } +/** + * Returns the refresh context stored for a prepared snapshot, if any. + */ export function getPreparedSecretsRuntimeSnapshotRefreshContext( snapshot: PreparedSecretsRuntimeSnapshot, ): SecretsRuntimeRefreshContext | null { @@ -91,20 +100,32 @@ export function getPreparedSecretsRuntimeSnapshotRefreshContext( return context ? cloneSecretsRuntimeRefreshContext(context) : null; } +/** + * Returns the active refresh context without exposing mutable runtime state. + */ export function getActiveSecretsRuntimeRefreshContext(): SecretsRuntimeRefreshContext | null { return activeRefreshContext ? cloneSecretsRuntimeRefreshContext(activeRefreshContext) : null; } +/** + * Returns the env used by the active runtime snapshot, falling back to process env. + */ export function getActiveSecretsRuntimeEnv(): NodeJS.ProcessEnv { return { ...(activeRefreshContext?.env ?? process.env), } as NodeJS.ProcessEnv; } +/** + * Registers cleanup hooks that run whenever the active secrets runtime snapshot is cleared. + */ export function registerSecretsRuntimeStateClearHook(clearHook: () => void): void { clearHooks.add(clearHook); } +/** + * Atomically activates a prepared secrets snapshot across config, auth-store, and web-tool state. + */ export function activateSecretsRuntimeSnapshotState(params: { snapshot: PreparedSecretsRuntimeSnapshot; refreshContext: SecretsRuntimeRefreshContext | null; @@ -125,6 +146,9 @@ export function activateSecretsRuntimeSnapshotState(params: { setRuntimeConfigSnapshotRefreshHandler(params.refreshHandler); } +/** + * Returns a cloned active secrets runtime snapshot for callers that need mutable data. + */ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null { if (!activeSnapshot) { return null; @@ -155,6 +179,9 @@ export function getActiveSecretsRuntimeConfigSnapshot(): Pick< }; } +/** + * Returns current auth stores, preferring live auth-store snapshots over activation-time clones. + */ export function getLiveSecretsRuntimeAuthStores(): PreparedSecretsRuntimeSnapshot["authStores"] { if (!activeSnapshot) { return []; @@ -165,6 +192,9 @@ export function getLiveSecretsRuntimeAuthStores(): PreparedSecretsRuntimeSnapsho })); } +/** + * Clears active secrets runtime state and all linked config/auth/web-tool snapshots. + */ export function clearSecretsRuntimeSnapshot(): void { activeSnapshot = null; activeRefreshContext = null; diff --git a/src/secrets/runtime-telegram.test-support.ts b/src/secrets/runtime-telegram.test-support.ts index 411d9482edb0..cf8781b729bf 100644 --- a/src/secrets/runtime-telegram.test-support.ts +++ b/src/secrets/runtime-telegram.test-support.ts @@ -1,12 +1,14 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for Telegram secret surface tests. */ const telegramSecrets = loadBundledChannelSecretContractApi("telegram"); if (!telegramSecrets?.collectRuntimeConfigAssignments) { throw new Error("Missing Telegram secret contract api"); } const telegramAssignments = telegramSecrets.collectRuntimeConfigAssignments; +// Use the real bundled Telegram secret contract while avoiding plugin bootstrap. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => id === "telegram" diff --git a/src/secrets/runtime-web-tools-fallback.runtime.ts b/src/secrets/runtime-web-tools-fallback.runtime.ts index f84763ff22c7..2e0e5a8662f4 100644 --- a/src/secrets/runtime-web-tools-fallback.runtime.ts +++ b/src/secrets/runtime-web-tools-fallback.runtime.ts @@ -1,6 +1,7 @@ import { resolvePluginWebFetchProviders } from "../plugins/web-fetch-providers.runtime.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; +/** Lazy-loaded provider discovery fallback used when public artifacts cannot prove the surface. */ export const runtimeWebToolsFallbackProviders = { resolvePluginWebFetchProviders, resolvePluginWebSearchProviders, diff --git a/src/secrets/runtime-web-tools-manifest.runtime.ts b/src/secrets/runtime-web-tools-manifest.runtime.ts index 149c82b6737f..e6553384b369 100644 --- a/src/secrets/runtime-web-tools-manifest.runtime.ts +++ b/src/secrets/runtime-web-tools-manifest.runtime.ts @@ -1,3 +1,7 @@ +/** + * Lazy manifest-registry facade for runtime web tool secret discovery. Keeps + * runtime tests able to replace manifest ownership without importing registry internals. + */ export { resolveManifestContractOwnerPluginId, resolveManifestContractPluginIds, diff --git a/src/secrets/runtime-web-tools-public-artifacts.runtime.ts b/src/secrets/runtime-web-tools-public-artifacts.runtime.ts index 817b1cd9afec..cf442c4d46a9 100644 --- a/src/secrets/runtime-web-tools-public-artifacts.runtime.ts +++ b/src/secrets/runtime-web-tools-public-artifacts.runtime.ts @@ -1,3 +1,7 @@ +/** + * Lazy public-artifact facade for bundled web provider metadata used by secrets runtime. + * This boundary avoids loading plugin packages just to inspect web tool surfaces. + */ export { resolveBundledWebFetchProvidersFromPublicArtifacts, resolveBundledWebSearchProvidersFromPublicArtifacts, diff --git a/src/secrets/runtime-web-tools-state.ts b/src/secrets/runtime-web-tools-state.ts index 8c177788f81a..8c4bfb862db2 100644 --- a/src/secrets/runtime-web-tools-state.ts +++ b/src/secrets/runtime-web-tools-state.ts @@ -2,14 +2,23 @@ import type { RuntimeWebToolsMetadata } from "./runtime-web-tools.types.js"; let activeRuntimeWebToolsMetadata: RuntimeWebToolsMetadata | null = null; +/** + * Clears active web-tool metadata when the secrets runtime snapshot is reset. + */ export function clearActiveRuntimeWebToolsMetadata(): void { activeRuntimeWebToolsMetadata = null; } +/** + * Stores web-tool metadata with clone isolation from caller-owned objects. + */ export function setActiveRuntimeWebToolsMetadata(metadata: RuntimeWebToolsMetadata): void { activeRuntimeWebToolsMetadata = structuredClone(metadata); } +/** + * Returns active web-tool metadata without exposing mutable runtime state. + */ export function getActiveRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata | null { if (!activeRuntimeWebToolsMetadata) { return null; diff --git a/src/secrets/runtime-web-tools.shared.ts b/src/secrets/runtime-web-tools.shared.ts index e85b313e9e20..be32781636c2 100644 --- a/src/secrets/runtime-web-tools.shared.ts +++ b/src/secrets/runtime-web-tools.shared.ts @@ -19,6 +19,9 @@ const loadResolveManifestContractOwnerPluginId = createLazyRuntimeNamedExport( ); type RuntimeWebWarningCode = Extract; +/** + * Result of resolving one provider credential from config, SecretRef, env, or fallback. + */ export type SecretResolutionResult = { value?: string; source: TSource; @@ -28,6 +31,9 @@ export type SecretResolutionResult = { fallbackUsedAfterRefFailure: boolean; }; +/** + * Metadata fields shared by runtime web search and fetch provider selection. + */ export type RuntimeWebProviderMetadataBase = { providerConfigured?: string; providerSource: "configured" | "auto-detect" | "none"; @@ -36,6 +42,9 @@ export type RuntimeWebProviderMetadataBase = { diagnostics: RuntimeWebDiagnostic[]; }; +/** + * Parameters shared by web search/fetch provider selection after provider surface discovery. + */ export type RuntimeWebProviderSelectionParams< TProvider extends { id: string; @@ -56,10 +65,12 @@ export type RuntimeWebProviderSelectionParams< resolvedConfig: OpenClawConfig; context: ResolverContext; defaults: SecretDefaults | undefined; + /** Defer keyless providers until credential-bearing auto-detect candidates are exhausted. */ deferKeylessFallback: boolean; fallbackUsedCode: RuntimeWebWarningCode; noFallbackCode: RuntimeWebWarningCode; autoDetectSelectedCode: RuntimeWebWarningCode; + /** Reads the primary credential location for a provider from source config. */ readConfiguredCredential: (params: { provider: TProvider; config: OpenClawConfig; @@ -70,11 +81,13 @@ export type RuntimeWebProviderSelectionParams< config: OpenClawConfig; toolConfig: TToolConfig; }) => { path: string; value: unknown } | undefined; + /** Resolves inline/env/SecretRef credentials and reports the winning source. */ resolveSecretInput: (params: { value: unknown; path: string; envVars: string[]; }) => Promise>; + /** Writes the selected credential into the resolved runtime config snapshot. */ setResolvedCredential: (params: { resolvedConfig: OpenClawConfig; provider: TProvider; @@ -122,6 +135,9 @@ function pushInactiveProviderCredentialWarnings< } } +/** + * Ensures a nested config object exists and returns it for mutation. + */ export function ensureObject( target: Record, key: string, @@ -149,6 +165,9 @@ function normalizeKnownProvider( return undefined; } +/** + * Returns whether a configured value or sibling ref field contains a SecretRef. + */ export function hasConfiguredSecretRef( value: unknown, defaults: SecretDefaults | undefined, @@ -188,6 +207,9 @@ function setResolvedCredentialPath(params: { } } +/** + * Provider set plus effective config state for one runtime web tool surface. + */ export type RuntimeWebProviderSurface = { providers: TProvider[]; configuredProvider?: string; @@ -195,6 +217,9 @@ export type RuntimeWebProviderSurface = { hasConfiguredSurface: boolean; }; +/** + * Parameters for resolving configured/available providers before credential selection. + */ export type ResolveRuntimeWebProviderSurfaceParams< TProvider extends { id: string; @@ -211,6 +236,7 @@ export type ResolveRuntimeWebProviderSurfaceParams< invalidAutoDetectCode: RuntimeWebWarningCode; sourceConfig: OpenClawConfig; context: ResolverContext; + /** Bundled plugin id already known from caller context, avoiding duplicate manifest lookup. */ configuredBundledPluginIdHint?: string; resolveProviders: (params: { configuredBundledPluginId?: string }) => Promise; sortProviders: (providers: TProvider[]) => TProvider[]; @@ -229,6 +255,9 @@ export type ResolveRuntimeWebProviderSurfaceParams< normalizeConfiguredProviderAgainstActiveProviders?: boolean; }; +/** + * Resolves available providers, configured provider validity, and whether the surface is active. + */ export async function resolveRuntimeWebProviderSurface< TProvider extends { id: string; @@ -334,6 +363,9 @@ export async function resolveRuntimeWebProviderSurface< }; } +/** + * Selects a configured or auto-detected provider and materializes its resolved credential. + */ export async function resolveRuntimeWebProviderSelection< TProvider extends { id: string; @@ -419,6 +451,7 @@ export async function resolveRuntimeWebProviderSelection< envVars: getProviderEnvVars(provider), }); if (fallbackResolution.source === "secretRef" && fallbackResolution.value) { + // Preserve transcript/config bytes for env-selected providers while materializing refs. setResolvedCredentialPath({ resolvedConfig: params.resolvedConfig, path: fallback.path, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 5d3841cfc407..fc9b31486833 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -86,6 +86,8 @@ function needsRuntimeWebFetchProviderDiscovery(params: { if (params.rawProvider) { return true; } + // Limits-only fetch config must stay on the runtime fast path; credential-shaped values are + // the signal that provider discovery and SecretRef resolution are actually needed. return hasCredentialBearingObjectValue(params.fetch, params.defaults); } @@ -172,6 +174,8 @@ async function hasCustomWebProviderPluginRisk(params: { env: params.env, }), ); + // Public artifacts are complete only for bundled providers. Any configured non-bundled + // plugin surface has to fall back to manifest/runtime discovery to avoid hiding providers. const hasNonBundledPluginId = (pluginId: string) => !bundledPluginIds.has(pluginId.trim()); if (Array.isArray(plugins.allow) && plugins.allow.some(hasNonBundledPluginId)) { return true; @@ -309,6 +313,8 @@ async function resolveSecretInputWithEnvFallback(params: { const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); if (fallback.value) { + // Provider env vars remain the explicit recovery path for unresolved refs so startup can + // continue while diagnostics still report which configured SecretRef failed. return { value: fallback.value, source: "env", @@ -356,6 +362,8 @@ async function resolveBundledWebSearchProviders(params: { : params.onlyPluginIds && params.onlyPluginIds.length > 0 ? sortUniqueStrings(params.onlyPluginIds) : undefined; + // Narrow plugin hints can use explicit public artifacts first; broad custom-plugin risk still + // routes through runtime discovery because installed or path-loaded providers may participate. if (onlyPluginIds && onlyPluginIds.length > 0) { const bundled = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ onlyPluginIds }); if (bundled && bundled.length > 0) { @@ -400,6 +408,8 @@ async function resolveBundledWebFetchProviders(params: { hasCustomWebFetchPluginRisk: boolean; }): Promise { const env = { ...process.env, ...params.context.env }; + // Web fetch has no keyless auto-detect fallback; a configured bundled owner can be resolved + // directly without loading every provider manifest. if (params.configuredBundledPluginId) { const bundled = resolveBundledExplicitWebFetchProvidersFromPublicArtifacts({ onlyPluginIds: [params.configuredBundledPluginId], @@ -509,6 +519,10 @@ function inactivePathsForFetchProvider(provider: PluginWebFetchProviderEntry): s : [provider.credentialPath]; } +/** + * Resolves runtime web search/fetch provider metadata and writes selected credentials into a + * cloned runtime config without mutating the source config. + */ export async function resolveRuntimeWebTools(params: { sourceConfig: OpenClawConfig; resolvedConfig: OpenClawConfig; diff --git a/src/secrets/runtime-web-tools.types.ts b/src/secrets/runtime-web-tools.types.ts index 56a45a0a3e50..037a670c8bd4 100644 --- a/src/secrets/runtime-web-tools.types.ts +++ b/src/secrets/runtime-web-tools.types.ts @@ -1,3 +1,4 @@ +/** Diagnostic codes emitted while selecting runtime web search/fetch providers. */ export type RuntimeWebDiagnosticCode = | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" | "WEB_SEARCH_AUTODETECT_SELECTED" @@ -8,29 +9,40 @@ export type RuntimeWebDiagnosticCode = | "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_FALLBACK_USED" | "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_NO_FALLBACK"; +/** User-facing diagnostic attached to runtime web-tool metadata. */ export type RuntimeWebDiagnostic = { code: RuntimeWebDiagnosticCode; message: string; path?: string; }; +/** Runtime selection metadata for the web search tool. */ export type RuntimeWebSearchMetadata = { + /** Provider explicitly configured in source config, before auto-detect fallback. */ providerConfigured?: string; providerSource: "configured" | "auto-detect" | "none"; + /** Provider that runtime calls should use after config validation and credential lookup. */ selectedProvider?: string; + /** Source that supplied the selected provider credential, or why it is unavailable. */ selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing"; + /** Perplexity transport chosen from provider config or runtime default. */ perplexityTransport?: "search_api" | "chat_completions"; diagnostics: RuntimeWebDiagnostic[]; }; +/** Runtime selection metadata for the web fetch tool. */ export type RuntimeWebFetchMetadata = { + /** Provider explicitly configured in source config, before auto-detect fallback. */ providerConfigured?: string; providerSource: "configured" | "auto-detect" | "none"; + /** Provider that runtime calls should use after config validation and credential lookup. */ selectedProvider?: string; + /** Source that supplied the selected provider credential, or why it is unavailable. */ selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing"; diagnostics: RuntimeWebDiagnostic[]; }; +/** Combined runtime metadata for web search/fetch tools and shared diagnostics. */ export type RuntimeWebToolsMetadata = { search: RuntimeWebSearchMetadata; fetch: RuntimeWebFetchMetadata; diff --git a/src/secrets/runtime-zalo.test-support.ts b/src/secrets/runtime-zalo.test-support.ts index eb2f96cd161e..1bc5be2c4718 100644 --- a/src/secrets/runtime-zalo.test-support.ts +++ b/src/secrets/runtime-zalo.test-support.ts @@ -1,12 +1,14 @@ import { vi } from "vitest"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +/** Test-only bootstrap registry mock for Zalo secret surface tests. */ const zaloSecrets = loadBundledChannelSecretContractApi("zalo"); if (!zaloSecrets?.collectRuntimeConfigAssignments) { throw new Error("Missing Zalo secret contract api"); } const zaloAssignments = zaloSecrets.collectRuntimeConfigAssignments; +// Use the real bundled Zalo secret contract while avoiding plugin bootstrap. vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: (id: string) => id === "zalo" diff --git a/src/secrets/runtime.integration.test-helpers.ts b/src/secrets/runtime.integration.test-helpers.ts index 3f6bb18e7a8d..f44b1a435ff5 100644 --- a/src/secrets/runtime.integration.test-helpers.ts +++ b/src/secrets/runtime.integration.test-helpers.ts @@ -3,6 +3,8 @@ import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.j import { clearPluginLoaderCache } from "../plugins/loader.js"; import { captureEnv } from "../test-utils/env.js"; import type { SecretsRuntimeEnvSnapshot } from "./runtime-openai-file-fixture.test-helper.js"; + +/** Shared integration helpers for full secrets runtime snapshot tests. */ export { asConfig, createOpenAIFileRuntimeConfig, @@ -16,8 +18,10 @@ export { export type { SecretsRuntimeEnvSnapshot } from "./runtime-openai-file-fixture.test-helper.js"; import { clearSecretsRuntimeSnapshot } from "./runtime.js"; +/** Slow integration timeout used by plugin-origin and gateway-auth runtime tests. */ export const SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS = 300_000; +/** Start an isolated secrets runtime integration test with bundled plugin env removed. */ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot { const envSnapshot = captureEnv([ "OPENCLAW_BUNDLED_PLUGINS_DIR", @@ -29,6 +33,7 @@ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot return envSnapshot; } +/** Restore env, mocks, config/plugin caches, and active secrets runtime state. */ export function endSecretsRuntimeIsolationForTest(envSnapshot: SecretsRuntimeEnvSnapshot) { vi.restoreAllMocks(); envSnapshot.restore(); diff --git a/src/secrets/secret-value.ts b/src/secrets/secret-value.ts index 9a192fede167..9b172968f1ea 100644 --- a/src/secrets/secret-value.ts +++ b/src/secrets/secret-value.ts @@ -1,7 +1,13 @@ import { isNonEmptyString, isRecord } from "./shared.js"; +/** + * Describes the resolved value shape a secret target accepts after provider resolution. + */ export type SecretExpectedResolvedValue = "string" | "string-or-object"; // pragma: allowlist secret +/** + * Returns whether a resolved provider value satisfies the target's accepted runtime shape. + */ export function isExpectedResolvedSecretValue( value: unknown, expected: SecretExpectedResolvedValue, @@ -12,6 +18,9 @@ export function isExpectedResolvedSecretValue( return isNonEmptyString(value) || isRecord(value); } +/** + * Returns whether an inline configured value should be treated as plaintext secret material. + */ export function hasConfiguredPlaintextSecretValue( value: unknown, expected: SecretExpectedResolvedValue, @@ -22,6 +31,9 @@ export function hasConfiguredPlaintextSecretValue( return isNonEmptyString(value) || (isRecord(value) && Object.keys(value).length > 0); } +/** + * Throws a caller-provided error when a resolved secret value does not match its target shape. + */ export function assertExpectedResolvedSecretValue(params: { value: unknown; expected: SecretExpectedResolvedValue; diff --git a/src/secrets/shared.ts b/src/secrets/shared.ts index 563de241a8b8..d5848b1f9e09 100644 --- a/src/secrets/shared.ts +++ b/src/secrets/shared.ts @@ -5,10 +5,16 @@ import { replaceFileAtomicSync } from "../infra/replace-file.js"; import { resolvePositiveTimerTimeoutMs } from "../shared/number-coercion.js"; export { isRecord } from "../utils.js"; +/** + * Narrows to strings that contain non-whitespace content. + */ export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +/** + * Parses a simple .env assignment value, stripping one matching quote pair after trimming. + */ export function parseEnvValue(raw: string): string { const trimmed = raw.trim(); if ( @@ -20,6 +26,9 @@ export function parseEnvValue(raw: string): string { return trimmed; } +/** + * Normalizes numeric config to a positive integer, falling back when the input is not finite. + */ export function normalizePositiveInt(value: unknown, fallback: number): number { if (typeof value === "number" && Number.isFinite(value)) { return Math.max(1, Math.floor(value)); @@ -27,10 +36,16 @@ export function normalizePositiveInt(value: unknown, fallback: number): number { return Math.max(1, Math.floor(fallback)); } +/** + * Normalizes timer values with the shared timeout coercion rules used by secret providers. + */ export function normalizePositiveTimerMs(value: unknown, fallback: number): number { return resolvePositiveTimerTimeoutMs(value, fallback); } +/** + * Splits a dotted config path into non-empty trimmed segments. + */ export function parseDotPath(pathname: string): string[] { return pathname .split(".") @@ -38,20 +53,32 @@ export function parseDotPath(pathname: string): string[] { .filter((segment) => segment.length > 0); } +/** + * Joins config path segments using the secrets command's dotted path format. + */ export function toDotPath(segments: string[]): string { return segments.join("."); } +/** + * Ensures the parent directory for a secret-related file exists with private permissions. + */ export function ensureDirForFile(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); } +/** + * Writes a JSON file through the private file store so new files get secret-safe permissions. + */ export function writeJsonFileSecure(pathname: string, value: unknown): void { privateFileStoreSync(path.dirname(pathname)).writeJson(path.basename(pathname), value, { trailingNewline: true, }); } +/** + * Reads a text file when present, returning null instead of throwing for missing paths. + */ export function readTextFileIfExists(pathname: string): string | null { if (!fs.existsSync(pathname)) { return null; @@ -59,6 +86,9 @@ export function readTextFileIfExists(pathname: string): string | null { return fs.readFileSync(pathname, "utf8"); } +/** + * Atomically writes secret-adjacent text, using the private store for default 0600 files. + */ export function writeTextFileAtomic(pathname: string, value: string, mode = 0o600): void { if (mode !== 0o600) { replaceFileAtomicSync({ diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index d46c3369f923..21bf884af533 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -8,14 +8,17 @@ import { resolveUserPath } from "../utils.js"; import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; import { parseEnvValue } from "./shared.js"; +/** Parses one .env assignment value using the shared shell-ish env parser. */ export function parseEnvAssignmentValue(raw: string): string { return parseEnvValue(raw); } +/** Lists canonical auth-profile stores visible to secrets audit/apply storage scanners. */ export function listAuthProfileStorePaths(config: OpenClawConfig, stateDir: string): string[] { return listAuthProfileStorePathsFromAuthStorePaths(config, stateDir); } +/** Lists legacy per-agent auth.json stores that can contain static credentials. */ export function listLegacyAuthJsonPaths(stateDir: string): string[] { const out: string[] = []; const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); @@ -39,9 +42,14 @@ function resolveActiveAgentDir(stateDir: string, env: NodeJS.ProcessEnv = proces if (override) { return resolveUserPath(override, env); } + // Storage scans must include the implicit main agent even before config has agent entries. return path.join(resolveUserPath(stateDir), "agents", "main", "agent"); } +/** + * Lists deduplicated models.json paths that may contain materialized provider credentials. + * Includes active env override, implicit main agent, discovered state dirs, and configured agents. + */ export function listAgentModelsJsonPaths( config: OpenClawConfig, stateDir: string, @@ -74,11 +82,18 @@ export function listAgentModelsJsonPaths( return [...paths]; } +/** Limits for safe opportunistic JSON reads during local storage scans. */ export type ReadJsonObjectOptions = { + /** Reject files larger than this byte count before reading content. */ maxBytes?: number; + /** Reject directories, symlinks, and other non-regular paths before JSON parsing. */ requireRegularFile?: boolean; }; +/** + * Reads a JSON object if the file exists, returning parse/stat errors without throwing. + * Non-object JSON values are treated as absent because scanners expect record-shaped stores. + */ export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 9dcfb589fce3..b9ad8c82537d 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -483,10 +483,12 @@ function loadSecretTargetRegistryFromPluginMetadata(params: { ]; } +/** Returns only core-owned secret target registry entries. */ export function getCoreSecretTargetRegistry(): SecretTargetRegistryEntry[] { return CORE_SECRET_TARGET_REGISTRY; } +/** Returns the process-cached registry including bundled plugin/channel metadata. */ export function getSecretTargetRegistry(): SecretTargetRegistryEntry[] { if (cachedSecretTargetRegistry) { return cachedSecretTargetRegistry; @@ -497,6 +499,7 @@ export function getSecretTargetRegistry(): SecretTargetRegistryEntry[] { return cachedSecretTargetRegistry; } +/** Returns an uncached source-tree registry for docs/snapshot generation. */ export function getSourceSecretTargetRegistry(): SecretTargetRegistryEntry[] { return loadSecretTargetRegistryFromPluginMetadata({ env: { diff --git a/src/secrets/target-registry-pattern.ts b/src/secrets/target-registry-pattern.ts index 0cadf39692c1..ca4976237557 100644 --- a/src/secrets/target-registry-pattern.ts +++ b/src/secrets/target-registry-pattern.ts @@ -2,11 +2,13 @@ import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; import { isRecord, parseDotPath } from "./shared.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; +/** Tokenized segment in a secret target path pattern. */ export type PathPatternToken = | { kind: "literal"; value: string } | { kind: "wildcard" } | { kind: "array"; field: string }; +/** Registry entry with compiled path/ref pattern tokens. */ export type CompiledTargetRegistryEntry = SecretTargetRegistryEntry & { pathTokens: PathPatternToken[]; pathDynamicTokenCount: number; @@ -14,6 +16,7 @@ export type CompiledTargetRegistryEntry = SecretTargetRegistryEntry & { refPathDynamicTokenCount: number; }; +/** Concrete config value matched by expanding a path pattern. */ export type ExpandedPathMatch = { segments: string[]; captures: string[]; @@ -24,6 +27,9 @@ function countDynamicPatternTokens(tokens: PathPatternToken[]): number { return tokens.filter((token) => token.kind === "wildcard" || token.kind === "array").length; } +/** + * Parses a dotted target pattern into literal, wildcard, and array traversal tokens. + */ export function parsePathPattern(pathPattern: string): PathPatternToken[] { const segments = parseDotPath(pathPattern); return segments.map((segment) => { @@ -41,6 +47,9 @@ export function parsePathPattern(pathPattern: string): PathPatternToken[] { }); } +/** + * Compiles a registry entry and verifies its value path/ref path wildcard shape matches. + */ export function compileTargetRegistryEntry( entry: SecretTargetRegistryEntry, ): CompiledTargetRegistryEntry { @@ -52,6 +61,7 @@ export function compileTargetRegistryEntry( if (requiresSiblingRefPath && !refPathTokens) { throw new Error(`Missing refPathPattern for sibling_ref target: ${entry.id}`); } + // Value and sibling-ref paths must capture the same wildcard/array values in the same order. if (refPathTokens && refPathDynamicTokenCount !== pathDynamicTokenCount) { throw new Error(`Mismatched wildcard shape for target ref path: ${entry.id}`); } @@ -64,6 +74,9 @@ export function compileTargetRegistryEntry( }; } +/** + * Matches concrete path segments against compiled pattern tokens and returns dynamic captures. + */ export function matchPathTokens( segments: string[], tokens: PathPatternToken[], @@ -85,6 +98,7 @@ export function matchPathTokens( if (!value) { return null; } + // Capture order must match materializePathTokens for sibling ref path reconstruction. captures.push(value); index += 1; continue; @@ -102,6 +116,9 @@ export function matchPathTokens( return index === segments.length ? { captures } : null; } +/** + * Rebuilds a concrete path from tokens and captures produced by matchPathTokens/expandPathTokens. + */ export function materializePathTokens( tokens: PathPatternToken[], captures: string[], @@ -132,6 +149,9 @@ export function materializePathTokens( return captureIndex === captures.length ? out : null; } +/** + * Expands a pattern across a config object and returns every matching value with captures. + */ export function expandPathTokens(root: unknown, tokens: PathPatternToken[]): ExpandedPathMatch[] { const out: ExpandedPathMatch[] = []; const walk = ( diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 5be274aface2..60d0207df91f 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -32,6 +32,7 @@ let compiledCoreOpenClawTargetState: { targetsByType: Map; } | null = null; +// Channel contract entries are process-stable; plugin install/reload is the owner of freshness. const compiledChannelOpenClawTargets = new Map(); function buildTargetTypeIndex( @@ -226,6 +227,9 @@ function toResolvedPlanTarget( }; } +/** + * Lists the full secrets target registry in public, serializable form. + */ export function listSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { return getCompiledSecretTargetRegistryState().compiledSecretTargetRegistry.map((entry) => Object.assign( @@ -252,12 +256,18 @@ export function listSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { ); } +/** + * Narrows unknown input to a target id currently present in the compiled registry. + */ export function isKnownSecretTargetId(value: unknown): value is string { return ( typeof value === "string" && getCompiledSecretTargetRegistryState().knownTargetIds.has(value) ); } +/** + * Resolves a secrets apply-plan target against registered target type and path patterns. + */ export function resolvePlanTargetAgainstRegistry(candidate: { type: string; pathSegments: string[]; @@ -312,6 +322,9 @@ function resolvePlanTargetAgainstEntries( return null; } +/** + * Resolves an openclaw.json config path to the matching plan-capable secrets target. + */ export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null { for (const entry of getCompiledCoreOpenClawTargetState().openClawCompiledSecretTargets) { if (!entry.includeInPlan) { @@ -332,6 +345,7 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve const explicitChannelEntries = explicitChannelId ? getCompiledChannelOpenClawTargets(explicitChannelId) : null; + // Channel-owned contracts get first chance for explicit channel paths before bundled defaults. for (const entry of explicitChannelEntries ?? []) { if (!entry.includeInPlan) { continue; @@ -364,12 +378,18 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve return null; } +/** + * Discovers configured secret-bearing values in openclaw.json using the full registry. + */ export function discoverConfigSecretTargets( config: OpenClawConfig, ): DiscoveredConfigSecretTarget[] { return discoverConfigSecretTargetsByIds(config); } +/** + * Discovers configured openclaw.json targets, optionally limited to selected registry ids. + */ export function discoverConfigSecretTargetsByIds( config: OpenClawConfig, targetIds?: Iterable, @@ -390,6 +410,9 @@ export function discoverConfigSecretTargetsByIds( return discoverSecretTargetsFromEntries(config, discoveryEntries); } +/** + * Discovers secret-bearing values in auth-profiles.json store objects. + */ export function discoverAuthProfileSecretTargets( store: unknown, targetIds?: Iterable, @@ -404,6 +427,9 @@ export function discoverAuthProfileSecretTargets( return discoverSecretTargetsFromEntries(store, discoveryEntries); } +/** + * Lists auth-profile target entries that participate in plaintext/unresolved-ref audit. + */ export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] { return getCompiledSecretTargetRegistryState().compiledSecretTargetRegistry.filter( (entry) => entry.configFile === "auth-profiles.json" && entry.includeInAudit, diff --git a/src/secrets/target-registry-test-helpers.ts b/src/secrets/target-registry-test-helpers.ts index 09b1212fb2d9..f4a602808f69 100644 --- a/src/secrets/target-registry-test-helpers.ts +++ b/src/secrets/target-registry-test-helpers.ts @@ -1,3 +1,7 @@ +/** + * Canonicalize legacy coverage IDs to current registry IDs so historical tests + * can compare the same target surface after web tools moved under plugins. + */ export function canonicalizeSecretTargetCoverageId(id: string): string { if (id === "tools.web.x_search.apiKey") { return "plugins.entries.xai.config.webSearch.apiKey"; diff --git a/src/secrets/target-registry-types.ts b/src/secrets/target-registry-types.ts index e8c31d1c251d..c6524f5bd3ab 100644 --- a/src/secrets/target-registry-types.ts +++ b/src/secrets/target-registry-types.ts @@ -1,41 +1,76 @@ +/** Config document that owns a registered secret-bearing target. */ export type SecretTargetConfigFile = "openclaw.json" | "auth-profiles.json"; // pragma: allowlist secret +/** Storage shape used by a target: inline SecretInput or a sibling `*Ref` field. */ export type SecretTargetShape = "secret_input" | "sibling_ref"; // pragma: allowlist secret +/** Resolved value shape accepted by runtime and apply validation. */ export type SecretTargetExpected = "string" | "string-or-object"; // pragma: allowlist secret +/** Auth profile families that have separate secret target coverage. */ export type AuthProfileType = "api_key" | "token"; +/** + * Registry metadata for one configurable secret-bearing value. + */ export type SecretTargetRegistryEntry = { + /** Stable id used by plans, audits, docs, and targeted discovery filters. */ id: string; + /** Plan/configure target family; aliases keep CLI-facing names additive. */ targetType: string; targetTypeAliases?: string[]; + /** Config document where the value is discovered or rewritten. */ configFile: SecretTargetConfigFile; + /** Dot-path pattern for the secret-bearing value; `*` captures path segments. */ pathPattern: string; + /** Optional sibling SecretRef path materialized from the same captures as `pathPattern`. */ refPathPattern?: string; + /** Whether the registered value stores a SecretInput directly or via a sibling ref field. */ secretShape: SecretTargetShape; + /** Runtime value shape accepted after SecretRef resolution. */ expectedResolvedValue: SecretTargetExpected; + /** Enables `openclaw secrets apply` targeting for this entry. */ includeInPlan: boolean; + /** Enables interactive/non-interactive configure candidate generation. */ includeInConfigure: boolean; + /** Enables plaintext/unresolved-ref audit scanning. */ includeInAudit: boolean; + /** Captured path segment that names the owning provider, when applicable. */ providerIdPathSegmentIndex?: number; + /** Captured path segment that names the owning account/profile, when applicable. */ accountIdPathSegmentIndex?: number; + /** Auth-profile family for auth-profiles.json entries. */ authProfileType?: AuthProfileType; + /** Enables provider-shadowing diagnostics for provider-auth surfaces with fallback order. */ trackProviderShadowing?: boolean; }; +/** + * Concrete plan/config target after registry pattern matching and capture resolution. + */ export type ResolvedPlanTarget = { entry: SecretTargetRegistryEntry; + /** Concrete path to the secret-bearing value in the owning config document. */ pathSegments: string[]; + /** Concrete sibling SecretRef path when `entry.secretShape` is `sibling_ref`. */ refPathSegments?: string[]; + /** Provider id captured from `pathSegments`, if the registry entry declares one. */ providerId?: string; + /** Account/profile id captured from `pathSegments`, if the registry entry declares one. */ accountId?: string; }; +/** + * A configured secret target discovered during audit/config scanning. + */ export type DiscoveredConfigSecretTarget = { entry: SecretTargetRegistryEntry; + /** Dot path for display, audit output, and CLI targeting. */ path: string; pathSegments: string[]; + /** Dot path to the sibling SecretRef field when the entry uses one. */ refPath?: string; refPathSegments?: string[]; + /** Current value at `pathSegments`; may be plaintext, SecretInput, object, or unset. */ value: unknown; + /** Current value at `refPathSegments`, present only for sibling-ref entries. */ refValue?: unknown; providerId?: string; accountId?: string; diff --git a/src/secrets/target-registry.ts b/src/secrets/target-registry.ts index 93801ac14a79..f5f6d0a450f6 100644 --- a/src/secrets/target-registry.ts +++ b/src/secrets/target-registry.ts @@ -1 +1,5 @@ +/** + * Public target-registry barrel for CLI/configure/audit callers. Query helpers + * own discovery and resolution against the generated registry data. + */ export * from "./target-registry-query.js"; diff --git a/src/secrets/unsupported-surface-policy.ts b/src/secrets/unsupported-surface-policy.ts index ef540df0009c..25ebb8034673 100644 --- a/src/secrets/unsupported-surface-policy.ts +++ b/src/secrets/unsupported-surface-policy.ts @@ -36,6 +36,7 @@ const unsupportedSecretRefSurfacePatterns = [ ...bundledChannelUnsupportedSecretRefSurfacePatterns, ]; +// Candidate scanning only sees openclaw.json; auth-profile-only surfaces are audited elsewhere. const unsupportedSecretRefConfigCandidatePatterns = [ ...CORE_UNSUPPORTED_SECRETREF_CONFIG_CANDIDATE_PATTERNS, ...bundledChannelUnsupportedSecretRefSurfacePatterns, @@ -92,6 +93,8 @@ function collectPatternCandidates(params: { if (token.kind === "wildcard") { if (Array.isArray(params.current)) { + // Wildcards traverse both objects and arrays because plugin/channel configs use both + // shapes for owner-defined maps. for (const [index, value] of params.current.entries()) { collectPatternCandidates({ ...params, @@ -128,6 +131,7 @@ function collectPatternCandidates(params: { if (!Array.isArray(value)) { return; } + // Array tokens preserve the named field in the reported path, matching config dot-paths. for (const [index, entry] of value.entries()) { collectPatternCandidates({ ...params, @@ -150,15 +154,24 @@ function collectPatternCandidates(params: { }); } +/** + * Returns canonical config/auth-profile path patterns that do not support SecretRef values. + */ export function getUnsupportedSecretRefSurfacePatterns(): string[] { return [...unsupportedSecretRefSurfacePatterns]; } +/** + * Concrete unsupported config value discovered from an openclaw.json-like object. + */ export type UnsupportedSecretRefConfigCandidate = { path: string; value: unknown; }; +/** + * Finds configured openclaw.json values whose surfaces currently reject SecretRef objects. + */ export function collectUnsupportedSecretRefConfigCandidates( raw: unknown, ): UnsupportedSecretRefConfigCandidate[] { diff --git a/src/security/audit-channel-test-helpers.ts b/src/security/audit-channel-test-helpers.ts index d2e7fdbd4bd4..774044778ce6 100644 --- a/src/security/audit-channel-test-helpers.ts +++ b/src/security/audit-channel-test-helpers.ts @@ -1,6 +1,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +/** Build a minimal channel plugin stub for security audit unit tests. */ export function stubAuditChannelPlugin(params: { id: string; label: string; @@ -33,6 +34,8 @@ export function stubAuditChannelPlugin(params: { ((cfg, accountId) => { const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; + // Default inspection mirrors the resolved test account so channel + // audit tests can override only the behavior they care about. const account = params.resolveAccount(cfg, resolvedAccountId) as | { config?: Record } | undefined; diff --git a/src/security/audit-channel.collect.runtime.ts b/src/security/audit-channel.collect.runtime.ts index bed24a7f73ea..a79e74d56b0b 100644 --- a/src/security/audit-channel.collect.runtime.ts +++ b/src/security/audit-channel.collect.runtime.ts @@ -3,6 +3,7 @@ import { collectChannelSecurityFindings as collectChannelSecurityFindingsImpl } type CollectChannelSecurityFindings = typeof import("./audit-channel.js").collectChannelSecurityFindings; +/** Runtime facade for channel security collection, kept mockable for audit tests. */ export function collectChannelSecurityFindings( ...args: Parameters ): ReturnType { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 927fabea0ecb..48254002200d 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -14,6 +14,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.types.js"; +/** Classify free-form channel warnings into audit severities. */ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity { const s = message.toLowerCase(); if ( @@ -78,6 +79,7 @@ function formatChannelAccountNote(params: { : ""; } +/** Collect channel-specific security findings across active channel plugins/accounts. */ export async function collectChannelSecurityFindings(params: { cfg: OpenClawConfig; sourceConfig?: OpenClawConfig; @@ -143,6 +145,9 @@ export async function collectChannelSecurityFindings(params: { }; } const useSourceUnavailableAccount = Boolean( + // Secret resolution can replace a configured account with an unresolved + // placeholder. Use source config when needed so audits still explain the + // originally configured credential surface. sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && (!hasResolvedCredentialValue(resolvedAccount) || @@ -207,6 +212,8 @@ export async function collectChannelSecurityFindings(params: { normalizeEntry?: (raw: string) => string; }) => { const policyPath = input.policyPath ?? `${input.allowFromPath}policy`; + // DM allowlist audit may need channel-specific normalization and async + // account ownership checks before classifying open/multi-user exposure. const { hasWildcard, isMultiUserDm } = await resolveDmAllowAuditState({ provider: input.provider, accountId: input.accountId, diff --git a/src/security/audit-deep-code-safety.ts b/src/security/audit-deep-code-safety.ts index 3dfc6eb9ad69..5e22568b12e7 100644 --- a/src/security/audit-deep-code-safety.ts +++ b/src/security/audit-deep-code-safety.ts @@ -3,11 +3,13 @@ import type { SecurityAuditFinding } from "./audit.types.js"; let auditDeepModulePromise: Promise | undefined; +/** Lazily load deep audit code paths so normal audits avoid plugin/skill scans. */ async function loadAuditDeepModule() { auditDeepModulePromise ??= import("./audit.deep.runtime.js"); return await auditDeepModulePromise; } +/** Collect plugin and installed-skill code safety findings when deep audit is enabled. */ export async function collectDeepCodeSafetyFindings(params: { cfg: OpenClawConfig; stateDir: string; diff --git a/src/security/audit-deep-probe-findings.ts b/src/security/audit-deep-probe-findings.ts index 1ff85f801eda..108b4e0fd7c1 100644 --- a/src/security/audit-deep-probe-findings.ts +++ b/src/security/audit-deep-probe-findings.ts @@ -1,11 +1,16 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { SecurityAuditFinding, SecurityAuditReport } from "./audit.types.js"; +/** + * Convert optional deep gateway probe results into security audit findings. + * This keeps CLI/audit callers aligned on check ids, titles, and remediation text. + */ export function collectDeepProbeFindings(params: { deep?: SecurityAuditReport["deep"]; authWarning?: string; }): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; + // Only attempted probes can fail; skipped deep probes are represented by missing data. if (params.deep?.gateway?.attempted && !params.deep.gateway.ok) { findings.push({ checkId: "gateway.probe_failed", diff --git a/src/security/audit-extra.summary.ts b/src/security/audit-extra.summary.ts index 7285407db70b..db28c85d16f8 100644 --- a/src/security/audit-extra.summary.ts +++ b/src/security/audit-extra.summary.ts @@ -13,6 +13,7 @@ import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; import { collectAuditModelRefs } from "./audit-model-refs.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; +/** Lightweight audit finding shape used by summary-only audit helpers. */ export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; @@ -138,6 +139,7 @@ function isBrowserEnabled(cfg: OpenClawConfig): boolean { return cfg.browser?.enabled !== false; } +/** Produce a concise inventory of major security-relevant surfaces. */ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const group = summarizeGroupPolicy(cfg); const elevated = cfg.tools?.elevated?.enabled !== false; @@ -168,6 +170,7 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi ]; } +/** Flag small-parameter models when they retain web/browser tool exposure. */ export function collectSmallModelRiskFindings(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -197,6 +200,8 @@ export function collectSmallModelRiskFindings(params: { const exposureSet = new Set(); for (const entry of smallModels) { const agentId = extractAgentIdFromSource(entry.source); + // Evaluate each model in its agent context because sandbox/tool policy can + // differ per agent and provider override. const modelRef = parseModelRef(entry.id, "openai", { allowPluginNormalization: false, }); diff --git a/src/security/audit-fs.ts b/src/security/audit-fs.ts index 2ea52648ffb9..8484e7420ea5 100644 --- a/src/security/audit-fs.ts +++ b/src/security/audit-fs.ts @@ -1,3 +1,4 @@ +/** Filesystem permission audit facade backed by shared infra permission helpers. */ export { formatPermissionDetail, formatPermissionRemediation, diff --git a/src/security/audit-model-refs.ts b/src/security/audit-model-refs.ts index cd20ce55e40c..b9eea48065de 100644 --- a/src/security/audit-model-refs.ts +++ b/src/security/audit-model-refs.ts @@ -10,6 +10,10 @@ import { } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** + * Model reference used by security audit findings. + * `id` is the normalized provider/model key; `source` is the config path shown in diagnostics. + */ export type AuditModelRef = { id: string; source: string }; function resolveAuditModelId( @@ -17,6 +21,8 @@ function resolveAuditModelId( raw: string, aliasIndex: ReturnType, ): string { + // Audit runs before provider/plugin runtime loading, so only config-defined aliases + // are normalized here; unresolved values are still reported with their original text. const resolved = resolveModelRefFromString({ cfg, raw, @@ -47,6 +53,10 @@ function addModelRef(params: { }); } +/** + * Collect every configured primary and fallback model that security audits should classify. + * Agent-specific refs keep source labels precise so findings point at the risky override. + */ export function collectAuditModelRefs(cfg: OpenClawConfig): AuditModelRef[] { const aliasIndex = buildModelAliasIndex({ cfg, diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index ebab4235f46b..d28d4a93422c 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -29,6 +29,7 @@ type PluginTrustPolicyDeps = { let pluginTrustPolicyDepsPromise: Promise | undefined; +/** Lazily load tool-policy helpers so basic security imports avoid agent policy modules. */ async function loadPluginTrustPolicyDeps(): Promise { pluginTrustPolicyDepsPromise ??= Promise.all([ import("../agents/sandbox/config.js"), @@ -274,6 +275,7 @@ function isPinnedRegistrySpec(spec: string): boolean { return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version); } +/** Collect supply-chain and reachable-tool findings for installed plugins and hook packs. */ export async function collectPluginsTrustFindings(params: { cfg: OpenClawConfig; stateDir: string; @@ -292,6 +294,8 @@ export async function collectPluginsTrustFindings(params: { config: params.cfg, stateDir: params.stateDir, }); + // Allowlist entries may use aliases/canonical ids. Normalize against the + // current registry before treating an entry as phantom. const normalizePluginId = createPluginRegistryIdNormalizer(pluginIndex); const indexedPluginIds = new Set( pluginIndex.plugins.map((plugin) => plugin.pluginId.toLowerCase()), @@ -392,6 +396,8 @@ export async function collectPluginsTrustFindings(params: { const profile = context.tools?.profile ?? params.cfg.tools?.profile; const restrictiveProfile = Boolean(deps.resolveToolProfilePolicy(profile)); const sandboxMode = deps.resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; + // Probe with a synthetic plugin tool id: broad allow policies will allow + // it, while restrictive profiles or explicit allowlists should not. const policies = resolveToolPolicies({ cfg: params.cfg, deps, @@ -482,6 +488,8 @@ export async function collectPluginsTrustFindings(params: { if (!recordedVersion) { continue; } + // Installed package.json is the local truth; registry metadata drift means + // update/reinstall should refresh the recorded supply-chain evidence. const installPath = record.installPath ?? path.join(params.stateDir, "extensions", pluginId); const installedVersion = await readInstalledPackageVersion(installPath); if (!installedVersion || installedVersion === recordedVersion) { diff --git a/src/security/audit-tool-policy.ts b/src/security/audit-tool-policy.ts index 2726f99cc8b8..d9c9b53e3078 100644 --- a/src/security/audit-tool-policy.ts +++ b/src/security/audit-tool-policy.ts @@ -1 +1,2 @@ +/** Security-audit facade for sandbox tool policy selection. */ export { pickSandboxToolPolicy } from "../agents/sandbox-tool-policy.js"; diff --git a/src/security/audit.deep.runtime.ts b/src/security/audit.deep.runtime.ts index 25662225d9cd..7c730d6af246 100644 --- a/src/security/audit.deep.runtime.ts +++ b/src/security/audit.deep.runtime.ts @@ -1,3 +1,4 @@ +/** Deep audit facade for code-safety scans that are loaded only when requested. */ export { collectInstalledSkillsCodeSafetyFindings, collectPluginsCodeSafetyFindings, diff --git a/src/security/audit.nondeep.runtime.ts b/src/security/audit.nondeep.runtime.ts index b560a0ec7e5c..64faf35acda9 100644 --- a/src/security/audit.nondeep.runtime.ts +++ b/src/security/audit.nondeep.runtime.ts @@ -1,3 +1,4 @@ +/** Non-deep audit facade for cheap summary/config findings. */ export { collectAttackSurfaceSummaryFindings, collectSmallModelRiskFindings, diff --git a/src/security/audit.runtime.ts b/src/security/audit.runtime.ts index f36d23de14dd..66bf685b805b 100644 --- a/src/security/audit.runtime.ts +++ b/src/security/audit.runtime.ts @@ -2,6 +2,7 @@ import { runSecurityAudit as runSecurityAuditImpl } from "./audit.js"; type RunSecurityAudit = typeof import("./audit.js").runSecurityAudit; +/** Runtime facade for the full security audit entrypoint. */ export function runSecurityAudit( ...args: Parameters ): ReturnType { diff --git a/src/security/audit.types.ts b/src/security/audit.types.ts index 0c816f17dae9..1e4f2f9b3beb 100644 --- a/src/security/audit.types.ts +++ b/src/security/audit.types.ts @@ -1,5 +1,7 @@ +/** Severity levels emitted by security audit checks. */ export type SecurityAuditSeverity = "info" | "warn" | "critical"; +/** One actionable or informational security audit finding. */ export type SecurityAuditFinding = { checkId: string; severity: SecurityAuditSeverity; @@ -8,18 +10,21 @@ export type SecurityAuditFinding = { remediation?: string; }; +/** Finding intentionally hidden by a configured audit suppression. */ export type SecurityAuditSuppressedFinding = SecurityAuditFinding & { suppression: { reason?: string; }; }; +/** Count summary grouped by audit severity. */ export type SecurityAuditSummary = { critical: number; warn: number; info: number; }; +/** Complete security audit report returned by CLI/runtime callers. */ export type SecurityAuditReport = { ts: number; summary: SecurityAuditSummary; diff --git a/src/security/channel-metadata.ts b/src/security/channel-metadata.ts index 8ee95d4af613..bc7ac183520c 100644 --- a/src/security/channel-metadata.ts +++ b/src/security/channel-metadata.ts @@ -19,6 +19,10 @@ function truncateText(value: string, maxChars: number): string { return `${trimmed}...`; } +/** + * Build bounded, externally wrapped channel metadata for prompt context. + * Channel-provided labels can be user-controlled, so callers must treat this as untrusted content. + */ export function buildUntrustedChannelMetadata(params: { source: string; label: string; @@ -28,6 +32,7 @@ export function buildUntrustedChannelMetadata(params: { const cleaned = params.entries .map((entry) => (typeof entry === "string" ? normalizeEntry(entry) : "")) .filter((entry) => Boolean(entry)) + // Bound each entry before dedupe so one oversized metadata value cannot crowd out others. .map((entry) => truncateText(entry, DEFAULT_MAX_ENTRY_CHARS)); const deduped = uniqueStrings(cleaned); if (deduped.length === 0) { diff --git a/src/security/config-regex.ts b/src/security/config-regex.ts index 76e8d0e86c7f..05c94439f9ec 100644 --- a/src/security/config-regex.ts +++ b/src/security/config-regex.ts @@ -4,8 +4,13 @@ import { type SafeRegexRejectReason, } from "./safe-regex.js"; +/** Reject reasons that should be surfaced for user-configured regex patterns. */ export type ConfigRegexRejectReason = Exclude; +/** + * Result for one config regex pattern. + * Empty patterns return null from the compiler; invalid or unsafe patterns return a rejected shape. + */ export type CompiledConfigRegex = | { regex: RegExp; @@ -27,8 +32,13 @@ function normalizeRejectReason(result: SafeRegexCompileResult): ConfigRegexRejec return result.reason; } +/** + * Compile a single user-configured regex with the shared safe-regex guardrails. + * Returns null for blank patterns so optional config entries can be skipped silently. + */ export function compileConfigRegex(pattern: string, flags = ""): CompiledConfigRegex | null { const result = compileSafeRegexDetailed(pattern, flags); + // Blank config entries are absence, not rejection diagnostics. if (result.reason === "empty") { return null; } @@ -40,6 +50,10 @@ export function compileConfigRegex(pattern: string, flags = ""): CompiledConfigR } as CompiledConfigRegex; } +/** + * Compile a list of user-configured regex patterns, separating usable regexes from diagnostics. + * Callers can keep operating with safe entries while reporting rejected unsafe patterns once. + */ export function compileConfigRegexes( patterns: string[], flags = "", diff --git a/src/security/context-visibility.ts b/src/security/context-visibility.ts index 946071fcae09..e42733b976c9 100644 --- a/src/security/context-visibility.ts +++ b/src/security/context-visibility.ts @@ -1,21 +1,34 @@ import type { ContextVisibilityMode } from "../config/types.base.js"; +/** Supplemental context classes that can be hidden independently from the main message. */ export type ContextVisibilityKind = "history" | "thread" | "quote" | "forwarded"; +/** Machine-readable reason for a supplemental context visibility decision. */ export type ContextVisibilityDecisionReason = + /** Visibility mode includes all supplemental context. */ | "mode_all" + /** Sender allowlist includes the item source. */ | "sender_allowed" + /** Quote-only visibility mode permits quoted context even when sender is not allowed. */ | "quote_override" + /** Context was omitted by visibility mode or sender policy. */ | "blocked"; +/** Visibility decision returned to callers that need both the boolean result and audit reason. */ export type ContextVisibilityDecision = { + /** Whether the supplemental context item should be included. */ include: boolean; + /** Rule that decided inclusion or omission. */ reason: ContextVisibilityDecisionReason; }; +/** Evaluates one supplemental context item against mode, kind, and sender allowlist state. */ export function evaluateSupplementalContextVisibility(params: { + /** Configured visibility mode for the current channel or default policy. */ mode: ContextVisibilityMode; + /** Supplemental context class being evaluated. */ kind: ContextVisibilityKind; + /** Whether the item source is permitted by the sender allowlist. */ senderAllowed: boolean; }): ContextVisibilityDecision { if (params.mode === "all") { @@ -27,21 +40,32 @@ export function evaluateSupplementalContextVisibility(params: { if (params.mode === "allowlist_quote" && params.kind === "quote") { return { include: true, reason: "quote_override" }; } + // Fail closed: unknown or non-matching policy combinations must omit + // supplemental context rather than leaking sender history/thread data. return { include: false, reason: "blocked" }; } +/** Boolean shorthand for callers that do not need the audit reason. */ export function shouldIncludeSupplementalContext(params: { + /** Configured visibility mode for the current channel or default policy. */ mode: ContextVisibilityMode; + /** Supplemental context class being evaluated. */ kind: ContextVisibilityKind; + /** Whether the item source is permitted by the sender allowlist. */ senderAllowed: boolean; }): boolean { return evaluateSupplementalContextVisibility(params).include; } +/** Filters supplemental context items and reports how many were omitted by visibility policy. */ export function filterSupplementalContextItems(params: { + /** Candidate supplemental context items in original delivery order. */ items: readonly T[]; + /** Configured visibility mode for the current channel or default policy. */ mode: ContextVisibilityMode; + /** Shared supplemental context class for every candidate item. */ kind: ContextVisibilityKind; + /** Per-item allowlist predicate for the sender or source identity. */ isSenderAllowed: (item: T) => boolean; }): { items: T[]; omitted: number } { const items = params.items.filter((item) => diff --git a/src/security/core-dangerous-config-flags.ts b/src/security/core-dangerous-config-flags.ts index 22fb60874665..27c5c47bb38e 100644 --- a/src/security/core-dangerous-config-flags.ts +++ b/src/security/core-dangerous-config-flags.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** List enabled core config flags that intentionally weaken security posture. */ export function collectCoreInsecureOrDangerousFlags(cfg: OpenClawConfig): string[] { const enabledFlags: string[] = []; if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { @@ -24,6 +25,8 @@ export function collectCoreInsecureOrDangerousFlags(cfg: OpenClawConfig): string if (cfg.tools?.exec?.applyPatch?.workspaceOnly === false) { enabledFlags.push("tools.exec.applyPatch.workspaceOnly=false"); } + // Suppressions are not insecure by themselves, but they hide audit findings + // and should be visible in dangerous-flag snapshots. const auditSuppressionCount = cfg.security?.audit?.suppressions?.length ?? 0; if (auditSuppressionCount > 0) { enabledFlags.push(`security.audit.suppressions configured (${auditSuppressionCount})`); diff --git a/src/security/dangerous-config-flags-core.ts b/src/security/dangerous-config-flags-core.ts index 1bd78a446d25..e2be3b5263bd 100644 --- a/src/security/dangerous-config-flags-core.ts +++ b/src/security/dangerous-config-flags-core.ts @@ -26,6 +26,10 @@ type CollectPluginConfigContractMatches = (input: { root: Record; }) => Iterable; +/** + * Plugin config contract data used to extend core dangerous-flag detection. + * Tests and snapshot callers can inject prepared contracts to avoid manifest discovery. + */ export type DangerousConfigFlagContractInputs = { configContractsById?: ReadonlyMap; collectPluginConfigContractMatches?: CollectPluginConfigContractMatches; @@ -54,9 +58,15 @@ function collectExactPluginConfigContractMatches({ pathPattern: string; root: Record; }): PluginConfigContractMatch[] { + // Core fallback only understands exact config keys; manifest-aware callers inject + // the shared matcher so path patterns keep one implementation. return Object.hasOwn(root, pathPattern) ? [{ path: pathPattern, value: root[pathPattern] }] : []; } +/** + * Return every enabled dangerous flag from core config plus plugin config contracts. + * The returned strings are stable audit/report labels, not user-edited config paths. + */ export function collectEnabledInsecureOrDangerousFlagsFromContracts( cfg: OpenClawConfig, inputs: DangerousConfigFlagContractInputs = {}, diff --git a/src/security/dangerous-config-flags-current.ts b/src/security/dangerous-config-flags-current.ts index f5d434834af1..99e4578138bb 100644 --- a/src/security/dangerous-config-flags-current.ts +++ b/src/security/dangerous-config-flags-current.ts @@ -44,6 +44,10 @@ function resolveCurrentPluginConfigContractsById(params: { return contractsById; } +/** + * Collect dangerous flags using the gateway's current plugin metadata snapshot when it is complete. + * Returns undefined when any configured plugin is missing so callers can use manifest discovery. + */ export function collectEnabledInsecureOrDangerousFlagsFromCurrentSnapshot( cfg: OpenClawConfig, ): string[] | undefined { diff --git a/src/security/dangerous-config-flags.ts b/src/security/dangerous-config-flags.ts index aa17142378a3..4823c4ce0a23 100644 --- a/src/security/dangerous-config-flags.ts +++ b/src/security/dangerous-config-flags.ts @@ -6,6 +6,10 @@ import { isRecord } from "../utils.js"; import { collectEnabledInsecureOrDangerousFlagsFromContracts } from "./dangerous-config-flags-core.js"; import { collectEnabledInsecureOrDangerousFlagsFromCurrentSnapshot } from "./dangerous-config-flags-current.js"; +/** + * Collect enabled insecure/dangerous config flags for audit warnings and gateway tool previews. + * Plugin flags use current metadata when requested, then fall back to resolving manifest contracts. + */ export function collectEnabledInsecureOrDangerousFlags( cfg: OpenClawConfig, options: { preferCurrentPluginMetadataSnapshot?: boolean } = {}, diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index b0df52aa2cd8..bca2be97b31e 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -10,6 +10,10 @@ import type { ChannelId } from "../channels/plugins/types.public.js"; import type { GroupPolicy } from "../config/types.base.js"; import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; +/** + * Derive a stable main-DM owner from a single-entry allowlist. + * Wildcards, multi-owner lists, and non-main DM scopes stay unpinned so callers keep route-specific sessions. + */ export function resolvePinnedMainDmOwnerFromAllowlist(params: { dmScope?: string | null; allowFrom?: Array | null; @@ -46,7 +50,10 @@ export function resolveEffectiveAllowFromLists(params: { return resolveChannelIngressEffectiveAllowFromLists(params); } +/** Admission decision returned by legacy DM/group access helpers. */ export type DmGroupAccessDecision = "allow" | "block" | "pairing"; + +/** Stable reason codes used by channel plugins, command auth, and diagnostics. */ export const DM_GROUP_ACCESS_REASON = { GROUP_POLICY_ALLOWED: "group_policy_allowed", GROUP_POLICY_DISABLED: "group_policy_disabled", @@ -58,6 +65,7 @@ export const DM_GROUP_ACCESS_REASON = { DM_POLICY_PAIRING_REQUIRED: "dm_policy_pairing_required", DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted", } as const; +/** Machine-readable reason code for a DM/group access decision. */ export type DmGroupAccessReasonCode = (typeof DM_GROUP_ACCESS_REASON)[keyof typeof DM_GROUP_ACCESS_REASON]; type DmGroupAccessResult = { @@ -72,7 +80,12 @@ const dmGroupAccess = ( reason: string, ): DmGroupAccessResult => ({ decision, reasonCode, reason }); -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve sender access for `dmPolicy=open`, where `*` means fully open and a configured + * allowlist still restricts the accepted sender set. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function resolveOpenDmAllowlistAccess(params: { effectiveAllowFrom: Array; isSenderAllowed: (allowFrom: string[]) => boolean; @@ -141,7 +154,12 @@ export async function readStoreAllowFromForDmPolicy(params: { return await readChannelIngressStoreAllowFromForDmPolicy(params); } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve legacy DM/group sender admission from already-computed allowlists. + * Group messages are evaluated against group policy first; DM policy applies only outside groups. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function resolveDmGroupAccessDecision(params: { isGroup: boolean; dmPolicy?: string | null; @@ -214,7 +232,11 @@ export function resolveDmGroupAccessDecision(params: { ); } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve legacy DM/group sender admission and return the effective allowlists used. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): { decision: DmGroupAccessDecision; reasonCode: DmGroupAccessReasonCode; @@ -244,7 +266,12 @@ export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): }; } -/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */ +/** + * Resolve legacy sender admission plus control-command authorization. + * Control commands use configured allowlists, not pairing-store state, for group safety. + * + * @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. + */ export function resolveDmGroupAccessWithCommandGate( params: DmGroupAccessInputParams & { command?: { diff --git a/src/security/exec-filesystem-policy.ts b/src/security/exec-filesystem-policy.ts index 98403aeb1a84..c8471878e5da 100644 --- a/src/security/exec-filesystem-policy.ts +++ b/src/security/exec-filesystem-policy.ts @@ -10,6 +10,7 @@ import type { AgentToolsConfig, ExecToolConfig } from "../config/types.tools.js" const MUTATING_FS_TOOLS = ["write", "edit", "apply_patch"] as const; const RUNTIME_TOOLS = ["exec", "process"] as const; +/** Scope where exec-like tools remain available while mutating filesystem tools are disabled. */ export type ExecFilesystemPolicyDriftHit = { scopeLabel: string; runtimeTools: string[]; @@ -70,6 +71,7 @@ function isExecFilesystemConstrained(params: { return params.sandboxWorkspaceAccess !== "rw"; } +/** Find policy scopes where exec can still mutate files despite disabled fs tools. */ export function collectExecFilesystemPolicyDriftHits( cfg: OpenClawConfig, ): ExecFilesystemPolicyDriftHit[] { @@ -98,6 +100,8 @@ export function collectExecFilesystemPolicyDriftHits( globalExec, agentExec: context.tools?.exec, }); + // Sandboxed all-mode with non-rw workspace access constrains local exec + // mutations enough that disabling write/edit/apply_patch is not misleading. if ( isExecFilesystemConstrained({ sandboxMode: sandbox.mode, @@ -119,6 +123,8 @@ export function collectExecFilesystemPolicyDriftHits( continue; } + // Drift means every explicit mutating filesystem tool is disabled while a + // runtime path that can still mutate files remains allowed. const disabledFilesystemTools = MUTATING_FS_TOOLS.filter( (tool) => !isToolAllowedByPolicies(tool, policies), ); diff --git a/src/security/external-content-source.ts b/src/security/external-content-source.ts index 04889829a380..8e48daf992df 100644 --- a/src/security/external-content-source.ts +++ b/src/security/external-content-source.ts @@ -1,7 +1,12 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Hook session sources that carry untrusted external content into agent prompts. */ export type HookExternalContentSource = "gmail" | "webhook"; +/** + * Resolve a hook session key into its external content source. + * Unknown `hook:*` sessions are treated as webhooks so legacy/custom hooks stay wrapped. + */ export function resolveHookExternalContentSource( sessionKey: string, ): HookExternalContentSource | undefined { @@ -15,12 +20,14 @@ export function resolveHookExternalContentSource( return undefined; } +/** Map hook session provenance to the prompt-facing external content source label. */ export function mapHookExternalContentSource( source: HookExternalContentSource, ): "email" | "webhook" { return source === "gmail" ? "email" : "webhook"; } +/** Return true when a session key should receive external-content prompt wrapping. */ export function isExternalHookSession(sessionKey: string): boolean { return resolveHookExternalContentSource(sessionKey) !== undefined; } diff --git a/src/security/installed-plugin-dirs.ts b/src/security/installed-plugin-dirs.ts index 0f4509d86745..7c9a9fe93a3c 100644 --- a/src/security/installed-plugin-dirs.ts +++ b/src/security/installed-plugin-dirs.ts @@ -2,6 +2,10 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s const IGNORED_INSTALLED_PLUGIN_DIR_NAMES = new Set(["node_modules", ".openclaw-install-backups"]); +/** + * Decide whether an installed-plugin directory should be skipped by security audits. + * This filters generated install debris while keeping real plugin roots visible to scans. + */ export function shouldIgnoreInstalledPluginDirName(name: string): boolean { const normalized = normalizeOptionalLowercaseString(name); if (!normalized) { @@ -13,6 +17,8 @@ export function shouldIgnoreInstalledPluginDirName(name: string): boolean { if (normalized.startsWith(".")) { return true; } + // Failed installs and rollback copies can contain stale plugin code; audit the live + // root once and ignore these generated backups so findings stay actionable. if (normalized.endsWith(".bak")) { return true; } diff --git a/src/security/scan-paths.ts b/src/security/scan-paths.ts index b4fed3261793..9390e45b751a 100644 --- a/src/security/scan-paths.ts +++ b/src/security/scan-paths.ts @@ -1,5 +1,7 @@ +/** Path containment helpers re-exported for security scanners. */ export { isPathInside, isPathInsideWithRealpath } from "../infra/path-safety.js"; +/** Return true for extension paths intentionally skipped by source scanners. */ export function extensionUsesSkippedScannerPath(entry: string): boolean { const segments = entry.split(/[\\/]+/).filter(Boolean); return segments.some( diff --git a/src/security/test-temp-cases.ts b/src/security/test-temp-cases.ts index e4d813b5d73c..71fa9f32e23a 100644 --- a/src/security/test-temp-cases.ts +++ b/src/security/test-temp-cases.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +/** Small async temp directory factory for security tests with numbered cases. */ export class AsyncTempCaseFactory { private caseId = 0; private fixtureRoot = ""; @@ -21,6 +22,8 @@ export class AsyncTempCaseFactory { async makeTmpDir(label: string) { const dir = path.join(this.fixtureRoot, `case-${this.caseId++}-${label}`); + // Labels are test-authored and become path suffixes; callers keep them + // simple so failure output remains readable. await fs.mkdir(dir, { recursive: true }); return dir; } diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index 26796f51e94c..c6f42fda4c6b 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -1,3 +1,4 @@ +/** Windows ACL audit facade backed by shared infra permission helpers. */ export { createIcaclsResetCommand, formatIcaclsResetCommand, diff --git a/src/sessions/input-provenance.ts b/src/sessions/input-provenance.ts index 98c6bf9c9db6..4a6a9708e54e 100644 --- a/src/sessions/input-provenance.ts +++ b/src/sessions/input-provenance.ts @@ -1,6 +1,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { AgentMessage } from "../agents/runtime/index.js"; +// Input provenance marks whether a user-role message actually came from an +// external user, another session, or an internal system/tool handoff. export const INPUT_PROVENANCE_KIND_VALUES = [ "external_user", "inter_session", @@ -50,6 +52,8 @@ export function normalizeInputProvenance(value: unknown): InputProvenance | unde }; } +// Only attach provenance to user messages that do not already carry it. Existing +// provenance is preserved because upstream channel/runtime code owns that fact. export function applyInputProvenanceToUserMessage( message: AgentMessage, inputProvenance: InputProvenance | undefined, @@ -107,6 +111,9 @@ export function hasInterSessionUserProvenance( return isInterSessionInputProvenance(message.provenance); } +// Prefix text is model-facing safety context for inter-session handoffs. It +// states source metadata and explicitly prevents treating the payload as direct +// end-user instruction. export function buildInterSessionPromptPrefix( inputProvenance: InputProvenance | undefined, ): string { @@ -151,6 +158,8 @@ export function stripInterSessionPromptPrefixForDisplay(text: string): string { return removeFirstInterSessionPromptPrefix(text); } +// Idempotently moves the generated provenance envelope to the top of prompt +// text so later decoration cannot bury the safety instruction. export function annotateInterSessionPromptText( text: string, inputProvenance: InputProvenance | undefined, diff --git a/src/sessions/level-overrides.ts b/src/sessions/level-overrides.ts index fb05f1728794..21a50a2b45be 100644 --- a/src/sessions/level-overrides.ts +++ b/src/sessions/level-overrides.ts @@ -8,6 +8,8 @@ import type { SessionEntry } from "../config/sessions.js"; const INVALID_VERBOSE_LEVEL_ERROR = 'invalid verboseLevel (use "on"|"off"|"full")'; +// Session-level override parsers use tri-state results: undefined means no +// change, null clears the saved override, and a level writes the override. export function parseVerboseOverride( raw: unknown, ): { ok: true; value: VerboseLevel | null | undefined } | { ok: false; error: string } { @@ -27,6 +29,8 @@ export function parseVerboseOverride( return { ok: true, value: normalized }; } +// Mutates a persisted session entry after parsing. Callers keep parse/apply +// separate so invalid user input can be reported before touching the store. export function applyVerboseOverride(entry: SessionEntry, level: VerboseLevel | null | undefined) { if (level === undefined) { return; @@ -57,6 +61,7 @@ export function parseTraceOverride( return { ok: true, value: normalized }; } +// Mutates trace override with the same tri-state contract as verbose level. export function applyTraceOverride(entry: SessionEntry, level: TraceLevel | null | undefined) { if (level === undefined) { return; diff --git a/src/sessions/session-chat-type.ts b/src/sessions/session-chat-type.ts index 46d1fb724dbb..194d7d16df21 100644 --- a/src/sessions/session-chat-type.ts +++ b/src/sessions/session-chat-type.ts @@ -11,6 +11,8 @@ export { type SessionKeyChatType, } from "./session-chat-type-shared.js"; +// Session chat-type derivation first uses generic key parsing, then falls back +// to bootstrap channel plugins for legacy platform-specific session keys. type LegacySessionChatTypeDeriver = NonNullable< NonNullable>["messaging"] >["deriveLegacySessionChatType"]; @@ -29,6 +31,7 @@ function collectLegacyChatTypeCandidatePluginIds(scopedSessionKey: string): stri if (firstToken) { ids.add(firstToken); } + // Historical WhatsApp group keys can be bare JIDs without a channel prefix. if (scopedSessionKey.includes("@g.us")) { ids.add("whatsapp"); } diff --git a/src/sessions/session-id-resolution.ts b/src/sessions/session-id-resolution.ts index 66482349b181..a53bb03cedee 100644 --- a/src/sessions/session-id-resolution.ts +++ b/src/sessions/session-id-resolution.ts @@ -2,6 +2,8 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import type { SessionEntry } from "../config/sessions.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; +// Session-id matching resolves fuzzy CLI/user input against store keys while +// avoiding silent picks when multiple plausible sessions tie. type SessionIdMatch = [string, SessionEntry]; type NormalizedSessionIdMatch = { sessionKey: string; @@ -66,6 +68,8 @@ function collapseAliasMatches(matches: NormalizedSessionIdMatch[]): NormalizedSe if (group.length === 1) { return group[0]; } + // Aliases that normalize to the same request key represent one session. + // Prefer freshest canonical key so ambiguity only reports distinct sessions. return [...group].toSorted((a, b) => { const timeDiff = compareNormalizedUpdatedAtDescending(a, b); if (timeDiff !== 0) { @@ -93,6 +97,8 @@ function selectFreshestUniqueMatch( return undefined; } +// Selection contract: structural suffix/request-key matches beat fuzzy matches; +// tied structural or fuzzy matches stay ambiguous for caller-visible errors. export function resolveSessionIdMatchSelection( matches: Array<[string, SessionEntry]>, sessionId: string, diff --git a/src/sessions/session-id.ts b/src/sessions/session-id.ts index 475d017832b4..4e10dd979cce 100644 --- a/src/sessions/session-id.ts +++ b/src/sessions/session-id.ts @@ -1,3 +1,5 @@ +// Canonical OpenClaw session ids are UUID-shaped. Store/session-key aliases may +// be different; this helper only answers whether raw text looks like a UUID id. export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function looksLikeSessionId(value: string): boolean { diff --git a/src/sessions/session-label.ts b/src/sessions/session-label.ts index 07d5ec5ffa1e..04e7034ccee5 100644 --- a/src/sessions/session-label.ts +++ b/src/sessions/session-label.ts @@ -1,3 +1,5 @@ +// User-editable session labels are short display strings saved in session +// metadata; parser returns structured errors for CLI/API callers. export const SESSION_LABEL_MAX_LENGTH = 512; export type ParsedSessionLabel = { ok: true; label: string } | { ok: false; error: string }; diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 9e693ac8d460..6c38bff01ba2 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -9,6 +9,8 @@ import { } from "./input-provenance.js"; import { emitSessionTranscriptUpdate } from "./transcript-events.js"; +// User-turn transcript helpers persist the selected prompt/media as a user +// message before or during runtime execution, preserving provenance/idempotency. type TranscriptAppendConfig = Parameters[0]["config"]; type UserTurnSessionEntry = { @@ -165,6 +167,8 @@ function normalizeTranscriptText(value: string | null | undefined): string { const CHANNEL_MEDIA_PLACEHOLDER_PATTERN = /^(?:\s+\([^)]*\))?$/i; +// Select text for persisted user turns. Channel-generated media placeholders +// are dropped only when structured media is present, keeping plain text intact. export function resolvePersistedUserTurnText( value: string | null | undefined, options: ResolvePersistedUserTurnTextOptions = {}, @@ -214,6 +218,8 @@ function normalizeOptionalTextArray( const URL_LIKE_MEDIA_PATH_PATTERN = /^[a-z][a-z0-9+.-]*:/i; function resolveTranscriptMediaPath(pathValue: string, workspaceDir: string | undefined): string { + // Relative staged media paths are anchored to the media workspace; absolute + // paths and URL-like refs are already stable transcript references. if (!workspaceDir || path.isAbsolute(pathValue) || URL_LIKE_MEDIA_PATH_PATTERN.test(pathValue)) { return pathValue; } @@ -323,6 +329,8 @@ function isBeforeAgentRunBlockedMessage(message: AgentMessage): boolean { return marker !== undefined; } +// Runtime messages may lack transcript metadata because channel adapters prepare +// display text separately. Merge only safe user messages, never block markers. export function mergePreparedUserTurnMessageForRuntime(params: { runtimeMessage: AgentMessage; preparedMessage?: PersistedUserTurnMessage; @@ -427,6 +435,8 @@ export async function appendUserTurnTranscriptMessage( }; } +// Store-backed persistence resolves the current session transcript file lazily +// so callers can pass a session entry/store without knowing the final path. export async function persistUserTurnTranscript( params: PersistUserTurnTranscriptParams, ): Promise { @@ -557,6 +567,8 @@ export function createUserTurnTranscriptRecorder( return undefined; } if (options.waitForRuntime) { + // Approved persistence waits for runtime-owned writes first to avoid + // duplicate user turns when the harness already persisted the message. await waitForRuntimePersistence(); if (persisted) { return persistedResult; diff --git a/src/shared/agent-liveness.ts b/src/shared/agent-liveness.ts index a00fb7522322..a98b23f3ae60 100644 --- a/src/shared/agent-liveness.ts +++ b/src/shared/agent-liveness.ts @@ -1,12 +1,15 @@ +/** Return true for the normalized liveness state that means a run is blocked. */ export function isBlockedLivenessState(livenessState: unknown): boolean { return typeof livenessState === "string" && livenessState.trim().toLowerCase() === "blocked"; } +/** Convert a blocked-run error payload into a user-facing wait/status message. */ export function formatBlockedLivenessError(error: unknown): string { const message = typeof error === "string" ? error.trim() : ""; return message || "Agent run blocked before producing a usable result."; } +/** Coerce any blocked liveness state into an error status while preserving other statuses. */ export function normalizeBlockedLivenessWaitStatus< TStatus extends "ok" | "error" | "timeout" | "pending", >(params: { diff --git a/src/shared/agent-run-status.ts b/src/shared/agent-run-status.ts index 3aaf0ead3313..df1e916bc37f 100644 --- a/src/shared/agent-run-status.ts +++ b/src/shared/agent-run-status.ts @@ -1,3 +1,8 @@ +/** + * Shared agent-run status predicates for gateway wait loops and delivery announcements. + * Keep the status set aligned with the gateway protocol values that can still transition. + */ +/** Statuses that are not final and should keep waiters/subscribers attached. */ const NON_TERMINAL_AGENT_RUN_STATUSES = new Set(["accepted", "started", "in_flight"]); /** Returns true for agent-run statuses that still need polling or live updates. */ diff --git a/src/shared/avatar-policy.ts b/src/shared/avatar-policy.ts index a0e344283720..316a366ba933 100644 --- a/src/shared/avatar-policy.ts +++ b/src/shared/avatar-policy.ts @@ -2,10 +2,20 @@ import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { isPathInside } from "../infra/path-guards.js"; +/** + * Shared avatar source policy for config validation, agent identity loading, + * gateway uploads, and Control UI rendering hints. + */ + +/** Maximum avatar payload size accepted by local file and gateway upload paths. */ export const AVATAR_MAX_BYTES = 2 * 1024 * 1024; +// Local avatar serving intentionally excludes formats handled only as MIME fallbacks: +// callers may recognize BMP/TIFF MIME types, but local inline serving stays on +// the smaller browser-safe extension set below. const LOCAL_AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); +/** MIME hints for known image extensions, including formats not accepted for local serving. */ const AVATAR_MIME_BY_EXT: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -18,10 +28,15 @@ const AVATAR_MIME_BY_EXT: Record = { ".tiff": "image/tiff", }; +/** Detects data URLs before image-specific avatar validation. */ export const AVATAR_DATA_RE = /^data:/i; +/** Detects inline image data URLs that can be used as avatar sources. */ export const AVATAR_IMAGE_DATA_RE = /^data:image\//i; +/** Detects remote avatar URLs served over HTTP(S). */ export const AVATAR_HTTP_RE = /^https?:\/\//i; +/** Detects URI schemes so non-path avatar values can be rejected or routed. */ export const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +/** Detects Windows absolute paths before URI-scheme classification. */ export const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/; const AVATAR_PATH_EXT_RE = /\.(png|jpe?g|gif|webp|svg|ico)$/i; diff --git a/src/shared/balanced-json.ts b/src/shared/balanced-json.ts index cfab52c4887c..f4a7ebf973b8 100644 --- a/src/shared/balanced-json.ts +++ b/src/shared/balanced-json.ts @@ -1,5 +1,7 @@ +/** JSON opening delimiters supported by the balanced-fragment scanner. */ export type JsonOpeningDelimiter = "{" | "["; +/** One balanced JSON object/array fragment found inside arbitrary text. */ export type BalancedJsonFragment = { json: string; startIndex: number; @@ -18,6 +20,7 @@ function isJsonOpeningDelimiter( return char === "{" ? openers.includes("{") : char === "[" && openers.includes("["); } +/** Extract the first balanced JSON object/array prefix found in text. */ export function extractBalancedJsonPrefix( raw: string, opts: { openers?: readonly JsonOpeningDelimiter[] } = {}, @@ -40,6 +43,8 @@ export function extractBalancedJsonPrefix( break; } if (inString) { + // Delimiters inside strings are data, not structure. Track escapes so an + // escaped quote does not prematurely end string mode. if (escaped) { escaped = false; } else if (char === "\\") { @@ -68,6 +73,7 @@ export function extractBalancedJsonPrefix( return null; } +/** Extract every balanced JSON object/array fragment from arbitrary text. */ export function extractBalancedJsonFragments( raw: string, opts: { openers?: readonly JsonOpeningDelimiter[] } = {}, diff --git a/src/shared/custom-command-config.ts b/src/shared/custom-command-config.ts index 5664ad3688d1..880aaa687a87 100644 --- a/src/shared/custom-command-config.ts +++ b/src/shared/custom-command-config.ts @@ -1,16 +1,19 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Raw custom slash-command entry from config. */ export type CustomCommandInput = { command?: string | null; description?: string | null; }; +/** Validation issue for one configured custom command. */ export type CustomCommandIssue = { index: number; field: "command" | "description"; message: string; }; +/** Command validation policy for one command family. */ export type CustomCommandConfig = { label: string; pattern: RegExp; @@ -20,6 +23,7 @@ export type CustomCommandConfig = { const DEFAULT_PREFIX = "/"; +/** Normalize a slash command name to the internal lowercase underscore form. */ export function normalizeSlashCommandName(value: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -29,10 +33,12 @@ export function normalizeSlashCommandName(value: string): string { return normalizeLowercaseStringOrEmpty(withoutSlash).replace(/-/g, "_"); } +/** Normalize command descriptions without changing user-authored wording. */ export function normalizeCommandDescription(value: string): string { return value.trim(); } +/** Validate and normalize custom command config entries. */ export function resolveCustomCommands(params: { commands?: CustomCommandInput[] | null; reservedCommands?: Set; @@ -55,6 +61,8 @@ export function resolveCustomCommands(params: { for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; + // Accumulate issues instead of throwing so config UIs and CLIs can present + // all invalid commands in one pass. const normalized = normalizeSlashCommandName(entry?.command ?? ""); if (!normalized) { issues.push({ diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index 8ad5ecdbf10a..92cd582bf11b 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -57,9 +57,13 @@ export function resolveOpenClawManifestBlock(params: { } export type OpenClawManifestRequires = { + /** All binaries that must be available. */ bins: string[]; + /** Alternative binaries where any one match is enough. */ anyBins: string[]; + /** Environment variables required by the entry. */ env: string[]; + /** Config paths required by the entry. */ config: string[]; }; @@ -99,10 +103,15 @@ export function resolveOpenClawManifestOs(metadataObj: Record): } export type ParsedOpenClawManifestInstallBase = { + /** Original install entry for caller-specific parsing. */ raw: Record; + /** Normalized install kind accepted by the caller. */ kind: string; + /** Optional stable package/tool id from the manifest entry. */ id?: string; + /** Optional human-facing package/tool label. */ label?: string; + /** Optional binaries expected after installation. */ bins?: string[]; }; diff --git a/src/shared/gateway-method-policy.ts b/src/shared/gateway-method-policy.ts index 7510d35f0d47..5d9fb602b7c6 100644 --- a/src/shared/gateway-method-policy.ts +++ b/src/shared/gateway-method-policy.ts @@ -7,10 +7,12 @@ const RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES = [ const RESERVED_ADMIN_GATEWAY_METHOD_SCOPE = "operator.admin" as const; +/** Return whether a gateway method is reserved for operator admin calls. */ function isReservedAdminGatewayMethod(method: string): boolean { return RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)); } +/** Resolve the mandatory scope for reserved gateway methods. */ export function resolveReservedGatewayMethodScope( method: string, ): typeof RESERVED_ADMIN_GATEWAY_METHOD_SCOPE | undefined { @@ -20,6 +22,7 @@ export function resolveReservedGatewayMethodScope( return RESERVED_ADMIN_GATEWAY_METHOD_SCOPE; } +/** Coerce plugin-declared scopes away from unsafe reserved gateway method scopes. */ export function normalizePluginGatewayMethodScope( method: string, scope: TScope | undefined, diff --git a/src/shared/global-singleton.ts b/src/shared/global-singleton.ts index c7c49e062c3b..ef1ab5f07b5d 100644 --- a/src/shared/global-singleton.ts +++ b/src/shared/global-singleton.ts @@ -1,3 +1,7 @@ +/** + * Process-local singleton helpers for registries, caches, and SDK-visible shared state. + * Keys must be symbols so unrelated modules cannot collide on `globalThis` property names. + */ /** Resolves a process-local singleton for caches and registries that tolerate helper lookup. */ export function resolveGlobalSingleton(key: symbol, create: () => T): T { const globalStore = globalThis as Record; diff --git a/src/shared/google-models.ts b/src/shared/google-models.ts index c4c0187edcc9..ca46a2aeaf34 100644 --- a/src/shared/google-models.ts +++ b/src/shared/google-models.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Return true when a model id/name refers to the Gemma 4 family. */ export function isGemma4ModelId(modelId?: string | null): boolean { const normalized = normalizeLowercaseStringOrEmpty(modelId); return /(?:^|[/_:-])gemma[-_]?4(?:$|[/_.:-])/.test(normalized); diff --git a/src/shared/google-turn-ordering.ts b/src/shared/google-turn-ordering.ts index 3336901b3fc8..5a1f71aa2a50 100644 --- a/src/shared/google-turn-ordering.ts +++ b/src/shared/google-turn-ordering.ts @@ -2,6 +2,7 @@ import type { AgentMessage } from "../agents/runtime/index.js"; const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)"; +/** Add a synthetic user bootstrap when Google-style providers receive assistant-first turns. */ export function sanitizeGoogleAssistantFirstOrdering(messages: AgentMessage[]): AgentMessage[] { const first = messages[0] as { role?: unknown; content?: unknown } | undefined; const role = first?.role; @@ -17,6 +18,8 @@ export function sanitizeGoogleAssistantFirstOrdering(messages: AgentMessage[]): return messages; } + // Google chat APIs reject assistant-first transcripts. The bootstrap marker + // makes the mutation idempotent while preserving the original assistant turn. const bootstrap: AgentMessage = { role: "user", content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT, diff --git a/src/shared/json-schema-defaults.ts b/src/shared/json-schema-defaults.ts index e3e38fa714ed..c4222e26386d 100644 --- a/src/shared/json-schema-defaults.ts +++ b/src/shared/json-schema-defaults.ts @@ -455,6 +455,7 @@ function resolveSchemaRef( return localTarget.found ? localTarget : resolveSchemaResourceRef(root, ref, baseId); } +/** Normalize JSON Schema constructs into the TypeBox compiler subset used by plugin validators. */ export function normalizeJsonSchemaForTypeBox(schema: JsonSchemaValue): JsonSchemaValue { return normalizeJsonSchemaNode(schema) as JsonSchemaValue; } @@ -700,6 +701,7 @@ function findJsonSchemaNodeError( return undefined; } +/** Return the first structural JSON Schema error that would make validation/defaulting unsafe. */ export function findJsonSchemaShapeError(schema: JsonSchemaValue): string | undefined { return findJsonSchemaNodeError(schema, "", schema, schema, undefined); } @@ -1261,6 +1263,7 @@ function applySchemaDefaults( return nextValue; } +/** Apply schema defaults to a config value while preserving caller-owned value shape. */ export function applyJsonSchemaDefaults(schema: JsonSchemaValue, value: T): T { return applySchemaDefaults(schema, value) as T; } diff --git a/src/shared/json-schema.types.ts b/src/shared/json-schema.types.ts index 98b295ec8c5d..ad940e366196 100644 --- a/src/shared/json-schema.types.ts +++ b/src/shared/json-schema.types.ts @@ -1,3 +1,4 @@ import type { TSchema } from "typebox"; +/** TypeBox schema value widened for generic JSON-schema object transforms. */ export type JsonSchemaObject = TSchema & Record; diff --git a/src/shared/lazy-promise.ts b/src/shared/lazy-promise.ts index 6dc4fd4770dc..f87100ff6dc2 100644 --- a/src/shared/lazy-promise.ts +++ b/src/shared/lazy-promise.ts @@ -1,13 +1,22 @@ +/** Manual-control promise cache for lazy runtime resources. */ export type LazyPromiseLoader = { + /** Resolves the cached value, creating one load promise when needed. */ load(): Promise; + /** Drops the cached promise so the next load starts fresh. */ clear(): void; }; +/** Options for controlling lazy promise cache behavior. */ export type LazyPromiseLoaderOptions = { + /** Keep rejected promises cached instead of allowing the next caller to retry. */ cacheRejections?: boolean; }; -/** Creates a small promise cache that dedupes concurrent loads and can be cleared manually. */ +/** + * Creates a small promise cache that dedupes concurrent loads and can be cleared manually. + * + * Rejections are evicted by default so transient dynamic-import/runtime failures can recover. + */ export function createLazyPromiseLoader( load: () => T | Promise, options: LazyPromiseLoaderOptions = {}, diff --git a/src/shared/message-content-blocks.ts b/src/shared/message-content-blocks.ts index 4a0224e6ff4f..a845ae46d2b8 100644 --- a/src/shared/message-content-blocks.ts +++ b/src/shared/message-content-blocks.ts @@ -1,3 +1,4 @@ +/** Visit object-shaped content blocks in an assistant/user message payload. */ export function visitObjectContentBlocks( message: unknown, visitor: (block: Record) => void, diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index b80e02c5cc44..d77add44f01f 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -1,3 +1,4 @@ +/** Node record returned by gateway node-list endpoints. */ export type NodeListNode = { nodeId: string; displayName?: string; @@ -22,6 +23,7 @@ export type NodeListNode = { approvedAtMs?: number; }; +/** Pending pairing/access request shown to operators. */ export type PendingRequest = { requestId: string; nodeId: string; @@ -36,6 +38,7 @@ export type PendingRequest = { requiredApproveScopes?: Array<"operator.pairing" | "operator.write" | "operator.admin">; }; +/** Persisted paired node entry with optional token and permission metadata. */ export type PairedNode = { nodeId: string; token?: string; @@ -53,6 +56,7 @@ export type PairedNode = { lastSeenReason?: string; }; +/** Combined pairing list result used by CLI/UI node approval surfaces. */ export type PairingList = { pending: PendingRequest[]; paired: PairedNode[]; diff --git a/src/shared/node-match.ts b/src/shared/node-match.ts index 54bb84432604..46a72d819836 100644 --- a/src/shared/node-match.ts +++ b/src/shared/node-match.ts @@ -4,17 +4,33 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +/** + * Shared node-selection policy for CLI, gateway-facing SDK helpers, and plugins. + * + * Exact ids, remote IPs, normalized display names, and long id prefixes are the + * only accepted query shapes; fuzzy ordering lives here so callers agree. + */ + +/** Node fields accepted by shared CLI/API node selection helpers. */ export type NodeMatchCandidate = { + /** Stable node id used for RPC/session routing. */ nodeId: string; + /** Human-facing node name used for fuzzy operator input. */ displayName?: string; + /** Tailscale or network address accepted as an exact match. */ remoteIp?: string; + /** Connected nodes win only after the strongest match type is chosen. */ connected?: boolean; + /** Client id used to prefer current OpenClaw nodes over legacy migration ties. */ clientId?: string; }; type ScoredNodeMatch = { + /** Candidate that matched one of the accepted query shapes. */ node: NodeMatchCandidate; + /** Match class strength; higher classes outrank all tie-break heuristics. */ matchScore: number; + /** Tie-break score within one match class, such as connected/current-client preference. */ selectionScore: number; }; @@ -74,6 +90,7 @@ function resolveMatchScore( query: string, queryNormalized: string, ): number { + // Match class outranks selection heuristics: exact ids beat IPs, names, and id prefixes. if (node.nodeId === query) { return 4_000; } @@ -154,6 +171,8 @@ export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query: return strongestMatches[0]?.node.nodeId ?? ""; } + // Only after the strongest match class is isolated do operational tie-breakers + // like connected state and current-client preference choose a winner. const topSelectionScore = Math.max(...strongestMatches.map((match) => match.selectionScore)); const matches = strongestMatches.filter((match) => match.selectionScore === topSelectionScore); if (matches.length === 1) { diff --git a/src/shared/node-presence.ts b/src/shared/node-presence.ts index c1a6c00cbc3e..2d21f9c3e9ba 100644 --- a/src/shared/node-presence.ts +++ b/src/shared/node-presence.ts @@ -1,7 +1,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Gateway event name used by node hosts to refresh their last-seen presence. */ export const NODE_PRESENCE_ALIVE_EVENT = "node.presence.alive"; +/** Reasons accepted from native/background node presence events. */ const NODE_PRESENCE_ALIVE_REASONS = [ "background", "silent_push", @@ -11,10 +13,12 @@ const NODE_PRESENCE_ALIVE_REASONS = [ "connect", ] as const; +/** Canonical trigger reason stored with node presence updates. */ export type NodePresenceAliveReason = (typeof NODE_PRESENCE_ALIVE_REASONS)[number]; const NODE_PRESENCE_ALIVE_REASON_SET = new Set(NODE_PRESENCE_ALIVE_REASONS); +/** Normalizes untrusted presence trigger values, defaulting unknown input to background. */ export function normalizeNodePresenceAliveReason(value: unknown): NodePresenceAliveReason { const normalized = normalizeOptionalString(value)?.toLowerCase(); if (normalized && NODE_PRESENCE_ALIVE_REASON_SET.has(normalized)) { diff --git a/src/shared/number-coercion.ts b/src/shared/number-coercion.ts index d5c2041b4318..4c93865de6ca 100644 --- a/src/shared/number-coercion.ts +++ b/src/shared/number-coercion.ts @@ -1 +1,2 @@ +/** Shared numeric coercion facade for legacy imports inside core. */ export * from "@openclaw/normalization-core/number-coercion"; diff --git a/src/shared/regexp.ts b/src/shared/regexp.ts index e235b74766d3..991c7fc914ee 100644 --- a/src/shared/regexp.ts +++ b/src/shared/regexp.ts @@ -1,3 +1,4 @@ +/** Escape text so it can be embedded literally inside a RegExp pattern. */ export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/shared/runtime-import.ts b/src/shared/runtime-import.ts index 2141b69d65ea..43f81f539f09 100644 --- a/src/shared/runtime-import.ts +++ b/src/shared/runtime-import.ts @@ -1,18 +1,31 @@ +/** + * Runtime import helpers for lazy modules that may be loaded from file URLs or platform paths. + * Windows paths need normalization before Node's ESM loader can import them safely. + */ import { toSafeImportPath } from "./import-specifier.js"; +/** Runtime-facing alias for import specifier normalization helpers. */ export { toSafeImportPath as toSafeRuntimeImportPath } from "./import-specifier.js"; -/** Resolves a runtime import part against a base URL/path after platform-safe normalization. */ +/** + * Resolves lazy runtime import parts against the caller's module URL or path. + * Absolute normalized paths stay standalone; relative parts resolve against the normalized base. + */ export function resolveRuntimeImportSpecifier(baseUrl: string, parts: readonly string[]): string { const joined = parts.join(""); const safeJoined = toSafeImportPath(joined); + // Absolute Windows paths and UNC shares become standalone file URLs instead + // of being resolved relative to the caller's module URL. if (safeJoined !== joined) { return safeJoined; } return new URL(joined, toSafeImportPath(baseUrl)).href; } -/** Imports a lazy runtime module through the normalized runtime specifier. */ +/** + * Imports a lazy runtime module through the normalized runtime specifier. + * The injectable importer keeps platform-specific specifier handling unit-testable. + */ export async function importRuntimeModule( baseUrl: string, parts: readonly string[], diff --git a/src/shared/safe-record.ts b/src/shared/safe-record.ts index 7d069b93f360..48807f6b949d 100644 --- a/src/shared/safe-record.ts +++ b/src/shared/safe-record.ts @@ -1,3 +1,4 @@ +/** Defensive object guard for values that may have hostile traps. */ export function isRecord(value: unknown): value is Record { try { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -6,6 +7,7 @@ export function isRecord(value: unknown): value is Record { } } +/** Read one property from a record-like value without letting traps escape. */ export function readRecordValue(value: unknown, key: string): unknown { if (!isRecord(value)) { return undefined; @@ -17,6 +19,7 @@ export function readRecordValue(value: unknown, key: string): unknown { } } +/** Copy array entries defensively from values that may throw on length/index access. */ export function copyArrayEntries(value: unknown): unknown[] { let isArray: boolean; try { @@ -47,6 +50,7 @@ export function copyArrayEntries(value: unknown): unknown[] { return entries; } +/** Copy record entries whose values are also record-shaped. */ export function copyRecordEntries(value: unknown): Array<[string, T]> { if (!isRecord(value)) { return []; @@ -62,6 +66,8 @@ export function copyRecordEntries(value: unknown): Array<[string, T]> { const entries: Array<[string, T]> = []; for (const key of keys) { const entry = readRecordValue(value, key); + // Callers use this for nested config maps; non-object leaves are ignored so + // later code does not need repeated record guards. if (isRecord(entry)) { entries.push([key, entry as T]); } diff --git a/src/shared/schema-keyword-strip.ts b/src/shared/schema-keyword-strip.ts index 0eb58b33902f..bab8b14956eb 100644 --- a/src/shared/schema-keyword-strip.ts +++ b/src/shared/schema-keyword-strip.ts @@ -1,3 +1,4 @@ +/** Recursively remove schema keywords unsupported by a target provider/tool surface. */ export function stripUnsupportedSchemaKeywords( schema: unknown, unsupportedKeywords: ReadonlySet, @@ -14,6 +15,8 @@ export function stripUnsupportedSchemaKeywords( if (unsupportedKeywords.has(key)) { continue; } + // Schema containers hold nested schemas under different shapes. Recurse + // through each known container while preserving unrelated metadata fields. if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) { cleaned[key] = Object.fromEntries( Object.entries(value as Record).map(([childKey, childValue]) => [ diff --git a/src/shared/scoped-expiring-id-cache.ts b/src/shared/scoped-expiring-id-cache.ts index 51290e46eb25..d04dc401c85c 100644 --- a/src/shared/scoped-expiring-id-cache.ts +++ b/src/shared/scoped-expiring-id-cache.ts @@ -1,6 +1,10 @@ +/** Per-scope TTL cache used to suppress repeated ids without cross-scope bleed. */ export type ScopedExpiringIdCache = { + /** Records an id for a scope at the provided timestamp or current time. */ record: (scope: TScope, id: TId, now?: number) => void; + /** Returns true while the id is present and within the inclusive TTL window. */ has: (scope: TScope, id: TId, now?: number) => boolean; + /** Clears every scope and id from the backing store. */ clear: () => void; }; @@ -13,8 +17,11 @@ export function createScopedExpiringIdCache< TScope extends string | number, TId extends string | number, >(options: { + /** Backing store supplied by callers that need module- or test-owned lifecycle. */ store: Map>; + /** Time-to-live in milliseconds; non-finite values collapse to immediate expiry. */ ttlMs: number; + /** Scope size that triggers opportunistic cleanup on record. */ cleanupThreshold: number; }): ScopedExpiringIdCache { const ttlMs = resolveNonNegativeInteger(options.ttlMs, 0); diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index a026767dfff3..8cace4f844a6 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -1,3 +1,4 @@ +/** Agent identity fields returned by gateway session listing APIs. */ export type GatewayAgentIdentity = { name?: string; theme?: string; @@ -6,22 +7,26 @@ export type GatewayAgentIdentity = { avatarUrl?: string; }; +/** Model summary returned for an agent/session row. */ export type GatewayAgentModel = { primary?: string; fallbacks?: string[]; }; +/** Runtime selection metadata for an agent row. */ export type GatewayAgentRuntime = { id: string; fallback?: "openclaw" | "none"; source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key"; }; +/** Thinking-level option exposed to UI clients. */ export type GatewayThinkingLevelOption = { id: string; label: string; }; +/** Common agent row shape used by session list responses. */ export type GatewayAgentRow = { id: string; name?: string; @@ -34,6 +39,7 @@ export type GatewayAgentRow = { thinkingDefault?: string; }; +/** Generic base for paged session-list responses. */ export type SessionsListResultBase = { ts: number; path: string; @@ -47,6 +53,7 @@ export type SessionsListResultBase = { sessions: TRow[]; }; +/** Generic base for successful session patch responses. */ export type SessionsPatchResultBase = { ok: true; path: string; diff --git a/src/shared/session-usage-timeseries-types.ts b/src/shared/session-usage-timeseries-types.ts index 97c9324b3f68..85de909901ea 100644 --- a/src/shared/session-usage-timeseries-types.ts +++ b/src/shared/session-usage-timeseries-types.ts @@ -1,16 +1,29 @@ +/** Usage totals for one sampled point in a session usage time series. */ export type SessionUsageTimePoint = { + /** Unix epoch milliseconds for the sample. */ timestamp: number; + /** Input tokens counted in this sample window. */ input: number; + /** Output tokens counted in this sample window. */ output: number; + /** Cached input tokens read in this sample window. */ cacheRead: number; + /** Cached input tokens written in this sample window. */ cacheWrite: number; + /** Total billable and cached tokens counted in this sample window. */ totalTokens: number; + /** Estimated cost for this sample window. */ cost: number; + /** Running token total through this sample. */ cumulativeTokens: number; + /** Running cost total through this sample. */ cumulativeCost: number; }; +/** Downsampled usage series returned for a single session when requested. */ export type SessionUsageTimeSeries = { + /** Session id for the series when loaded from a known session record. */ sessionId?: string; + /** Ordered samples for charting usage over time. */ points: SessionUsageTimePoint[]; }; diff --git a/src/shared/store-writer-queue.ts b/src/shared/store-writer-queue.ts index 994df1b562fb..c69064d9fb7c 100644 --- a/src/shared/store-writer-queue.ts +++ b/src/shared/store-writer-queue.ts @@ -1,14 +1,20 @@ /** Pending exclusive store write plus the promise hooks for its caller. */ export type StoreWriterTask = { + /** Write operation to run once earlier tasks for the same store path finish. */ fn: () => Promise; + /** Resolves the caller's promise with the write result. */ resolve: (value: unknown) => void; + /** Rejects the caller's promise with the write failure or test cleanup error. */ reject: (reason: unknown) => void; }; /** Per-store-path FIFO queue that serializes file writes within one process. */ export type StoreWriterQueue = { + /** True while a drain loop owns this queue. */ running: boolean; + /** Writes waiting behind the active drain. */ pending: StoreWriterTask[]; + /** Active drain promise, reused by waiters until the current batch settles. */ drainPromise: Promise | null; }; diff --git a/src/shared/string-sample.ts b/src/shared/string-sample.ts index f9691ace82df..56cbfb9234cb 100644 --- a/src/shared/string-sample.ts +++ b/src/shared/string-sample.ts @@ -1,7 +1,14 @@ +/** + * Shared string sampling for operator logs and SDK helpers that need bounded readable lists. + * This intentionally formats for humans, not for machine parsing. + */ /** Formats a bounded comma-separated sample of string entries with a hidden-count suffix. */ export function summarizeStringEntries(params: { + /** Entries to summarize; nullish values are treated as an empty list. */ entries?: ReadonlyArray | null; + /** Maximum visible entries; non-finite values use the default and values below one clamp to one. */ limit?: number; + /** Text returned when no entries are available. */ emptyText?: string; }): string { const entries = params.entries ?? []; @@ -9,6 +16,7 @@ export function summarizeStringEntries(params: { return params.emptyText ?? ""; } const rawLimit = params.limit ?? 6; + // Keep summaries useful for operator output even when callers pass bad limits. const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : 6; const sample = entries.slice(0, limit); const suffix = entries.length > sample.length ? ` (+${entries.length - sample.length})` : ""; diff --git a/src/shared/text-chunking.ts b/src/shared/text-chunking.ts index 6809f19f569f..fff639efc089 100644 --- a/src/shared/text-chunking.ts +++ b/src/shared/text-chunking.ts @@ -1,4 +1,9 @@ -/** Splits text into bounded chunks using caller-owned soft-break selection. */ +/** + * Splits text into bounded chunks using caller-owned soft-break selection. + * + * The resolver sees each limit-sized window and returns an in-window break index; + * invalid indexes fall back to the hard limit so chunking always makes progress. + */ export function chunkTextByBreakResolver( text: string, limit: number, @@ -25,7 +30,8 @@ export function chunkTextByBreakResolver( if (chunk.length > 0) { chunks.push(chunk); } - // If the break lands before whitespace, consume one separator and trim the rest for the next chunk. + // Keep separator ownership with the boundary: one matched separator is + // consumed here, and any adjacent whitespace is trimmed before the next window. const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); remaining = remaining.slice(nextStart).trimStart(); diff --git a/src/shared/text/formatted-reasoning-message.ts b/src/shared/text/formatted-reasoning-message.ts index 579656e0e418..822f37b70c7a 100644 --- a/src/shared/text/formatted-reasoning-message.ts +++ b/src/shared/text/formatted-reasoning-message.ts @@ -1,5 +1,6 @@ import { stripReasoningTagsFromText } from "./reasoning-tags.js"; +/** Strip provider-formatted Reasoning/Thinking preambles from visible text. */ export function stripFormattedReasoningMessage(text: string): string { const stripped = stripReasoningTagsFromText(text); const lines = stripped.split(/\r?\n/u); @@ -22,6 +23,8 @@ export function stripFormattedReasoningMessage(text: string): string { } } + // Remove blank/italic summary preamble lines but preserve the substantive + // answer body exactly after the first non-preamble line. let index = 1; while (index < lines.length) { const trimmed = lines[index]?.trim() ?? ""; diff --git a/src/shared/text/plain-text-tool-call-blocks.ts b/src/shared/text/plain-text-tool-call-blocks.ts index f4a6a994b8f9..3bdb91c678d1 100644 --- a/src/shared/text/plain-text-tool-call-blocks.ts +++ b/src/shared/text/plain-text-tool-call-blocks.ts @@ -1 +1,2 @@ +/** Facade for shared plain-text tool-call block stripping used by visible-text cleanup. */ export { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js"; diff --git a/src/shared/thread-binding-lifecycle.ts b/src/shared/thread-binding-lifecycle.ts index 7ae059622029..2609cd58993e 100644 --- a/src/shared/thread-binding-lifecycle.ts +++ b/src/shared/thread-binding-lifecycle.ts @@ -1,17 +1,27 @@ +/** Persisted timestamps and optional TTL overrides for one channel thread binding. */ export type ThreadBindingLifecycleRecord = { + /** Epoch milliseconds when the binding was created. */ boundAt: number; + /** Epoch milliseconds of the latest activity seen for the bound conversation. */ lastActivityAt: number; + /** Optional idle timeout override in milliseconds; zero disables idle expiry. */ idleTimeoutMs?: number; + /** Optional max-age override in milliseconds; zero disables max-age expiry. */ maxAgeMs?: number; }; /** Resolves the next expiration for a channel thread binding from idle and max-age limits. */ export function resolveThreadBindingLifecycle(params: { + /** Stored binding timestamps and optional timeout overrides. */ record: ThreadBindingLifecycleRecord; + /** Fallback idle timeout in milliseconds when the record has no override. */ defaultIdleTimeoutMs: number; + /** Fallback max-age timeout in milliseconds when the record has no override. */ defaultMaxAgeMs: number; }): { + /** Earliest expiration timestamp, omitted when both limits are disabled. */ expiresAt?: number; + /** Expiration source corresponding to `expiresAt`. */ reason?: "idle-expired" | "max-age-expired"; } { const idleTimeoutMs = diff --git a/src/shared/usage-types.ts b/src/shared/usage-types.ts index a9cdc2f8b429..e90c36fd5a3e 100644 --- a/src/shared/usage-types.ts +++ b/src/shared/usage-types.ts @@ -10,14 +10,23 @@ import type { SessionToolUsage, } from "../infra/session-cost-usage.js"; +/** One session or session-family row returned by the gateway usage endpoint. */ export type SessionUsageEntry = { + /** Stable row key for UI diffing; may be a session id or family key. */ key: string; + /** Human-readable session label when available. */ label?: string; + /** Concrete session id for instance-scoped rows. */ sessionId?: string; + /** Whether this row represents one session instance or a grouped family. */ scope?: "instance" | "family"; + /** Grouping key shared by related historical session instances. */ sessionFamilyKey?: string; + /** Latest/current session id for a grouped family row. */ currentSessionId?: string; + /** Session ids included in a family aggregate row. */ includedSessionIds?: string[]; + /** Count of historical instances included in the family row. */ historicalInstanceCount?: number; updatedAt?: number; agentId?: string; @@ -41,6 +50,7 @@ export type SessionUsageEntry = { contextWeight?: SessionSystemPromptReport | null; }; +/** Cross-session aggregate buckets returned alongside usage rows. */ export type SessionsUsageAggregates = { messages: SessionMessageCounts; tools: SessionToolUsage; @@ -61,9 +71,13 @@ export type SessionsUsageAggregates = { }>; }; +/** Full gateway response for the sessions usage view. */ export type SessionsUsageResult = { + /** Unix epoch milliseconds for when this report was generated. */ updatedAt: number; + /** Inclusive report start date in YYYY-MM-DD form. */ startDate: string; + /** Inclusive report end date in YYYY-MM-DD form. */ endDate: string; sessions: SessionUsageEntry[]; totals: CostUsageSummary["totals"]; diff --git a/src/skills/discovery/bins.ts b/src/skills/discovery/bins.ts index 920142faef20..6c8973e1a14d 100644 --- a/src/skills/discovery/bins.ts +++ b/src/skills/discovery/bins.ts @@ -1,6 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SkillEntry } from "../types.js"; +/** Collects all binary names a set of skills may require or install. */ export function collectSkillBins(entries: SkillEntry[]): string[] { const bins = new Set(); for (const entry of entries) { diff --git a/src/skills/discovery/chat-command-invocation.ts b/src/skills/discovery/chat-command-invocation.ts index 846ccfc11704..44132cd63c9d 100644 --- a/src/skills/discovery/chat-command-invocation.ts +++ b/src/skills/discovery/chat-command-invocation.ts @@ -5,6 +5,7 @@ import { import { getChatCommands } from "../../auto-reply/commands-registry.data.js"; import type { SkillCommandSpec } from "../types.js"; +/** Lists slash command names reserved by built-in chat commands and callers. */ export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set { const reserved = new Set(); for (const command of getChatCommands()) { @@ -28,6 +29,7 @@ export function listReservedChatSlashCommandNames(extraNames: string[] = []): Se return reserved; } +// Skill commands allow spaces/underscores in names but compare through dash-normalized lookup. function normalizeSkillCommandLookup(value: string): string { return (normalizeOptionalLowercaseString(value) ?? "").replace(/[\s_]+/g, "-"); } diff --git a/src/skills/discovery/chat-commands.runtime.ts b/src/skills/discovery/chat-commands.runtime.ts index 86176edd0953..d6f44a295d64 100644 --- a/src/skills/discovery/chat-commands.runtime.ts +++ b/src/skills/discovery/chat-commands.runtime.ts @@ -1 +1,2 @@ +// Runtime facade for chat command discovery without importing the full discovery module. export { listSkillCommandsForAgents, listSkillCommandsForWorkspace } from "./chat-commands.js"; diff --git a/src/skills/discovery/command-specs.ts b/src/skills/discovery/command-specs.ts index 85b764fe942a..2a9fd176aebd 100644 --- a/src/skills/discovery/command-specs.ts +++ b/src/skills/discovery/command-specs.ts @@ -20,6 +20,7 @@ const SKILL_COMMAND_MAX_LENGTH = 32; const SKILL_COMMAND_FALLBACK = "skill"; const SKILL_COMMAND_DESCRIPTION_MAX_LENGTH = 100; +// De-duplicate noisy skill command diagnostics across large workspace scans. function debugSkillCommandOnce( messageKey: string, message: string, @@ -71,6 +72,7 @@ function resolveUniqueSkillCommandName(base: string, used: Set): string return `${base.slice(0, Math.max(1, SKILL_COMMAND_MAX_LENGTH - 2))}_x`; } +/** Builds user-invocable slash command specs for visible workspace skills. */ export function buildWorkspaceSkillCommandSpecs( workspaceDir: string, opts?: { diff --git a/src/skills/discovery/filter.ts b/src/skills/discovery/filter.ts index 3c956e44d367..cdd170f809c7 100644 --- a/src/skills/discovery/filter.ts +++ b/src/skills/discovery/filter.ts @@ -3,6 +3,7 @@ import { sortUniqueStrings, } from "@openclaw/normalization-core/string-normalization"; +/** Normalizes an optional skill filter while preserving undefined as "not configured". */ export function normalizeSkillFilter(skillFilter?: ReadonlyArray): string[] | undefined { if (skillFilter === undefined) { return undefined; diff --git a/src/skills/discovery/skill-index.ts b/src/skills/discovery/skill-index.ts index 36f4ce8dafdc..edf03f5d063d 100644 --- a/src/skills/discovery/skill-index.ts +++ b/src/skills/discovery/skill-index.ts @@ -2,6 +2,7 @@ import { resolveSkillKey } from "../loading/frontmatter.js"; import { resolveSkillSource } from "../loading/source.js"; import type { SkillEntry } from "../types.js"; +/** Indexed skill metadata used for runtime visibility and command lookup. */ export type SkillIndexEntry = { entry: SkillEntry; name: string; @@ -30,6 +31,7 @@ export type BuildSkillIndexOptions = { agentSkillFilter?: readonly string[]; }; +/** Normalizes a skill name to the comparable key used by filters and commands. */ export function normalizeSkillIndexName(value: string): string { return value .trim() diff --git a/src/skills/lifecycle/archive-install.ts b/src/skills/lifecycle/archive-install.ts index 7103d59c01e7..35edaf4a07a0 100644 --- a/src/skills/lifecycle/archive-install.ts +++ b/src/skills/lifecycle/archive-install.ts @@ -14,6 +14,7 @@ import type { InstallPolicyOrigin, InstallPolicySource } from "../../security/in const VALID_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; const DEFAULT_SKILL_ARCHIVE_ROOT_MARKERS = ["SKILL.md"] as const; +/** Accepted root marker names for ClawHub skill archive uploads. */ export const CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS = [ "SKILL.md", "skill.md", @@ -38,12 +39,14 @@ type SkillArchiveInstallPolicy = { source?: InstallPolicySource; }; +/** Result shape for installing a skill archive into a workspace skills dir. */ export type SkillArchiveInstallResult = | { ok: true; targetDir: string } | { ok: false; error: string; failureKind: SkillArchiveInstallFailureKind }; export type SkillArchiveInstallFailureKind = "invalid-request" | "unavailable"; +/** Normalizes a tracked slug without accepting traversal or path separators. */ export function normalizeTrackedSkillSlug(raw: string): string { const slug = raw.trim(); if (!slug || slug.includes("/") || slug.includes("\\") || slug.includes("..")) { diff --git a/src/skills/lifecycle/install-output.ts b/src/skills/lifecycle/install-output.ts index e6435f9dd00c..554c9097de0f 100644 --- a/src/skills/lifecycle/install-output.ts +++ b/src/skills/lifecycle/install-output.ts @@ -6,6 +6,7 @@ type InstallCommandResult = { stderr: string; }; +// Prefer explicit error lines, then the last useful line, to keep CLI failures compact. function summarizeInstallOutput(text: string): string | undefined { const raw = text.trim(); if (!raw) { @@ -29,6 +30,7 @@ function summarizeInstallOutput(text: string): string | undefined { return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; } +/** Formats a bounded install failure message from command exit and output. */ export function formatInstallFailureMessage(result: InstallCommandResult): string { const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); diff --git a/src/skills/lifecycle/install-tar-verbose.ts b/src/skills/lifecycle/install-tar-verbose.ts index f7bfaeccd1a0..235f9caf528b 100644 --- a/src/skills/lifecycle/install-tar-verbose.ts +++ b/src/skills/lifecycle/install-tar-verbose.ts @@ -16,6 +16,7 @@ const TAR_VERBOSE_MONTHS = new Set([ ]); const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +// Tar verbose prefixes encode file type in the first mode character. function mapTarVerboseTypeChar(typeChar: string): string { switch (typeChar) { case "l": @@ -67,6 +68,7 @@ function parseTarSizeToken(raw: string, line: string): number { return size; } +/** Parses tar verbose metadata into type and byte size entries. */ export function parseTarVerboseMetadata(stdout: string): Array<{ type: string; size: number }> { const lines = normalizeStringEntries(stdout.split("\n")); return lines.map((line) => { diff --git a/src/skills/lifecycle/install-types.ts b/src/skills/lifecycle/install-types.ts index 40fc248af32d..d3aadc6ea668 100644 --- a/src/skills/lifecycle/install-types.ts +++ b/src/skills/lifecycle/install-types.ts @@ -1,3 +1,4 @@ +/** Normalized output returned by skill install flows and command wrappers. */ export type SkillInstallResult = { ok: boolean; message: string; diff --git a/src/skills/lifecycle/upload-install.ts b/src/skills/lifecycle/upload-install.ts index 4c6bf076ad5f..421b31064037 100644 --- a/src/skills/lifecycle/upload-install.ts +++ b/src/skills/lifecycle/upload-install.ts @@ -13,8 +13,10 @@ import { type SkillUploadStore, } from "./upload-store.js"; +/** Error classes exposed by uploaded skill archive install attempts. */ export type UploadedSkillInstallErrorKind = "invalid-request" | "unavailable"; +/** User-facing disabled message for archive upload installs. */ export const UPLOADED_SKILL_ARCHIVES_DISABLED_MESSAGE = "Uploaded skill archive installs are disabled by skills.install.allowUploadedArchives"; @@ -39,6 +41,7 @@ export type UploadedSkillInstallResult = errorKind: UploadedSkillInstallErrorKind; }; +// Preserve invalid-request failures for caller feedback; other install failures are unavailable. function uploadInstallFailureErrorKind( failureKind: SkillArchiveInstallFailureKind, ): UploadedSkillInstallErrorKind { diff --git a/src/skills/lifecycle/upload-store.ts b/src/skills/lifecycle/upload-store.ts index 11dc8721bf5f..0b9ab040c089 100644 --- a/src/skills/lifecycle/upload-store.ts +++ b/src/skills/lifecycle/upload-store.ts @@ -13,6 +13,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { createAsyncLock, readDurableJsonFile, writeJsonAtomic } from "../../infra/json-files.js"; import { validateRequestedSkillSlug } from "./archive-install.js"; +/** Time window in which uploaded skill archive chunks may be committed. */ export const SKILL_UPLOAD_TTL_MS = 60 * 60 * 1000; export const MAX_SKILL_UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024; export const MAX_SKILL_UPLOAD_BASE64_LENGTH = Math.ceil(MAX_SKILL_UPLOAD_CHUNK_BYTES / 3) * 4; diff --git a/src/skills/loading/bundled-context.ts b/src/skills/loading/bundled-context.ts index 027930fb4c36..7206541ab9e6 100644 --- a/src/skills/loading/bundled-context.ts +++ b/src/skills/loading/bundled-context.ts @@ -6,6 +6,7 @@ const skillsLogger = createSubsystemLogger("skills"); let hasWarnedMissingBundledDir = false; let cachedBundledContext: { dir: string; names: Set } | null = null; +/** Bundled skill path context resolved from runtime defaults. */ export type BundledSkillsContext = { dir?: string; names: Set; @@ -29,6 +30,7 @@ export function resolveBundledSkillsContext( if (cachedBundledContext?.dir === dir) { return { dir, names: new Set(cachedBundledContext.names) }; } + // Bundled skill metadata is process-stable; cache names until restart. const result = loadSkillsFromDirSafe({ dir, source: "openclaw-bundled" }); for (const skill of result.skills) { if (skill.name.trim()) { diff --git a/src/skills/loading/config.ts b/src/skills/loading/config.ts index 40b5e3f5d37a..82c82bbff4a3 100644 --- a/src/skills/loading/config.ts +++ b/src/skills/loading/config.ts @@ -21,6 +21,7 @@ const DEFAULT_CONFIG_VALUES: Record = { "browser.evaluateEnabled": true, }; +/** Platform helpers re-exported for skill loading callers and tests. */ export { hasBinary, resolveConfigPath, resolveRuntimePlatform }; export function resolveSkillsInstallPreferences(config?: OpenClawConfig): SkillsInstallPreferences { diff --git a/src/skills/loading/local-loader.ts b/src/skills/loading/local-loader.ts index 6b5630990247..0949359c6dec 100644 --- a/src/skills/loading/local-loader.ts +++ b/src/skills/loading/local-loader.ts @@ -10,6 +10,7 @@ type LoadedLocalSkill = { frontmatter: ParsedSkillFrontmatter; }; +// Read SKILL.md through the root boundary helper so symlinks cannot escape the skill root. function readSkillFileSync(params: { rootRealPath: string; filePath: string; @@ -99,6 +100,7 @@ function listCandidateSkillDirs(dir: string): string[] { } } +/** Loads skills from a local directory while turning read/parse failures into diagnostics. */ export function loadSkillsFromDirSafe(params: { dir: string; source: string; maxBytes?: number }): { skills: Skill[]; frontmatterByFilePath: ReadonlyMap; diff --git a/src/skills/loading/runtime-config.ts b/src/skills/loading/runtime-config.ts index ad839fa9418c..07388b839b28 100644 --- a/src/skills/loading/runtime-config.ts +++ b/src/skills/loading/runtime-config.ts @@ -2,6 +2,7 @@ import { getRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; +// Raw skill secret refs must not be replaced by redacted runtime snapshots. function hasConfiguredSkillApiKeyRef(config?: OpenClawConfig): boolean { const entries = config?.skills?.entries; if (!entries || typeof entries !== "object") { @@ -18,6 +19,7 @@ function hasConfiguredSkillApiKeyRef(config?: OpenClawConfig): boolean { return false; } +/** Chooses the runtime config snapshot unless it would hide skill secret refs. */ export function resolveSkillRuntimeConfig(config?: OpenClawConfig): OpenClawConfig | undefined { const runtimeConfig = getRuntimeConfigSnapshot(); if (!runtimeConfig) { diff --git a/src/skills/loading/serialize.ts b/src/skills/loading/serialize.ts index 89c6f8cbb082..a19797fe132a 100644 --- a/src/skills/loading/serialize.ts +++ b/src/skills/loading/serialize.ts @@ -1,5 +1,6 @@ const SKILLS_SYNC_QUEUE = new Map>(); +/** Serializes async work by key so repeated skill loads do not race on shared files. */ export async function serializeByKey(key: string, task: () => Promise) { const prev = SKILLS_SYNC_QUEUE.get(key) ?? Promise.resolve(); const next = prev.then(task, task); diff --git a/src/skills/loading/source.ts b/src/skills/loading/source.ts index d597610edf24..f51d6d81ac6d 100644 --- a/src/skills/loading/source.ts +++ b/src/skills/loading/source.ts @@ -8,6 +8,7 @@ type SkillSourceCompat = Skill & { }; }; +/** Returns the stable source label attached to a loaded skill. */ export function resolveSkillSource(skill: Skill): string { const compatSkill = skill as SkillSourceCompat; const canonical = normalizeOptionalString(compatSkill.source) ?? ""; diff --git a/src/skills/research/autocapture.ts b/src/skills/research/autocapture.ts index 49924aaa383f..c3b431e819bd 100644 --- a/src/skills/research/autocapture.ts +++ b/src/skills/research/autocapture.ts @@ -17,12 +17,14 @@ type SkillResearchAgentContext = { const log = createSubsystemLogger("skills/research"); +// Captured updates append below existing skill text so learned context stays auditable. function buildAutoCaptureUpdateContent(existingSkill: string, capturedContent: string): string { return [existingSkill.trimEnd(), "", "## Captured Update", "", capturedContent.trim(), ""].join( "\n", ); } +/** Captures durable skill research signals from a session transcript when enabled. */ export async function runSkillResearchAutoCapture(params: { event: SkillResearchAgentEndEvent; ctx: SkillResearchAgentContext; diff --git a/src/skills/research/signals.ts b/src/skills/research/signals.ts index 93d11412aa2f..f095c2c85e43 100644 --- a/src/skills/research/signals.ts +++ b/src/skills/research/signals.ts @@ -19,6 +19,7 @@ type DurableInstruction = { evidence: string; }; +// Topic inference stays conservative so autocapture proposes broad skills, not brittle names. function inferTopic(text: string): { skillName: string; title: string; label: string } { const lower = text.toLowerCase(); if (/\banimated\b|\bgifs?\b/.test(lower)) { @@ -59,6 +60,7 @@ function extractInstruction(text: string): string | undefined { return trimmed.replace(/^ok[,. ]+/i, ""); } +/** Extracts a candidate durable instruction from transcript text. */ export function extractDurableInstructionProposal(params: { messages: unknown[]; }): DurableInstruction | undefined { diff --git a/src/skills/research/text.ts b/src/skills/research/text.ts index e683ef1f4af5..0033b3f85dd8 100644 --- a/src/skills/research/text.ts +++ b/src/skills/research/text.ts @@ -1,5 +1,6 @@ const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]); +// Transcript content can be raw strings or Responses-style typed text blocks. function readTextValue(value: unknown): string { if (typeof value === "string") { return value; @@ -35,6 +36,7 @@ function extractMessageText(content: unknown): string { return extractTextBlock(content); } +/** Extracts role/text pairs from mixed transcript message shapes. */ export function extractTranscriptText(messages: unknown[]): Array<{ role: string; text: string }> { const result: Array<{ role: string; text: string }> = []; for (const message of messages) { diff --git a/src/skills/runtime/cron-snapshot.runtime.ts b/src/skills/runtime/cron-snapshot.runtime.ts index cfa58e34b568..860e86ea10be 100644 --- a/src/skills/runtime/cron-snapshot.runtime.ts +++ b/src/skills/runtime/cron-snapshot.runtime.ts @@ -1,3 +1,4 @@ +// Runtime-only facade used by cron snapshot code to avoid broader skill imports. export { canExecRequestNode } from "../../agents/exec-defaults.js"; export { resolveEffectiveAgentSkillFilter } from "../discovery/agent-filter.js"; export { getRemoteSkillEligibility } from "./remote.js"; diff --git a/src/skills/runtime/embedded-run-entries.ts b/src/skills/runtime/embedded-run-entries.ts index a50dad39b687..38c0a4fbeb5b 100644 --- a/src/skills/runtime/embedded-run-entries.ts +++ b/src/skills/runtime/embedded-run-entries.ts @@ -3,6 +3,7 @@ import { resolveSkillRuntimeConfig } from "../loading/runtime-config.js"; import { loadWorkspaceSkillEntries } from "../loading/workspace.js"; import type { SkillEntry, SkillSnapshot } from "../types.js"; +/** Resolves skill entries embedded into a run payload into runtime-visible entries. */ export function resolveEmbeddedRunSkillEntries(params: { workspaceDir: string; config?: OpenClawConfig; diff --git a/src/skills/runtime/env-overrides.runtime.ts b/src/skills/runtime/env-overrides.runtime.ts index 6f5ebf3947a8..fe53dfcbf722 100644 --- a/src/skills/runtime/env-overrides.runtime.ts +++ b/src/skills/runtime/env-overrides.runtime.ts @@ -2,6 +2,7 @@ import { getActiveSkillEnvKeys as getActiveSkillEnvKeysImpl } from "./env-overri type GetActiveSkillEnvKeys = typeof import("./env-overrides.js").getActiveSkillEnvKeys; +/** Runtime facade for active skill env override discovery. */ export function getActiveSkillEnvKeys( ...args: Parameters ): ReturnType { diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index e06a153a0923..494a10e52cb8 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -12,6 +12,7 @@ import { hydrateResolvedSkills } from "./snapshot-hydration.js"; const resolvedSkillsCache = new Map(); const RESOLVED_SKILLS_CACHE_MAX = 10; +/** Inputs that make a resolved skill snapshot reusable within a process. */ export type ReusableSkillSnapshotParams = { workspaceDir: string; config: OpenClawConfig; diff --git a/src/skills/runtime/tools-dir.ts b/src/skills/runtime/tools-dir.ts index 66bda382c194..407abc0f6c97 100644 --- a/src/skills/runtime/tools-dir.ts +++ b/src/skills/runtime/tools-dir.ts @@ -4,6 +4,7 @@ import { resolveConfigDir } from "../../utils.js"; import { resolveSkillKey } from "../loading/frontmatter.js"; import type { SkillEntry } from "../types.js"; +/** Resolves a skill's tools directory relative to the OpenClaw config dir. */ export function resolveSkillToolsRootDir(entry: SkillEntry): string { const key = resolveSkillKey(entry.skill, entry); const safeKey = safePathSegmentHashed(key); diff --git a/src/skills/security/clawhub-verdicts.ts b/src/skills/security/clawhub-verdicts.ts index 520c5fa3b75b..fa71489d3991 100644 --- a/src/skills/security/clawhub-verdicts.ts +++ b/src/skills/security/clawhub-verdicts.ts @@ -5,6 +5,7 @@ import { } from "../../infra/clawhub.js"; import type { buildWorkspaceSkillStatus } from "../discovery/status.js"; +/** Public ClawHub verdict item shape projected into local security scan verdicts. */ export type OpenClawSkillSecurityVerdictItem = Omit< ClawHubSkillSecurityVerdictItem, "decision" | "error" | "security" diff --git a/src/skills/test-support/e2e-test-helpers.ts b/src/skills/test-support/e2e-test-helpers.ts index 13748c2891a9..9a25803a5f88 100644 --- a/src/skills/test-support/e2e-test-helpers.ts +++ b/src/skills/test-support/e2e-test-helpers.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +/** Writes a SKILL.md fixture for skills E2E tests. */ export async function writeSkill(params: { dir: string; name: string; diff --git a/src/skills/test-support/home-env.test-support.ts b/src/skills/test-support/home-env.test-support.ts index 089fdbcda1f3..5c59f71a3dd6 100644 --- a/src/skills/test-support/home-env.test-support.ts +++ b/src/skills/test-support/home-env.test-support.ts @@ -1,6 +1,7 @@ import os from "node:os"; import { vi } from "vitest"; +/** Process home env snapshot used by skill loader tests. */ export type SkillsHomeEnvSnapshot = { previousHome: string | undefined; previousOpenClawHome: string | undefined; diff --git a/src/skills/test-support/install-download-test-utils.ts b/src/skills/test-support/install-download-test-utils.ts index 98457b6e52b0..09d1fe71960f 100644 --- a/src/skills/test-support/install-download-test-utils.ts +++ b/src/skills/test-support/install-download-test-utils.ts @@ -1,5 +1,6 @@ import path from "node:path"; +/** Points OpenClaw state at a workspace-local temp dir for install tests. */ export function setTempStateDir(workspaceDir: string): string { const stateDir = path.join(workspaceDir, "state"); process.env.OPENCLAW_STATE_DIR = stateDir; diff --git a/src/skills/test-support/install-test-mocks.ts b/src/skills/test-support/install-test-mocks.ts index 88458c31e3e9..3598ecdf0a35 100644 --- a/src/skills/test-support/install-test-mocks.ts +++ b/src/skills/test-support/install-test-mocks.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import type { Mock } from "vitest"; +/** Shared Vitest mocks for skill install tests that mock heavy dependencies. */ export const runCommandWithTimeoutMock: Mock<(...args: unknown[]) => unknown> = vi.fn(); export const scanDirectoryWithSummaryMock: Mock<(...args: unknown[]) => unknown> = vi.fn(); export const fetchWithSsrFGuardMock: Mock<(...args: unknown[]) => unknown> = vi.fn(); diff --git a/src/skills/test-support/skill-plugin-fixtures.test-support.ts b/src/skills/test-support/skill-plugin-fixtures.test-support.ts index 614da4d75e6d..1ea66b7b8868 100644 --- a/src/skills/test-support/skill-plugin-fixtures.test-support.ts +++ b/src/skills/test-support/skill-plugin-fixtures.test-support.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +/** Writes a minimal plugin fixture that exposes one bundled skill. */ export async function writePluginWithSkill(params: { pluginRoot: string; pluginId: string; diff --git a/src/skills/test-support/test-helpers.ts b/src/skills/test-support/test-helpers.ts index 7b68fab4ad3a..e19b3f7ec79e 100644 --- a/src/skills/test-support/test-helpers.ts +++ b/src/skills/test-support/test-helpers.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { createSyntheticSourceInfo, type Skill } from "../loading/skill-contract.js"; import type { SkillEntry } from "../types.js"; +/** Writes a SKILL.md fixture with frontmatter and optional body. */ export async function writeSkill(params: { dir: string; name: string; diff --git a/src/skills/workshop/config.ts b/src/skills/workshop/config.ts index 495172c76835..8cbce3ff8e75 100644 --- a/src/skills/workshop/config.ts +++ b/src/skills/workshop/config.ts @@ -1,6 +1,7 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +/** Runtime configuration for the skill workshop proposal flow. */ export type SkillWorkshopConfig = { autonomous: { enabled: boolean; diff --git a/src/skills/workshop/frontmatter.ts b/src/skills/workshop/frontmatter.ts index 8d845a1fe792..d5d4b6fa4658 100644 --- a/src/skills/workshop/frontmatter.ts +++ b/src/skills/workshop/frontmatter.ts @@ -5,10 +5,12 @@ type ProposalFrontmatter = { description: string; }; +// JSON strings are valid YAML scalars and avoid ad hoc escaping. function yamlScalar(value: string): string { return JSON.stringify(value); } +/** Renders proposal markdown while preserving allowed original frontmatter fields. */ export function renderProposalMarkdown(params: { name: string; description: string; diff --git a/src/skills/workshop/policy.ts b/src/skills/workshop/policy.ts index 83be83c6a144..376a8743ad80 100644 --- a/src/skills/workshop/policy.ts +++ b/src/skills/workshop/policy.ts @@ -7,6 +7,7 @@ const SKILL_WORKSHOP_LIFECYCLE_ACTIONS = new Set(["apply", "reject", "quarantine type SkillWorkshopLifecycleAction = "apply" | "reject" | "quarantine"; +// Only lifecycle actions mutate proposals and therefore require approval checks. function readLifecycleAction(params: unknown): SkillWorkshopLifecycleAction | undefined { const action = asNullableRecord(params)?.action; if (typeof action !== "string" || !SKILL_WORKSHOP_LIFECYCLE_ACTIONS.has(action)) { @@ -41,6 +42,7 @@ function lifecycleApprovalText(action: SkillWorkshopLifecycleAction): { }; } +/** Returns approval policy for skill workshop lifecycle tool calls. */ export function resolveSkillWorkshopToolApproval(params: { toolName: string; toolParams: unknown; diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index 20fdd55d53aa..0d1e1ba56166 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -75,6 +75,7 @@ const MAX_PROPOSAL_DRAFT_BYTES = 1024 * 1024; const MAX_PROPOSAL_DIRECTORY_ENTRIES = MAX_PROPOSAL_SUPPORT_FILES * 4; const MAX_SKILL_PROPOSAL_DESCRIPTION_BYTES = 160; +/** Lists skill workshop proposals, optionally scoped to a workspace. */ export async function listSkillProposals( options: SkillProposalScopeOptions = {}, ): Promise { diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index 22afb66c3840..457383b82f3e 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -29,6 +29,7 @@ const MANIFEST_LOCK_REL_PATH = path.join(TARGET_LOCKS_REL_DIR, "proposals-manife const PROPOSAL_RECORD_FILE = "proposal.json"; const PROPOSAL_DRAFT_FILE = "PROPOSAL.md"; const PROPOSAL_ROLLBACK_FILE = "rollback.json"; +/** Maximum bytes accepted for a proposal draft. */ export const MAX_PROPOSAL_BYTES = 1024 * 1024; export const MAX_PROPOSAL_SUPPORT_FILE_BYTES = 256 * 1024; export const MAX_PROPOSAL_SUPPORT_FILES = 64; @@ -63,6 +64,7 @@ export type PreparedSkillProposalSupportFile = SkillProposalSupportFile & { }; type SkillProposalWriteGuard = (manifest: SkillProposalManifest) => Promise | void; +/** Creates a stable proposal id from skill name, date, and random suffix. */ export function createSkillProposalId(name: string, now = new Date()): string { const normalized = normalizeSkillIndexName(name) || "skill"; const date = now.toISOString().slice(0, 10).replaceAll("-", ""); diff --git a/src/skills/workshop/types.ts b/src/skills/workshop/types.ts index c0f18eb6d3cc..2f9990541106 100644 --- a/src/skills/workshop/types.ts +++ b/src/skills/workshop/types.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SkillScanFinding } from "../security/scanner.js"; +/** Schema id for persisted skill workshop proposal records. */ export const SKILL_WORKSHOP_SCHEMA = "openclaw.skill-workshop.proposal.v1" as const; export const SKILL_WORKSHOP_MANIFEST_SCHEMA = "openclaw.skill-workshop.proposals-manifest.v1" as const; diff --git a/src/state/openclaw-agent-db.paths.ts b/src/state/openclaw-agent-db.paths.ts index fe7bdd079e3c..fbeedd723a27 100644 --- a/src/state/openclaw-agent-db.paths.ts +++ b/src/state/openclaw-agent-db.paths.ts @@ -2,12 +2,20 @@ import path from "node:path"; import { normalizeAgentId } from "../routing/session-key.js"; import { resolveOpenClawStateSqliteDir } from "./openclaw-state-db.paths.js"; +/** + * Path helpers for per-agent SQLite state. + * + * Agent databases live beside the shared state database root so each agent can + * own private runtime tables while the shared registry can still discover them. + */ +/** Inputs for resolving one agent SQLite path or directory. */ export type OpenClawAgentSqlitePathOptions = { agentId: string; env?: NodeJS.ProcessEnv; path?: string; }; +/** Resolve the SQLite file for one normalized agent id. */ export function resolveOpenClawAgentSqlitePath(options: OpenClawAgentSqlitePathOptions): string { const agentId = normalizeAgentId(options.agentId); return ( @@ -22,6 +30,7 @@ export function resolveOpenClawAgentSqlitePath(options: OpenClawAgentSqlitePathO ); } +/** Resolve the containing directory for one agent's SQLite database. */ export function resolveOpenClawAgentSqliteDir(options: OpenClawAgentSqlitePathOptions): string { return path.dirname(resolveOpenClawAgentSqlitePath(options)); } diff --git a/src/state/openclaw-agent-db.ts b/src/state/openclaw-agent-db.ts index 3fb33cfafa6b..d944e53556ea 100644 --- a/src/state/openclaw-agent-db.ts +++ b/src/state/openclaw-agent-db.ts @@ -22,11 +22,19 @@ import { } from "./openclaw-state-db.js"; export { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js"; +/** + * Per-agent SQLite database lifecycle and shared-state registration. + * + * Each opened agent database is schema-owned by one normalized agent id, cached + * per pathname, protected with private file modes, and registered in the shared + * OpenClaw state database for discovery and maintenance. + */ const OPENCLAW_AGENT_SCHEMA_VERSION = 1; const OPENCLAW_AGENT_DB_DIR_MODE = 0o700; const OPENCLAW_AGENT_DB_FILE_MODE = 0o600; const OPENCLAW_AGENT_DB_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const; +/** Open per-agent SQLite database handle plus lifecycle maintenance. */ export type OpenClawAgentDatabase = { agentId: string; db: DatabaseSync; @@ -34,10 +42,12 @@ export type OpenClawAgentDatabase = { walMaintenance: SqliteWalMaintenance; }; +/** Options for resolving and opening one agent database. */ export type OpenClawAgentDatabaseOptions = OpenClawStateDatabaseOptions & { agentId: string; }; +/** Shared-state registry row describing an agent database seen by this process. */ export type OpenClawRegisteredAgentDatabase = { agentId: string; path: string; @@ -82,6 +92,7 @@ function ensureOpenClawAgentDatabasePermissions( const isDefaultAgentDatabase = path.resolve(pathname) === path.resolve(defaultPath); const dirExisted = existsSync(dir); mkdirSync(dir, { recursive: true, mode: OPENCLAW_AGENT_DB_DIR_MODE }); + // Default agent state is private by contract; custom pre-existing dirs keep caller ownership. if (isDefaultAgentDatabase || !dirExisted) { chmodSync(dir, OPENCLAW_AGENT_DB_DIR_MODE); } @@ -120,6 +131,7 @@ function assertExistingSchemaOwner( if (!existing) { return; } + // Agent DB files are not interchangeable; opening another role/id would corrupt ownership. if (existing.role !== "agent") { throw new Error( `OpenClaw agent database ${pathname} has schema role ${existing.role ?? "unknown"}; expected agent.`, @@ -206,6 +218,7 @@ function registerAgentDatabase(params: { ); } +/** List agent databases recorded in the shared OpenClaw state registry. */ export function listOpenClawRegisteredAgentDatabases( options: OpenClawStateDatabaseOptions = {}, ): OpenClawRegisteredAgentDatabase[] { @@ -224,6 +237,7 @@ export function listOpenClawRegisteredAgentDatabases( })); } +/** Open or return a cached per-agent database after schema and owner validation. */ export function openOpenClawAgentDatabase( options: OpenClawAgentDatabaseOptions, ): OpenClawAgentDatabase { @@ -241,6 +255,7 @@ export function openOpenClawAgentDatabase( return cached; } if (cached) { + // A closed handle can leave Kysely and WAL helpers cached; clear both before reopening. cached.walMaintenance.close(); clearNodeSqliteKyselyCacheForDatabase(cached.db); cachedDatabases.delete(pathname); @@ -270,6 +285,7 @@ export function openOpenClawAgentDatabase( return database; } +/** Run a synchronous immediate transaction against an agent database. */ export function runOpenClawAgentWriteTransaction( operation: (database: OpenClawAgentDatabase) => T, options: OpenClawAgentDatabaseOptions, @@ -280,6 +296,7 @@ export function runOpenClawAgentWriteTransaction( return result; } +/** Close cached agent databases so tests can remove temp dirs and reopen cleanly. */ export function closeOpenClawAgentDatabasesForTest(): void { for (const database of cachedDatabases.values()) { database.walMaintenance.close(); diff --git a/src/state/openclaw-state-db.paths.ts b/src/state/openclaw-state-db.paths.ts index b0aa0a60c9fb..06ad1bdacb35 100644 --- a/src/state/openclaw-state-db.paths.ts +++ b/src/state/openclaw-state-db.paths.ts @@ -4,6 +4,12 @@ import { isMainThread, threadId } from "node:worker_threads"; import { resolveStateDir } from "../config/paths.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +/** + * Path helpers for the shared OpenClaw SQLite state database. + * + * Tests get worker-scoped temp state roots unless they explicitly provide + * `OPENCLAW_STATE_DIR`, which prevents parallel Vitest workers from sharing WAL files. + */ function resolveOpenClawStateRootDir(env: NodeJS.ProcessEnv): string { if (env.OPENCLAW_STATE_DIR?.trim()) { return resolveStateDir(env); @@ -23,10 +29,12 @@ function resolveOpenClawStateRootDir(env: NodeJS.ProcessEnv): string { return resolveStateDir(env); } +/** Resolve the directory that contains the shared state SQLite file. */ export function resolveOpenClawStateSqliteDir(env: NodeJS.ProcessEnv = process.env): string { return path.join(resolveOpenClawStateRootDir(env), "state"); } +/** Resolve the shared state SQLite file path. */ export function resolveOpenClawStateSqlitePath(env: NodeJS.ProcessEnv = process.env): string { return path.join(resolveOpenClawStateSqliteDir(env), "openclaw.sqlite"); } diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 36983a8b2c90..b7aed8566169 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -17,26 +17,39 @@ import { } from "./openclaw-state-db.paths.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.generated.js"; +/** + * Shared OpenClaw SQLite state database lifecycle and metadata writers. + * + * This module owns schema creation, additive migrations for released state + * tables, private file permissions, cached handles, and audit rows for + * migrations/backups that operate on local state. + */ const OPENCLAW_STATE_SCHEMA_VERSION = 1; +/** Shared timeout used by state and agent SQLite handles before surfacing busy errors. */ export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 30_000; const OPENCLAW_STATE_DIR_MODE = 0o700; const OPENCLAW_STATE_FILE_MODE = 0o600; const OPENCLAW_STATE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const; +/** Open shared SQLite database handle plus WAL maintenance lifecycle. */ export type OpenClawStateDatabase = { db: DatabaseSync; path: string; walMaintenance: SqliteWalMaintenance; }; +/** Options for resolving or overriding the shared state database path. */ export type OpenClawStateDatabaseOptions = { env?: NodeJS.ProcessEnv; path?: string; }; +/** Status stored for a state migration run. */ export type OpenClawMigrationRunStatus = "completed" | "warning" | "failed"; +/** Status stored for a state backup run. */ export type OpenClawBackupRunStatus = "completed" | "failed"; +/** Input for recording one state migration run summary. */ export type RecordOpenClawStateMigrationRunOptions = OpenClawStateDatabaseOptions & { id?: string; startedAt: number; @@ -45,6 +58,7 @@ export type RecordOpenClawStateMigrationRunOptions = OpenClawStateDatabaseOption report: Record; }; +/** Input for recording one migrated source file/table pair. */ export type RecordOpenClawStateMigrationSourceOptions = OpenClawStateDatabaseOptions & { runId: string; migrationKind: string; @@ -60,6 +74,7 @@ export type RecordOpenClawStateMigrationSourceOptions = OpenClawStateDatabaseOpt report: Record; }; +/** Input for recording one state backup archive. */ export type RecordOpenClawStateBackupRunOptions = OpenClawStateDatabaseOptions & { id?: string; createdAt: number; @@ -99,6 +114,7 @@ function ensureOpenClawStatePermissions(pathname: string, env: NodeJS.ProcessEnv } const dirExisted = existsSync(dir); mkdirSync(dir, { recursive: true, mode: OPENCLAW_STATE_DIR_MODE }); + // Default state contains credentials-adjacent metadata; custom existing dirs keep caller modes. if (isDefaultStateDatabase || !dirExisted) { chmodSync(dir, OPENCLAW_STATE_DIR_MODE); } @@ -127,6 +143,7 @@ function ensureColumn(db: DatabaseSync, tableName: string, columnSql: string): v if (!columnName || !tableExists(db, tableName) || tableHasColumn(db, tableName, columnName)) { return; } + // State migrations are additive here; destructive or shape-changing repairs belong in doctor. db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnSql};`); } @@ -312,6 +329,7 @@ function backfillCronJobsFromJobJson(db: DatabaseSync): void { if (!job) { continue; } + // Legacy cron rows kept the contract in job_json; columns are a queryable projection of it. const schedule = recordField(job, "schedule"); const payload = recordField(job, "payload"); const scheduleKind = textField(schedule ?? {}, "kind"); @@ -456,6 +474,7 @@ function backfillDeliveryQueueEntriesFromEntryJson(db: DatabaseSync): void { if (!entry) { continue; } + // Queue metadata is denormalized for recovery queries but entry_json remains source of truth. const session = recordField(entry, "session"); const route = recordField(entry, "route"); const deliveryContext = recordField(entry, "deliveryContext"); @@ -671,6 +690,7 @@ function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string return options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env); } +/** Open or return a cached shared state database after schema and migration checks. */ export function openOpenClawStateDatabase( options: OpenClawStateDatabaseOptions = {}, ): OpenClawStateDatabase { @@ -681,6 +701,7 @@ export function openOpenClawStateDatabase( return cached; } if (cached) { + // A closed handle can leave Kysely and WAL helpers cached; clear both before reopening. cached.walMaintenance.close(); clearNodeSqliteKyselyCacheForDatabase(cached.db); cachedDatabases.delete(pathname); @@ -709,6 +730,7 @@ export function openOpenClawStateDatabase( return database; } +/** Run a synchronous immediate transaction against the shared state database. */ export function runOpenClawStateWriteTransaction( operation: (database: OpenClawStateDatabase) => T, options: OpenClawStateDatabaseOptions = {}, @@ -724,6 +746,7 @@ export function runOpenClawStateWriteTransaction( return result; } +/** Record a state migration run and return its stable run id. */ export function recordOpenClawStateMigrationRun( options: RecordOpenClawStateMigrationRunOptions, ): string { @@ -744,6 +767,7 @@ export function recordOpenClawStateMigrationRun( return id; } +/** Upsert the per-source audit row for a state migration. */ export function recordOpenClawStateMigrationSource( options: RecordOpenClawStateMigrationSourceOptions, ): void { @@ -786,6 +810,7 @@ export function recordOpenClawStateMigrationSource( }, options); } +/** Record a state backup archive and return its stable backup id. */ export function recordOpenClawStateBackupRun(options: RecordOpenClawStateBackupRunOptions): string { const id = options.id ?? randomUUID(); runOpenClawStateWriteTransaction((database) => { @@ -804,6 +829,7 @@ export function recordOpenClawStateBackupRun(options: RecordOpenClawStateBackupR return id; } +/** Close all cached shared state database handles. */ export function closeOpenClawStateDatabase(): void { for (const database of cachedDatabases.values()) { database.walMaintenance.close(); @@ -815,8 +841,10 @@ export function closeOpenClawStateDatabase(): void { cachedDatabases.clear(); } +/** Test whether any cached shared state database handle is still open. */ export function isOpenClawStateDatabaseOpen(): boolean { return Array.from(cachedDatabases.values()).some((database) => database.db.isOpen); } +/** Test alias for closing shared state handles from teardown code. */ export const closeOpenClawStateDatabaseForTest = closeOpenClawStateDatabase; diff --git a/src/state/sqlite-schema-shape.test-support.ts b/src/state/sqlite-schema-shape.test-support.ts index d3fa6c29a8b0..db076737303c 100644 --- a/src/state/sqlite-schema-shape.test-support.ts +++ b/src/state/sqlite-schema-shape.test-support.ts @@ -1,6 +1,12 @@ import { readFileSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; +/** + * Test helpers for comparing SQLite schema shape. + * + * The collected shape intentionally ignores SQLite autoindex suffixes so + * generated schema tests assert contract-relevant table, column, and index data. + */ type ColumnShape = { name: string; type: string; @@ -16,6 +22,7 @@ type IndexShape = { partial: number; }; +/** Comparable SQLite schema summary used by generated-schema tests. */ export type SqliteSchemaShape = Record< string, { @@ -36,6 +43,7 @@ type SqliteMasterRow = { name: string; }; +/** Execute schema SQL in memory and return its comparable shape. */ export function createSqliteSchemaShapeFromSql(schemaUrl: URL): SqliteSchemaShape { const db = new DatabaseSync(":memory:"); try { @@ -46,6 +54,7 @@ export function createSqliteSchemaShapeFromSql(schemaUrl: URL): SqliteSchemaShap } } +/** Collect table columns and indexes from an open SQLite database. */ export function collectSqliteSchemaShape(db: DatabaseSync): SqliteSchemaShape { const tableRows = db .prepare( @@ -98,6 +107,7 @@ function collectIndexes(db: DatabaseSync, tableName: string): IndexShape[] { } function normalizeAutoIndexName(name: string): string { + // SQLite autoindex names include table-specific suffixes that do not affect schema behavior. return name.startsWith("sqlite_autoindex_") ? "sqlite_autoindex" : name; } diff --git a/src/status/agent-runtime-label.ts b/src/status/agent-runtime-label.ts index 6bff729aa787..75f074c29cd7 100644 --- a/src/status/agent-runtime-label.ts +++ b/src/status/agent-runtime-label.ts @@ -7,6 +7,8 @@ import { isCliProvider } from "../agents/model-selection.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Status runtime labels turn harness/provider/session state into a short +// operator-facing name, sanitizing any persisted ACP/backend text. const AGENT_RUNTIME_LABELS: Readonly> = { openclaw: "OpenClaw Default", codex: "OpenAI Codex", @@ -26,6 +28,8 @@ export function resolveAgentRuntimeLabel(args: { }): string { const acpAgentRaw = normalizeOptionalString(args.sessionEntry?.acp?.agent); const acpAgent = acpAgentRaw ? sanitizeTerminalText(acpAgentRaw) : undefined; + // ACP sessions own their displayed runtime because the backend can differ + // from the normal model/provider selection path. if (acpAgent) { const backendRaw = normalizeOptionalString(args.sessionEntry?.acp?.backend); const backend = backendRaw ? sanitizeTerminalText(backendRaw) : undefined; diff --git a/src/status/fallback-notice-state.ts b/src/status/fallback-notice-state.ts index 439c8c04e98c..3135e9b5c156 100644 --- a/src/status/fallback-notice-state.ts +++ b/src/status/fallback-notice-state.ts @@ -3,6 +3,8 @@ import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.j import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Persisted fallback notice state is active only when the current selected and +// active runtime refs still match the recorded fallback transition. export type FallbackNoticeState = Pick< SessionEntry, "fallbackNoticeSelectedModel" | "fallbackNoticeActiveModel" | "fallbackNoticeReason" diff --git a/src/status/status-labels.ts b/src/status/status-labels.ts index a096b284e5e1..be38434fbfea 100644 --- a/src/status/status-labels.ts +++ b/src/status/status-labels.ts @@ -1 +1,2 @@ +// Small shared labels used by status message tests and compact command output. export const formatFastModeLabel = (enabled: boolean): string => `Fast: ${enabled ? "on" : "off"}`; diff --git a/src/status/status-message.runtime.ts b/src/status/status-message.runtime.ts index c2e751eed732..e673f67cb53f 100644 --- a/src/status/status-message.runtime.ts +++ b/src/status/status-message.runtime.ts @@ -1,3 +1,5 @@ +// Lazy boundary for the heavier status message formatter. Status text imports +// this wrapper so command startup does not eagerly load the full formatter graph. export async function loadStatusMessageRuntimeModule() { return await import("../auto-reply/status.runtime.js"); } diff --git a/src/status/status-queue.runtime.ts b/src/status/status-queue.runtime.ts index 49feeceb8b24..410a7376d646 100644 --- a/src/status/status-queue.runtime.ts +++ b/src/status/status-queue.runtime.ts @@ -1 +1,2 @@ +// Lazy queue-status facade used by status text to avoid eager reply queue setup. export { getFollowupQueueDepth, resolveQueueSettings } from "../auto-reply/reply/queue.js"; diff --git a/src/status/status-subagents.runtime.ts b/src/status/status-subagents.runtime.ts index ad5ea03e0fb6..372ad8530b86 100644 --- a/src/status/status-subagents.runtime.ts +++ b/src/status/status-subagents.runtime.ts @@ -1,3 +1,5 @@ +// Lazy subagent-status facade. Keeps subagent registries out of the base status +// import path until the command actually needs to render descendant runs. export { listControlledSubagentRuns } from "../agents/subagent-control.js"; export { countPendingDescendantRuns } from "../agents/subagent-registry.js"; export { buildSubagentsStatusLine } from "../auto-reply/reply/commands-status-subagents.js"; diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 96c2c5bc4fce..14a2817d73c8 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -45,6 +45,8 @@ import { import type { BuildStatusTextParams } from "./status-text.types.js"; export type { BuildStatusTextParams } from "./status-text.types.js"; +// Status text assembly gathers runtime/model/session/task facts, then delegates +// final formatting to status-message.runtime through lazy imports. const USAGE_OAUTH_ONLY_PROVIDERS = new Set([ "anthropic", "github-copilot", @@ -88,6 +90,8 @@ function loadStatusQueueRuntime(): Promise { const { cfg, @@ -307,6 +317,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise= 1 && wordCount <= maxWords; } +/** Normalize and reject unsupported activation names in one reusable step. */ export function normalizeSupportedRealtimeVoiceActivationName( value: string | undefined, maxWords = REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS, @@ -73,10 +88,12 @@ export function normalizeSupportedRealtimeVoiceActivationName( : undefined; } +/** Prefer longer names first so nested names match the most specific option. */ export function sortRealtimeVoiceActivationNames(names: string[]): string[] { return names.toSorted((left, right) => right.length - left.length || left.localeCompare(right)); } +/** Match and strip a configured activation name from either transcript edge. */ export function matchRealtimeVoiceActivationName( text: string, activationNames: string[], @@ -145,6 +162,8 @@ function leadingActivationNameCandidates( text: string, maxWords: number, ): EdgeActivationNameCandidate[] { + // Consider both the full opener and the text after "hey/ok" so "hey Molty" + // records a useful heardName without letting the opener become required. const opener = /^\s*(?:(?:hey|ok|okay)(?:\s*[-,:;]+\s*|\s+))?/i.exec(text); const nameStart = opener?.[0].length ?? 0; const candidates: EdgeActivationNameCandidate[] = []; @@ -209,6 +228,8 @@ function trailingActivationNameCandidates( if (!/(^|[\s,.:;!?-])$/.test(text.slice(0, startIndex))) { break; } + // Trailing fuzzy matches are only trusted when the speaker clearly used + // direct address as a question, e.g. "what changed, Molty?". const directAddressBoundary = /(^|[,.:;!?-]\s*)$/.test(text.slice(0, startIndex)); const trailingQuestion = /\?\s*$/.test(text); if (wordCount > 1) { @@ -249,6 +270,7 @@ function levenshteinDistance(left: string, right: string): number { let previous = new Uint32Array(right.length + 1); let current = new Uint32Array(right.length + 1); + // Keep only two rows so fuzzy matching stays allocation-light per transcript. for (let index = 0; index <= right.length; index += 1) { previous[index] = index; } @@ -302,6 +324,8 @@ function commonPrefixLength(left: string, right: string): number { return limit; } +// Fuzzy matching requires a strong punctuation/edge boundary and same first +// letter, then applies stricter trailing-name rules to avoid dictation matches. function isFuzzyActivationNameMatch( candidate: EdgeActivationNameCandidate, heardCompact: string, diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index 83d2d810cdb5..89f50a598c1b 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -21,9 +21,21 @@ import { type RealtimeVoiceAgentConsultTranscriptEntry, } from "./agent-consult-tool.js"; +/** + * Agent runtime surface used by realtime voice consults. + */ export type RealtimeVoiceAgentConsultRuntime = PluginRuntimeCore["agent"]; + +/** + * Speakable text returned to the realtime voice bridge after an agent consult. + */ export type RealtimeVoiceAgentConsultResult = { text: string }; + +/** + * Controls whether voice consults run in a fresh session or fork context from the requester. + */ export type RealtimeVoiceAgentConsultContextMode = "isolated" | "fork"; + export { resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, @@ -43,6 +55,9 @@ const defaultRealtimeVoiceAgentConsultDeps: RealtimeVoiceAgentConsultDeps = { let realtimeVoiceAgentConsultDeps = defaultRealtimeVoiceAgentConsultDeps; +/** + * Overrides consult runtime dependencies for deterministic tests. + */ export function setRealtimeVoiceAgentConsultDepsForTest( deps: Partial | null, ): void { @@ -52,6 +67,8 @@ export function setRealtimeVoiceAgentConsultDepsForTest( } function resolveRealtimeVoiceAgentSandboxSessionKey(agentId: string, sessionKey: string): string { + // Embedded agent runs expect agent-scoped sandbox keys; keep already-scoped keys intact so + // callers can deliberately share a sandbox with an existing agent session. const trimmed = sessionKey.trim(); if (trimmed.toLowerCase().startsWith("agent:")) { return trimmed; @@ -87,6 +104,8 @@ function resolveRealtimeVoiceAgentDeliveryContext(params: { }): DeliveryContext | undefined { const requesterSessionKey = params.spawnedBy?.trim(); try { + // Prefer the live requester session, then its base thread, then the voice consult session. + // This preserves channel/account/thread routing when a voice bridge delegates back to agent. const candidates: string[] = []; if (requesterSessionKey) { const { baseSessionKey } = parseSessionThreadInfoFast(requesterSessionKey); @@ -142,6 +161,8 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { if (entry.sessionId?.trim()) { return { ...deliveryFields, updatedAt: now }; } + // Fork only from same-agent requester sessions. Cross-agent parent sessions may carry + // incompatible provider state, so they get a fresh consult session with spawnedBy linkage. if (shouldFork) { const parentEntry = params.agentRuntime.session.getSessionEntry({ storePath: params.storePath, @@ -190,6 +211,9 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { throw new Error("realtime voice agent consult session could not be initialized"); } +/** + * Runs an embedded agent consult and returns concise speakable text for realtime voice playback. + */ export async function consultRealtimeVoiceAgent(params: { cfg: OpenClawConfig; agentRuntime: RealtimeVoiceAgentConsultRuntime; @@ -221,6 +245,8 @@ export async function consultRealtimeVoiceAgent(params: { const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.cfg, agentId); await params.agentRuntime.ensureAgentWorkspace({ dir: workspaceDir }); + // The consult session stores normal session metadata so subsequent voice turns can keep + // routing and, in fork mode, recover useful conversation context from the requester. const storePath = params.agentRuntime.session.resolveStorePath(params.cfg.session?.store, { agentId, }); @@ -247,6 +273,8 @@ export async function consultRealtimeVoiceAgent(params: { const sessionFile = params.agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { agentId, }); + // Voice consults suppress verbose/reasoning output because the bridge needs a short, + // speakable answer, not agent-run diagnostics or hidden reasoning artifacts. const result = await params.agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: params.sessionKey, diff --git a/src/talk/agent-consult-tool.ts b/src/talk/agent-consult-tool.ts index 558948d305f7..17aa57080d08 100644 --- a/src/talk/agent-consult-tool.ts +++ b/src/talk/agent-consult-tool.ts @@ -1,27 +1,39 @@ +/** + * Realtime voice tool definition and helpers for delegating work to OpenClaw. + * + * Voice providers call this function tool when a spoken request needs normal + * agent tools, memory, workspace context, or current information before reply. + */ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import type { RealtimeVoiceTool } from "./provider-types.js"; +/** Stable provider-facing tool name for realtime voice agent delegation. */ export const REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME = "openclaw_agent_consult"; +/** Closed policy set controlling whether the consult tool is exposed. */ export const REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES = [ "safe-read-only", "owner", "none", ] as const; +/** Tool exposure policy for the shared realtime voice consult tool. */ export type RealtimeVoiceAgentConsultToolPolicy = (typeof REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES)[number]; +/** Normalized tool-call arguments accepted from realtime providers. */ export type RealtimeVoiceAgentConsultArgs = { question: string; context?: string; responseStyle?: string; }; +/** Compact transcript entry included in delegated agent prompts. */ export type RealtimeVoiceAgentConsultTranscriptEntry = { role: "user" | "assistant"; text: string; }; +/** Shared realtime voice function-tool descriptor projected to providers. */ export const REALTIME_VOICE_AGENT_CONSULT_TOOL: RealtimeVoiceTool = { type: "function", name: REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, @@ -47,6 +59,7 @@ export const REALTIME_VOICE_AGENT_CONSULT_TOOL: RealtimeVoiceTool = { }, }; +/** Build the interim spoken instruction while the delegated agent turn runs. */ export function buildRealtimeVoiceAgentConsultWorkingResponse( audienceLabel = "person", ): Record { @@ -57,6 +70,7 @@ export function buildRealtimeVoiceAgentConsultWorkingResponse( }; } +/** Default safe tool allowlist for voice consults in read-only mode. */ const SAFE_READ_ONLY_TOOLS = [ "read", "web_search", @@ -66,6 +80,7 @@ const SAFE_READ_ONLY_TOOLS = [ "memory_get", ] as const; +/** Type guard for user/config supplied consult tool policies. */ export function isRealtimeVoiceAgentConsultToolPolicy( value: unknown, ): value is RealtimeVoiceAgentConsultToolPolicy { @@ -77,6 +92,7 @@ export function isRealtimeVoiceAgentConsultToolPolicy( ); } +/** Normalize a configured consult tool policy with a caller-owned fallback. */ export function resolveRealtimeVoiceAgentConsultToolPolicy( value: unknown, fallback: RealtimeVoiceAgentConsultToolPolicy, @@ -85,6 +101,7 @@ export function resolveRealtimeVoiceAgentConsultToolPolicy( return isRealtimeVoiceAgentConsultToolPolicy(normalized) ? normalized : fallback; } +/** Merge the shared consult tool with provider/plugin custom realtime tools. */ export function resolveRealtimeVoiceAgentConsultTools( policy: RealtimeVoiceAgentConsultToolPolicy, customTools: RealtimeVoiceTool[] = [], @@ -93,6 +110,8 @@ export function resolveRealtimeVoiceAgentConsultTools( if (policy !== "none") { tools.set(REALTIME_VOICE_AGENT_CONSULT_TOOL.name, REALTIME_VOICE_AGENT_CONSULT_TOOL); } + // Keep the built-in consult tool first and prevent custom tools from + // replacing its provider-facing contract by name. for (const tool of customTools) { if (!tools.has(tool.name)) { tools.set(tool.name, tool); @@ -101,6 +120,7 @@ export function resolveRealtimeVoiceAgentConsultTools( return [...tools.values()]; } +/** Resolve the OpenClaw tool allowlist paired with the consult exposure policy. */ export function resolveRealtimeVoiceAgentConsultToolsAllow( policy: RealtimeVoiceAgentConsultToolPolicy, ): string[] | undefined { @@ -113,6 +133,7 @@ export function resolveRealtimeVoiceAgentConsultToolsAllow( return []; } +/** Build model instructions for when the voice agent should call the consult tool. */ export function buildRealtimeVoiceAgentConsultPolicyInstructions(config: { toolPolicy: RealtimeVoiceAgentConsultToolPolicy; consultPolicy?: "auto" | "substantive" | "always"; @@ -136,6 +157,7 @@ export function buildRealtimeVoiceAgentConsultPolicyInstructions(config: { ].join("\n"); } +/** Parse provider-owned consult tool arguments into the normalized contract. */ export function parseRealtimeVoiceAgentConsultArgs(args: unknown): RealtimeVoiceAgentConsultArgs { const question = readConsultStringArg(args, "question") ?? @@ -152,6 +174,7 @@ export function parseRealtimeVoiceAgentConsultArgs(args: unknown): RealtimeVoice }; } +/** Build the plain chat message used by browser/chat forwarding paths. */ export function buildRealtimeVoiceAgentConsultChatMessage(args: unknown): string { const parsed = parseRealtimeVoiceAgentConsultArgs(args); return [ @@ -163,6 +186,7 @@ export function buildRealtimeVoiceAgentConsultChatMessage(args: unknown): string .join("\n\n"); } +/** Build the delegated OpenClaw agent prompt for a live voice consult. */ export function buildRealtimeVoiceAgentConsultPrompt(params: { args: unknown; transcript: RealtimeVoiceAgentConsultTranscriptEntry[]; @@ -174,6 +198,7 @@ export function buildRealtimeVoiceAgentConsultPrompt(params: { const parsed = parseRealtimeVoiceAgentConsultArgs(params.args); const assistantLabel = params.assistantLabel ?? "Agent"; const questionSourceLabel = params.questionSourceLabel ?? params.userLabel.toLowerCase(); + // Bound transcript context so long meetings do not crowd out the live request. const transcript = params.transcript .slice(-12) .map( @@ -195,11 +220,13 @@ export function buildRealtimeVoiceAgentConsultPrompt(params: { .join("\n\n"); } +/** Collect only visible answer text from streamed delegated-agent payloads. */ export function collectRealtimeVoiceAgentConsultVisibleText( payloads: Array<{ text?: unknown; isError?: boolean; isReasoning?: boolean }>, ): string | null { const chunks: string[] = []; for (const payload of payloads) { + // Spoken replies must not include hidden reasoning or error-channel text. if (payload.isError || payload.isReasoning) { continue; } diff --git a/src/talk/agent-run-control-shared.ts b/src/talk/agent-run-control-shared.ts index 406cfb073d48..0fd23f06bca2 100644 --- a/src/talk/agent-run-control-shared.ts +++ b/src/talk/agent-run-control-shared.ts @@ -1,3 +1,9 @@ +/** + * Shared realtime voice controls for active OpenClaw agent runs. + * + * This module owns the provider-facing control tool, conservative intent + * classifier, and user-visible status/queue/cancel messages used by Talk. + */ import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -5,6 +11,7 @@ import { import type { RealtimeVoiceTool } from "./provider-types.js"; import type { TalkEvent } from "./talk-events.js"; +/** Provider-facing control modes for status, steering, cancellation, and follow-up work. */ export const REALTIME_VOICE_AGENT_CONTROL_MODES = [ "status", "steer", @@ -12,15 +19,19 @@ export const REALTIME_VOICE_AGENT_CONTROL_MODES = [ "followup", ] as const; +/** Closed set of realtime voice agent-control modes. */ export type RealtimeVoiceAgentControlMode = (typeof REALTIME_VOICE_AGENT_CONTROL_MODES)[number]; +/** Provider return shape for control calls that cancel active work immediately. */ export type RealtimeVoiceAgentControlProviderResult = { status: "cancelled"; message: string; }; +/** Stable provider-facing tool name for active-run voice control. */ export const REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME = "openclaw_agent_control"; +/** Realtime function-tool descriptor projected to voice providers. */ export const REALTIME_VOICE_AGENT_CONTROL_TOOL: RealtimeVoiceTool = { type: "function", name: REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, @@ -44,6 +55,7 @@ export const REALTIME_VOICE_AGENT_CONTROL_TOOL: RealtimeVoiceTool = { }, }; +/** Classified control intent plus whether automatic tool routing is safe. */ export type RealtimeVoiceAgentControlIntent = { mode: RealtimeVoiceAgentControlMode; confidence: "high" | "medium" | "low"; @@ -57,6 +69,7 @@ export type RealtimeVoiceAgentControlIntent = { shouldAutoControl: boolean; }; +/** Snapshot of active work used when recent Talk events cannot describe status. */ export type RealtimeVoiceAgentRunActivity = { activeWorkKind?: "tool_call" | "model_call" | "embedded_run"; hasActiveEmbeddedRun?: boolean; @@ -67,6 +80,7 @@ export type RealtimeVoiceAgentRunActivity = { lastProgressReason?: string; }; +/** Result returned after applying or reporting a voice control request. */ export type RealtimeVoiceAgentControlResult = { ok: boolean; mode: RealtimeVoiceAgentControlMode; @@ -86,6 +100,7 @@ export type RealtimeVoiceAgentControlResult = { deliveredAtMs?: number; }; +/** Normalize user/config/provider supplied control modes. */ export function normalizeRealtimeVoiceAgentControlMode( value: unknown, ): RealtimeVoiceAgentControlMode | undefined { @@ -139,6 +154,7 @@ function hasNegatedCancelIntent(text: string): boolean { ); } +/** Classify raw spoken control text with conservative auto-control gating. */ export function resolveRealtimeVoiceAgentControlIntent(params: { text: string; mode?: unknown; @@ -155,6 +171,8 @@ export function resolveRealtimeVoiceAgentControlIntent(params: { const text = params.text; const normalized = text.trim().toLowerCase(); + // "Stop using X" redirects the active work; it must not be treated as an + // abort of the whole run just because it starts with "stop". if (matchesAnyPattern(normalized, STOP_REDIRECT_CONTROL_PATTERNS)) { return { mode: "steer", @@ -206,14 +224,17 @@ export function resolveRealtimeVoiceAgentControlIntent(params: { }; } +/** Return the best control mode for a spoken utterance, even if auto-routing is unsafe. */ export function classifyRealtimeVoiceAgentControlText(text: string): RealtimeVoiceAgentControlMode { return resolveRealtimeVoiceAgentControlIntent({ text }).mode; } +/** Whether a spoken utterance is safe to route automatically to the control tool. */ export function shouldAutoControlRealtimeVoiceAgentText(text: string): boolean { return resolveRealtimeVoiceAgentControlIntent({ text }).shouldAutoControl; } +/** Parse provider-owned control tool args from JSON strings or object payloads. */ export function parseRealtimeVoiceAgentControlToolArgs(args: unknown): { text: string; mode: RealtimeVoiceAgentControlMode; @@ -249,6 +270,7 @@ function parseRealtimeVoiceAgentControlToolArgsRecord(args: unknown): unknown { } } +/** Build the system-style instruction that forces exact spoken status output. */ export function buildRealtimeVoiceAgentControlSpeechMessage(text: string): string { return [ "Internal OpenClaw voice control result.", @@ -258,6 +280,7 @@ export function buildRealtimeVoiceAgentControlSpeechMessage(text: string): strin ].join("\n"); } +/** Provider result payload used when the control tool cancels active work. */ export function buildRealtimeVoiceAgentCancelProviderResult( message = "Cancelled the active OpenClaw run.", ): RealtimeVoiceAgentControlProviderResult { @@ -267,6 +290,7 @@ export function buildRealtimeVoiceAgentCancelProviderResult( }; } +/** Wrap follow-up text so an active run treats it as deferred context. */ export function buildRealtimeVoiceAgentFollowupSteeringText(text: string): string { return [ "Spoken follow-up for the current voice call.", @@ -276,6 +300,7 @@ export function buildRealtimeVoiceAgentFollowupSteeringText(text: string): strin ].join("\n"); } +/** User-facing message for queue failures while steering or adding follow-up work. */ export function formatRealtimeVoiceAgentQueueRejection( mode: RealtimeVoiceAgentControlMode, reason: string, @@ -302,6 +327,7 @@ function isRealtimeVoiceAgentControlToolEvent(event: TalkEvent): boolean { return normalizeOptionalString(payload.name) === REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME; } +/** Format a concise spoken status for the active or most recent voice run. */ export function formatRealtimeVoiceAgentStatus(params: { active: boolean; recentEvents?: readonly TalkEvent[]; diff --git a/src/talk/agent-run-control.ts b/src/talk/agent-run-control.ts index 890d747a5683..5a4e5f696c4b 100644 --- a/src/talk/agent-run-control.ts +++ b/src/talk/agent-run-control.ts @@ -1,3 +1,9 @@ +/** + * Runtime adapter for realtime voice control of active OpenClaw agent runs. + * + * The shared module owns classification and message contracts; this adapter + * binds those contracts to embedded-run abort, status, and steering primitives. + */ import type { EmbeddedAgentQueueMessageOutcome } from "../agents/embedded-agent-runner/runs.js"; import { abortEmbeddedAgentRun, @@ -55,6 +61,7 @@ const defaultDeps: RealtimeVoiceAgentControlDeps = { resolveActiveEmbeddedRunSessionId, }; +/** Apply a spoken status, cancel, steer, or follow-up request to an active run. */ export async function controlRealtimeVoiceAgentRun( params: { sessionKey: string; @@ -72,6 +79,8 @@ export async function controlRealtimeVoiceAgentRun( const activity = deps.getDiagnosticSessionActivitySnapshot({ sessionId, sessionKey }); const active = Boolean(sessionId || activity.activeWorkKind || activity.hasActiveEmbeddedRun); + // Status is read-only and can answer from diagnostic activity even when the + // active embedded run id has already disappeared. if (mode === "status") { return { ok: true, @@ -90,6 +99,8 @@ export async function controlRealtimeVoiceAgentRun( }; } + // Cancellation requires a concrete embedded-run id; activity-only snapshots + // are not abortable and should return an explicit no-active-run response. if (mode === "cancel") { if (!sessionId) { return { @@ -140,6 +151,8 @@ export async function controlRealtimeVoiceAgentRun( }; } + // Steering and follow-up both enqueue to the active run; follow-up is wrapped + // so the runner treats it as deferred context instead of an immediate pivot. const steerText = mode === "followup" ? buildRealtimeVoiceAgentFollowupSteeringText(text) : text; const outcome = await deps.queueEmbeddedAgentMessageWithOutcomeAsync(sessionId, steerText, { steeringMode: "all", diff --git a/src/talk/agent-talkback-runtime.ts b/src/talk/agent-talkback-runtime.ts index 6f377c60960a..b56705c66bab 100644 --- a/src/talk/agent-talkback-runtime.ts +++ b/src/talk/agent-talkback-runtime.ts @@ -1,27 +1,40 @@ +/** + * Debounced realtime voice talkback queue for delegated OpenClaw consults. + * + * Transcript fragments can arrive quickly while one consult is already running; + * this queue batches compatible fragments, runs consults serially, and aborts + * cleanly when the voice session closes. + */ import type { RuntimeLogger } from "../plugins/runtime/types-core.js"; +/** Text produced by a delegated voice consult. */ export type RealtimeVoiceAgentTalkbackResult = { text: string; }; +/** Minimal queue API owned by a realtime voice session. */ export type RealtimeVoiceAgentTalkbackQueue = { close(): void; enqueue(question: string, metadata?: unknown): void; }; +/** Runtime dependencies and policy knobs for the talkback queue. */ export type RealtimeVoiceAgentTalkbackQueueParams = { + /** Delay used to merge nearby transcript fragments into one consult. */ debounceMs: number; isStopped: () => boolean; logger: Pick; logPrefix: string; responseStyle: string; fallbackText: string; + /** Delegates a batched question to OpenClaw and respects the abort signal. */ consult: (args: { question: string; metadata?: unknown; responseStyle: string; signal: AbortSignal; }) => Promise; + /** Delivers final speakable text back to the realtime provider/session. */ deliver: (text: string) => void; }; @@ -30,6 +43,7 @@ type PendingQuestion = { metadata?: unknown; }; +/** Create a serial consult queue for realtime transcript talkback. */ export function createRealtimeVoiceAgentTalkbackQueue( params: RealtimeVoiceAgentTalkbackQueueParams, ): RealtimeVoiceAgentTalkbackQueue { @@ -52,6 +66,8 @@ export function createRealtimeVoiceAgentTalkbackQueue( return; } if (active) { + // Preserve order while avoiding concurrent consults; compatible metadata + // fragments are merged by appendPendingQuestion below. appendPendingQuestion(pendingQuestions, { question: trimmed, metadata: pending.metadata, @@ -106,6 +122,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( active = false; const queuedQuestion = pendingQuestions.shift(); if (queuedQuestion && !params.isStopped()) { + // Continue draining any questions queued while the active consult ran. void run(queuedQuestion); } } @@ -115,6 +132,7 @@ export function createRealtimeVoiceAgentTalkbackQueue( close: () => { clearDebounceTimer(); pendingQuestions = []; + // Abort only the active consult; pending work has already been dropped. activeAbortController?.abort(); }, enqueue: (question, metadata) => { @@ -132,6 +150,8 @@ export function createRealtimeVoiceAgentTalkbackQueue( } appendPendingQuestion(pendingQuestions, { question: trimmed, metadata }); clearDebounceTimer(); + // Debounce short transcript bursts so partial ASR fragments become a + // single consult question instead of multiple back-to-back agent turns. debounceTimer = setTimeout(() => { debounceTimer = undefined; const queuedQuestion = pendingQuestions.shift(); @@ -147,6 +167,8 @@ export function createRealtimeVoiceAgentTalkbackQueue( function appendPendingQuestion(queue: PendingQuestion[], next: PendingQuestion): void { const current = queue.at(-1); if (current && Object.is(current.metadata, next.metadata)) { + // Metadata identity represents the caller/context lane; merge only when the + // same lane produced adjacent fragments. current.question = `${current.question}\n${next.question}`; return; } diff --git a/src/talk/audio-codec.ts b/src/talk/audio-codec.ts index a92c95228080..8470530288a3 100644 --- a/src/talk/audio-codec.ts +++ b/src/talk/audio-codec.ts @@ -1,3 +1,9 @@ +/** + * PCM resampling and G.711 mu-law conversion helpers for Talk audio bridges. + * + * Telephony providers generally expect 8 kHz mu-law frames, while local audio + * capture and realtime providers can produce higher-rate signed 16-bit PCM. + */ const TELEPHONY_SAMPLE_RATE = 8000; const RESAMPLE_FILTER_TAPS = 31; const RESAMPLE_CUTOFF_GUARD = 0.94; @@ -16,10 +22,13 @@ type ResampleKernel = { const HOST_IS_LITTLE_ENDIAN = new Uint16Array(new Uint8Array([1, 0]).buffer)[0] === 1; +/** Clamp an intermediate sample to signed 16-bit PCM range. */ function clamp16(value: number): number { return Math.max(-32768, Math.min(32767, value)); } +// When the host and Buffer alignment allow it, an Int16Array view avoids copying +// every PCM sample. The fallback below preserves correctness for odd offsets. function canUseInt16View(buffer: Buffer): boolean { return HOST_IS_LITTLE_ENDIAN && buffer.byteOffset % Int16Array.BYTES_PER_ELEMENT === 0; } @@ -73,6 +82,8 @@ function buildResampleKernel( const inputStep = inputSampleRate / divisor; const phaseCount = outputSampleRate / divisor; if (phaseCount > RESAMPLE_MAX_PRECOMPUTED_PHASES) { + // Very unusual rate ratios would allocate too many phase tables; callers + // fall back to the direct bandlimited sampler instead. return undefined; } const coefficients = Array.from({ length: phaseCount }, (_, phaseIndex) => { @@ -89,6 +100,7 @@ function buildResampleKernel( return { coefficients, inputStep, phaseCount }; } +// Samples through a precomputed windowed-sinc kernel for common rate ratios. function sampleBandlimitedWithCoefficients( input: Int16Array, center: number, @@ -115,6 +127,7 @@ function sampleBandlimitedWithCoefficients( return weighted / weightSum; } +// Direct windowed-sinc sampler used when precomputing phase tables is too large. function sampleBandlimited( input: Int16Array, srcPos: number, @@ -145,6 +158,7 @@ function sampleBandlimited( return weighted / weightSum; } +/** Resample little-endian signed 16-bit PCM to another integer sample rate. */ export function resamplePcm( input: Buffer, inputSampleRate: number, @@ -190,10 +204,12 @@ export function resamplePcm( return output; } +/** Resample little-endian signed 16-bit PCM to the telephony 8 kHz rate. */ export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer { return resamplePcm(input, inputSampleRate, TELEPHONY_SAMPLE_RATE); } +/** Convert little-endian signed 16-bit PCM samples to G.711 mu-law bytes. */ export function pcmToMulaw(pcm: Buffer): Buffer { const pcmView = readInt16Samples(pcm); const mulaw = Buffer.alloc(pcmView.length); @@ -205,6 +221,7 @@ export function pcmToMulaw(pcm: Buffer): Buffer { return mulaw; } +/** Expand G.711 mu-law bytes into little-endian signed 16-bit PCM samples. */ export function mulawToPcm(mulaw: Buffer): Buffer { const pcm = Buffer.alloc(mulaw.length * 2); const pcmView = canUseInt16View(pcm) ? int16View(pcm) : undefined; @@ -221,10 +238,13 @@ export function mulawToPcm(mulaw: Buffer): Buffer { return pcm; } +/** Resample signed 16-bit PCM to 8 kHz and encode it as G.711 mu-law. */ export function convertPcmToMulaw8k(pcm: Buffer, inputSampleRate: number): Buffer { return pcmToMulaw(resamplePcmTo8k(pcm, inputSampleRate)); } +// ITU G.711-style mu-law companding. The bias and clip constants intentionally +// match the standard table formula so round-trips remain provider-compatible. function linearToMulaw(sampleInput: number): number { let sample = sampleInput; const BIAS = 132; diff --git a/src/talk/consult-question.ts b/src/talk/consult-question.ts index 36d8c318835d..400e97cf4522 100644 --- a/src/talk/consult-question.ts +++ b/src/talk/consult-question.ts @@ -1,3 +1,9 @@ +/** + * Realtime voice consult-question extraction and result summarization helpers. + * + * These utilities connect Talk tool calls to spoken follow-up answers by + * pulling human-readable questions/results out of provider-owned payloads. + */ const REALTIME_VOICE_CONSULT_QUESTION_STOPWORDS = new Set([ "a", "an", @@ -30,16 +36,22 @@ const DEFAULT_REALTIME_VOICE_SPEAKABLE_RESULT_KEYS = ["text", "result", "output" const DEFAULT_REALTIME_VOICE_SPEAKABLE_RESULT_MAX_CHARS = 1_800; export type RealtimeVoiceConsultQuestionMatchOptions = { + /** Minimum overlap ratio against the smaller token set for fuzzy matches. */ minTokenOverlapRatio?: number; + /** Minimum number of non-stopword tokens that must overlap. */ minTokenOverlapCount?: number; }; export type RealtimeVoiceSpeakableToolResultOptions = { + /** Candidate result keys to read from object-shaped tool output. */ keys?: readonly string[]; + /** Maximum spoken result length before appending a truncation marker. */ maxChars?: number; + /** Whether a raw string result is allowed as speakable output. */ stringResult?: boolean; }; +/** Read the consult question from a raw string or selected object keys. */ export function readRealtimeVoiceConsultQuestion( args: unknown, keys: readonly string[] = DEFAULT_REALTIME_VOICE_CONSULT_QUESTION_KEYS, @@ -60,6 +72,7 @@ export function readRealtimeVoiceConsultQuestion( return undefined; } +/** Normalize consult questions for stable matching across punctuation/casing. */ export function normalizeRealtimeVoiceConsultQuestion( value: string | undefined, ): string | undefined { @@ -72,6 +85,7 @@ export function normalizeRealtimeVoiceConsultQuestion( ); } +/** Compare two consult questions with exact, containment, and token-overlap matching. */ export function matchRealtimeVoiceConsultQuestions( left: string | undefined, right: string | undefined, @@ -82,6 +96,8 @@ export function matchRealtimeVoiceConsultQuestions( if (!normalizedLeft || !normalizedRight) { return false; } + // Containment catches common provider rewrites such as adding "please" or + // "can you" around the same short question. if ( normalizedLeft === normalizedRight || normalizedLeft.includes(normalizedRight) || @@ -108,6 +124,7 @@ export function matchRealtimeVoiceConsultQuestions( return overlap / Math.min(leftTokens.size, rightTokens.size) >= minTokenOverlapRatio; } +/** Extract a bounded speakable string from a tool result payload. */ export function readSpeakableRealtimeVoiceToolResult( result: unknown, options: RealtimeVoiceSpeakableToolResultOptions = {}, @@ -133,6 +150,8 @@ export function readSpeakableRealtimeVoiceToolResult( } function realtimeVoiceConsultQuestionTokens(value: string): Set { + // Drop short/stopword tokens so overlap is based on the semantic parts of the + // question instead of assistant boilerplate. return new Set( value .split(/[^\p{L}\p{N}]+/gu) @@ -154,5 +173,7 @@ function limitSpeakableRealtimeVoiceToolResult( if (trimmed.length <= maxChars) { return trimmed; } + // Reserve space for the marker so callers can keep audio replies under a + // provider or UX limit without losing the truncation signal. return `${trimmed.slice(0, Math.max(0, maxChars - 16)).trimEnd()} [truncated]`; } diff --git a/src/talk/consult-transcript.ts b/src/talk/consult-transcript.ts index 44e9bbd4d5c9..2c76859cd769 100644 --- a/src/talk/consult-transcript.ts +++ b/src/talk/consult-transcript.ts @@ -1,3 +1,9 @@ +/** + * Transcript guardrails for realtime voice agent consults. + * + * ASR often emits partial fragments or polite closings that should not trigger + * an OpenClaw consult. This classifier names those skip reasons for callers. + */ const REALTIME_VOICE_CONSULT_TRAILING_FRAGMENT_WORDS = new Set([ "a", "about", @@ -22,12 +28,14 @@ const REALTIME_VOICE_CONSULT_TRAILING_FRAGMENT_WORDS = new Set([ "with", ]); +/** Reason a transcript should be ignored before creating a consult request. */ export type SkippableRealtimeVoiceConsultTranscriptReason = | "empty" | "incomplete-transcript" | "trailing-fragment" | "non-actionable-closing"; +/** Classify transcript text that is empty, incomplete, fragmented, or non-actionable. */ export function classifySkippableRealtimeVoiceConsultTranscript( text: string, ): SkippableRealtimeVoiceConsultTranscriptReason | undefined { @@ -39,9 +47,13 @@ export function classifySkippableRealtimeVoiceConsultTranscript( return "incomplete-transcript"; } const lastWord = normalized.match(/[a-z']+$/)?.[0]?.replace(/^'+|'+$/g, ""); + // A trailing connector usually means ASR has not emitted the object yet: + // "tell me about", "ship it so", "check the". if (lastWord && REALTIME_VOICE_CONSULT_TRAILING_FRAGMENT_WORDS.has(lastWord)) { return "trailing-fragment"; } + // Closings are ignored unless they are framed as questions, because they are + // common conversational exits rather than work requests. if ( !normalized.includes("?") && (/^(i'?ll|i will) be (right )?back\b/.test(normalized) || diff --git a/src/talk/diagnostics.ts b/src/talk/diagnostics.ts index 731dc75562e1..e17a047f2795 100644 --- a/src/talk/diagnostics.ts +++ b/src/talk/diagnostics.ts @@ -1,3 +1,9 @@ +/** + * Privacy-preserving Talk diagnostic event projection. + * + * The diagnostic stream needs timing and size counters for reliability work, + * but must not export raw provider payloads, transcripts, or audio content. + */ import { emitTrustedDiagnosticEvent, type DiagnosticEventInput, @@ -7,6 +13,7 @@ import type { TalkEvent } from "./talk-events.js"; type TalkDiagnosticEventInput = Extract; +/** Convert a Talk event into the bounded diagnostic payload shape. */ export function createTalkDiagnosticEvent(event: TalkEvent): TalkDiagnosticEventInput { const payload = talkEventPayloadRecord(event.payload); return { @@ -20,11 +27,14 @@ export function createTalkDiagnosticEvent(event: TalkEvent): TalkDiagnosticEvent brain: event.brain, provider: event.provider, final: event.final, + // Read only known numeric aliases from provider payloads; raw payload text + // and audio bytes stay out of diagnostics. durationMs: firstFiniteTalkEventNumber(payload, ["durationMs", "latencyMs", "elapsedMs"]), byteLength: firstFiniteTalkEventNumber(payload, ["byteLength", "audioBytes"]), }; } +/** Emit a trusted internal diagnostic event for one Talk event. */ export function recordTalkDiagnosticEvent(event: TalkEvent): void { emitTrustedDiagnosticEvent(createTalkDiagnosticEvent(event)); } diff --git a/src/talk/event-metrics.ts b/src/talk/event-metrics.ts index d6a4d92ec507..739290634c21 100644 --- a/src/talk/event-metrics.ts +++ b/src/talk/event-metrics.ts @@ -1,5 +1,13 @@ +/** + * Shared metric extraction helpers for Talk event diagnostics and logging. + * + * Talk event payloads are provider-owned JSON blobs, so callers must coerce + * records and read only bounded numeric counters that are safe to export. + */ +/** Coerce unknown Talk event payloads into optional records for metric reads. */ export { asOptionalRecord as talkEventPayloadRecord } from "../../packages/normalization-core/src/record-coerce.js"; +/** Read the first non-negative finite number from a provider payload record. */ export function firstFiniteTalkEventNumber( record: Record | undefined, keys: readonly string[], @@ -10,6 +18,8 @@ export function firstFiniteTalkEventNumber( for (const key of keys) { const value = record[key]; if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + // Reject negative, NaN, and Infinity values before diagnostics/logging so + // provider bugs cannot poison aggregate Talk metrics. return value; } } diff --git a/src/talk/fast-context-runtime.ts b/src/talk/fast-context-runtime.ts index e280253bedc4..908310073d92 100644 --- a/src/talk/fast-context-runtime.ts +++ b/src/talk/fast-context-runtime.ts @@ -1,3 +1,10 @@ +/** + * Fast context lookup for realtime voice consults. + * + * When memory/session search can answer quickly, Talk can return concise + * context without launching a full agent consult; otherwise callers may fall + * back to the normal consult flow. + */ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -18,14 +25,20 @@ type MemorySearchHit = { score: number; }; +/** Fast-context lookup policy for realtime voice consult shortcuts. */ export type RealtimeVoiceFastContextConfig = { enabled: boolean; + /** Maximum memory/session hits to include in the spoken-context prompt. */ maxResults: number; + /** Search backends allowed for the quick lookup. */ sources: Array<"memory" | "sessions">; + /** Deadline before the quick lookup gives up. */ timeoutMs: number; + /** Whether miss/unavailable/timeout should fall back to a full consult. */ fallbackToConsult: boolean; }; +/** Human labels used in generated fast-context responses. */ export type RealtimeVoiceFastContextLabels = { audienceLabel: string; contextName: string; @@ -53,6 +66,8 @@ function normalizeSnippet(text: string): string { if (normalized.length <= MAX_SNIPPET_CHARS) { return normalized; } + // Keep individual memory snippets bounded so several hits still fit in a + // short realtime response prompt. return `${normalized.slice(0, MAX_SNIPPET_CHARS - 1).trimEnd()}...`; } @@ -104,6 +119,8 @@ async function withTimeout(promise: Promise, timeoutMs: number): Promise((_resolve, reject) => { + // resolveTimerTimeoutMs caps huge configured deadlines before they + // reach Node's timer APIs. timer = setTimeout( () => reject(new RealtimeFastContextTimeoutError(resolvedTimeoutMs)), resolvedTimeoutMs, @@ -124,6 +141,8 @@ async function lookupFastContext(params: { config: RealtimeVoiceFastContextConfig; query: string; }): Promise { + // The memory runtime owns whether memory/session search is active for this + // agent. Talk only consumes the current manager when it is already available. const memory = await getActiveMemorySearchManager({ cfg: params.cfg, agentId: params.agentId, @@ -142,6 +161,7 @@ async function lookupFastContext(params: { return { status: "hits", hits }; } +/** Try to answer a realtime consult from fast memory/session context. */ export async function resolveRealtimeVoiceFastContextConsult(params: { cfg: OpenClawConfig; agentId: string; @@ -170,12 +190,16 @@ export async function resolveRealtimeVoiceFastContextConsult(params: { ); if (lookup.status === "unavailable") { params.logger.debug?.(`[talk] fast context unavailable: ${lookup.error}`); + // In fallback mode, let the normal agent consult decide. Otherwise produce + // a bounded "no context handy" result immediately for the voice call. return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; } const { hits } = lookup; if (hits.length === 0) { + // Empty hits behave like unavailable context: either fall back to full + // agent work or answer quickly that nothing relevant was found. return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; @@ -187,6 +211,8 @@ export async function resolveRealtimeVoiceFastContextConsult(params: { } catch (error) { const message = formatErrorMessage(error); params.logger.debug?.(`[talk] fast context lookup failed: ${message}`); + // Timeouts and lookup failures are non-fatal because this is an optional + // acceleration path ahead of the normal consult runtime. return params.config.fallbackToConsult ? { handled: false } : { handled: true, result: { text: buildMissText(query, labels) } }; diff --git a/src/talk/forced-consult-coordinator.ts b/src/talk/forced-consult-coordinator.ts index 0f6e230c04e7..62dd61d3fbca 100644 --- a/src/talk/forced-consult-coordinator.ts +++ b/src/talk/forced-consult-coordinator.ts @@ -1,3 +1,10 @@ +/** + * Forced-consult dedupe coordinator for realtime voice sessions. + * + * The relay may synthesize an OpenClaw consult when the model hesitates, but a + * native provider tool call can still arrive later. This coordinator prevents + * duplicate consults and keeps late native calls correlated to forced handles. + */ import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import { matchRealtimeVoiceConsultQuestions, @@ -7,24 +14,29 @@ import { const DEFAULT_REALTIME_VOICE_FORCED_CONSULT_NATIVE_DEDUPE_MS = 2_000; const DEFAULT_REALTIME_VOICE_FORCED_CONSULT_LIMIT = 12; +/** Timer abstraction used so tests can inject deterministic fake timers. */ export type RealtimeVoiceForcedConsultTimer = { clear(): void; }; +/** Coordinator tuning and injectable clock/timer/matcher hooks. */ export type RealtimeVoiceForcedConsultCoordinatorOptions = { limit?: number; + /** Window for matching late native consults to forced consult handles. */ nativeDedupeMs?: number; now?: () => number; setTimer?: (fn: () => void, ms: number) => RealtimeVoiceForcedConsultTimer; questionsMatch?: (left: string | undefined, right: string | undefined) => boolean; }; +/** Stable handle for one forced consult lifecycle. */ export type RealtimeVoiceForcedConsultHandle = { id: string; question: string; context?: TContext; }; +/** Classification of a native provider consult relative to forced consult state. */ export type RealtimeVoiceForcedConsultNativeMatch = | { kind: "none"; question?: string } | { kind: "pending"; question?: string; handle: RealtimeVoiceForcedConsultHandle } @@ -36,9 +48,11 @@ export type RealtimeVoiceForcedConsultNativeMatch = }; export type RealtimeVoiceForcedConsultNativeRecentOptions = { + /** Treat native calls without readable questions as recent generic consults. */ allowUnknownQuestion?: boolean; }; +/** Public state machine for forced/native consult dedupe in a voice session. */ export type RealtimeVoiceForcedConsultCoordinator = { prepare( question: string, @@ -91,6 +105,7 @@ type RecentNativeConsult = { at: number; }; +/** Create an in-memory forced-consult coordinator for one realtime session. */ export function createRealtimeVoiceForcedConsultCoordinator( options: RealtimeVoiceForcedConsultCoordinatorOptions = {}, ): RealtimeVoiceForcedConsultCoordinator { @@ -117,6 +132,8 @@ export function createRealtimeVoiceForcedConsultCoordinator( const scheduleCleanup = (stored: StoredForcedConsult) => { stored.cleanupTimer?.clear(); + // Delivered/cancelled handles remain visible briefly so late provider tool + // calls can be matched and suppressed instead of spoken twice. stored.cleanupTimer = setTimer(() => { if (state.get(stored.handle.id) === stored) { state.delete(stored.handle.id); @@ -140,6 +157,7 @@ export function createRealtimeVoiceForcedConsultCoordinator( if (!first) { return; } + // Bound memory/timer use for long calls with repeated forced consults. first.timer?.clear(); first.cleanupTimer?.clear(); state.delete(first.handle.id); @@ -171,6 +189,8 @@ export function createRealtimeVoiceForcedConsultCoordinator( if (stored.questions.some((candidate) => questionsMatch(candidate, trimmed))) { return; } + // Provider rewrites can use prompt/query aliases; remembering all observed + // question variants improves later dedupe matching. stored.questions.push(trimmed); }; @@ -239,6 +259,7 @@ export function createRealtimeVoiceForcedConsultCoordinator( run(handle); } }, + // Clamp pathological delays before they reach Node timer APIs. resolveTimerTimeoutMs(delayMs, 0, 0), ); }, @@ -252,6 +273,8 @@ export function createRealtimeVoiceForcedConsultCoordinator( }, consumePending(question) { const pendingCandidates = [...state.values()].filter((candidate) => candidate.pending); + // If there is exactly one pending forced consult, allow callers that do + // not have readable question text to consume it unambiguously. const stored = !question && pendingCandidates.length === 1 ? pendingCandidates[0] @@ -281,6 +304,8 @@ export function createRealtimeVoiceForcedConsultCoordinator( recordNativeConsult(args, nativeCallId) { const question = readRealtimeVoiceConsultQuestion(args); recordRecentNativeConsult(question); + // Native calls win over scheduled forced calls when they match a pending + // question; the pending timer is cleared and the handle remains for dedupe. const pending = [...state.values()] .toReversed() .find( diff --git a/src/talk/logging.ts b/src/talk/logging.ts index 7561ec70a39e..fa69bf1b585a 100644 --- a/src/talk/logging.ts +++ b/src/talk/logging.ts @@ -2,14 +2,22 @@ import { getChildLogger } from "../logging/logger.js"; import { firstFiniteTalkEventNumber, talkEventPayloadRecord } from "./event-metrics.js"; import type { TalkEvent, TalkEventType } from "./talk-events.js"; +/** + * Log severity produced from Talk event envelopes. + */ type TalkLogLevel = "info" | "warn"; +/** + * Compact structured log record for a non-noisy Talk event. + */ type TalkLogRecord = { level: TalkLogLevel; message: string; attributes: Record; }; +// Delta events can arrive at audio/text chunk cadence; omitting them keeps logs useful +// without hiding lifecycle, error, usage, and latency events. const OMITTED_TALK_LOG_EVENT_TYPES = new Set([ "input.audio.delta", "output.audio.delta", @@ -20,7 +28,9 @@ const OMITTED_TALK_LOG_EVENT_TYPES = new Set([ const TALK_LOGGER_BINDINGS = Object.freeze({ subsystem: "talk" }); -/** Converts high-level Talk events into compact structured log records, skipping noisy deltas. */ +/** + * Converts high-level Talk events into compact structured log records, skipping noisy deltas. + */ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined { if (OMITTED_TALK_LOG_EVENT_TYPES.has(event.type)) { return undefined; @@ -58,7 +68,9 @@ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined }; } -/** Emits Talk logs best-effort so logging failures never break realtime audio handling. */ +/** + * Emits Talk logs best-effort so logging failures never break realtime audio handling. + */ export function recordTalkLogEvent(event: TalkEvent): void { const record = createTalkLogRecord(event); if (!record) { diff --git a/src/talk/observability.ts b/src/talk/observability.ts index 2d1c309c6f34..8652305c48e5 100644 --- a/src/talk/observability.ts +++ b/src/talk/observability.ts @@ -1,7 +1,14 @@ +/** + * Combined Talk observability hook for relays and SDK consumers. + * + * A single Talk event should feed both trusted diagnostics and structured logs; + * this facade keeps relay call sites from choosing only one path. + */ import { recordTalkDiagnosticEvent } from "./diagnostics.js"; import { recordTalkLogEvent } from "./logging.js"; import type { TalkEvent } from "./talk-events.js"; +/** Record one Talk event through diagnostics and logging projections. */ export function recordTalkObservabilityEvent(event: TalkEvent): void { recordTalkDiagnosticEvent(event); recordTalkLogEvent(event); diff --git a/src/talk/output-activity-tracker.ts b/src/talk/output-activity-tracker.ts index 36000a0014c7..9c00674f66ad 100644 --- a/src/talk/output-activity-tracker.ts +++ b/src/talk/output-activity-tracker.ts @@ -1,13 +1,22 @@ +/** + * Realtime voice output activity counters and playback-state tracking. + * + * Providers use this to decide whether assistant output is active, + * interruptible, or overdue relative to the audio duration already emitted. + */ export type RealtimeVoiceOutputActivityTrackerOptions = { + /** Injectable clock for deterministic tests and playback watchdog math. */ now?: () => number; }; +/** One output activity increment from source audio and/or sink audio. */ export type RealtimeVoiceOutputActivityDelta = { audioMs?: number; sourceAudioBytes?: number; sinkAudioBytes?: number; }; +/** Current output counters and playback timestamps. */ export type RealtimeVoiceOutputActivitySnapshot = { audioMs: number; chunks: number; @@ -19,19 +28,24 @@ export type RealtimeVoiceOutputActivitySnapshot = { playbackStartedAt?: number; }; +/** Mutable tracker for one realtime voice output stream. */ export type RealtimeVoiceOutputActivityTracker = { markStreamOpened(): void; markStreamEnding(): void; markPlaybackStarted(): void; markAudio(delta: RealtimeVoiceOutputActivityDelta): void; reset(): void; + /** Whether output exists or the downstream sink reports active playback. */ isActive(sinkActive?: boolean): boolean; + /** Whether caller speech should be treated as interrupting current output. */ isInterruptible(sinkActive?: boolean): boolean; elapsedPlaybackMs(): number; + /** Delay before watchdog should assume playback has exceeded expected audio duration. */ playbackWatchdogDelayMs(options: { marginMs: number; minMs?: number }): number | undefined; snapshot(): RealtimeVoiceOutputActivitySnapshot; }; +/** Create a fresh output activity tracker for a realtime voice session. */ export function createRealtimeVoiceOutputActivityTracker( options: RealtimeVoiceOutputActivityTrackerOptions = {}, ): RealtimeVoiceOutputActivityTracker { @@ -58,6 +72,8 @@ export function createRealtimeVoiceOutputActivityTracker( return { markStreamOpened() { + // A new stream clears playback markers but keeps cumulative counters until + // reset(), so callers can preserve total output stats across stream opens. streamEnding = false; playbackStarted = false; playbackStartedAt = undefined; @@ -74,6 +90,8 @@ export function createRealtimeVoiceOutputActivityTracker( playbackStartedAt = now(); }, markAudio(delta) { + // Clamp negative/provider-buggy deltas to zero while still recording that + // a chunk arrived. audioMs += Math.max(0, delta.audioMs ?? 0); sourceAudioBytes += Math.max(0, delta.sourceAudioBytes ?? 0); sinkAudioBytes += Math.max(0, delta.sinkAudioBytes ?? 0); @@ -91,6 +109,7 @@ export function createRealtimeVoiceOutputActivityTracker( playbackStartedAt = undefined; }, isActive(sinkActive = false) { + // Some sinks can report active playback before byte counters are visible. return sinkActive || chunks > 0; }, isInterruptible(sinkActive = false) { @@ -103,6 +122,8 @@ export function createRealtimeVoiceOutputActivityTracker( if (playbackStartedAt === undefined || audioMs <= 0) { return undefined; } + // Watchdog waits for emitted audio duration plus margin, but never below + // the configured minimum to avoid immediate false positives. return Math.max(minMs, audioMs - (now() - playbackStartedAt) + marginMs); }, snapshot, diff --git a/src/talk/provider-registry.ts b/src/talk/provider-registry.ts index 9065f7edc558..3b5956a88c6c 100644 --- a/src/talk/provider-registry.ts +++ b/src/talk/provider-registry.ts @@ -10,12 +10,17 @@ import { import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; import type { RealtimeVoiceProviderId } from "./provider-types.js"; +/** + * Normalizes realtime voice provider ids so direct ids and aliases compare through one registry key. + */ export function normalizeRealtimeVoiceProviderId( providerId: string | undefined, ): RealtimeVoiceProviderId | undefined { return normalizeCapabilityProviderId(providerId); } +// Realtime voice providers are regular plugin capability providers; Talk keeps this small +// wrapper so gateway and SDK callers do not need to know the manifest capability key. function resolveRealtimeVoiceProviderEntries(cfg?: OpenClawConfig): RealtimeVoiceProviderPlugin[] { return resolvePluginCapabilityProviders({ key: "realtimeVoiceProviders", @@ -30,10 +35,16 @@ function buildProviderMaps(cfg?: OpenClawConfig): { return buildCapabilityProviderMaps(resolveRealtimeVoiceProviderEntries(cfg)); } +/** + * Lists canonical realtime voice provider plugins in registry order. + */ export function listRealtimeVoiceProviders(cfg?: OpenClawConfig): RealtimeVoiceProviderPlugin[] { return [...buildProviderMaps(cfg).canonical.values()]; } +/** + * Resolves a realtime voice provider by canonical id or declared alias. + */ export function getRealtimeVoiceProvider( providerId: string | undefined, cfg?: OpenClawConfig, @@ -42,6 +53,8 @@ export function getRealtimeVoiceProvider( if (!normalized) { return undefined; } + // Prefer the capability runtime's direct provider lookup; alias maps are a secondary + // Talk-level convenience for user config and gateway requests. const directProvider = resolvePluginCapabilityProvider({ key: "realtimeVoiceProviders", providerId: normalized, @@ -53,6 +66,9 @@ export function getRealtimeVoiceProvider( return buildProviderMaps(cfg).aliases.get(normalized); } +/** + * Converts a realtime voice provider id or alias into the canonical provider id when known. + */ export function canonicalizeRealtimeVoiceProviderId( providerId: string | undefined, cfg?: OpenClawConfig, @@ -61,5 +77,6 @@ export function canonicalizeRealtimeVoiceProviderId( if (!normalized) { return undefined; } + // Unknown ids stay normalized so validation can report the same operator-facing value. return getRealtimeVoiceProvider(normalized, cfg)?.id ?? normalized; } diff --git a/src/talk/provider-resolver.ts b/src/talk/provider-resolver.ts index 693c473d8d5b..180a787d9da6 100644 --- a/src/talk/provider-resolver.ts +++ b/src/talk/provider-resolver.ts @@ -1,25 +1,38 @@ +/** + * Realtime voice provider selection and config resolution. + * + * This adapter applies the generic capability-provider resolver to Talk + * providers, including default model injection and per-call config overrides. + */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveConfiguredCapabilityProvider } from "../plugin-sdk/provider-selection-runtime.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; import { getRealtimeVoiceProvider, listRealtimeVoiceProviders } from "./provider-registry.js"; import type { RealtimeVoiceProviderConfig } from "./provider-types.js"; +/** Resolved realtime voice provider plus provider-normalized config. */ export type ResolvedRealtimeVoiceProvider = { provider: RealtimeVoiceProviderPlugin; providerConfig: RealtimeVoiceProviderConfig; }; +/** Inputs for resolving a configured or auto-selected realtime voice provider. */ export type ResolveConfiguredRealtimeVoiceProviderParams = { configuredProviderId?: string; providerConfigs?: Record | undefined>; + /** Last-mile overrides from a session/client request. */ providerConfigOverrides?: Record; cfg?: OpenClawConfig; + /** Alternate config object used by generic provider selection internals. */ cfgForResolve?: OpenClawConfig; + /** Test/runtime override for the provider list. */ providers?: RealtimeVoiceProviderPlugin[]; + /** Model injected before provider-specific resolveConfig runs. */ defaultModel?: string; noRegisteredProviderMessage?: string; }; +/** Resolve the configured realtime voice provider or auto-select the first configured one. */ export function resolveConfiguredRealtimeVoiceProvider( params: ResolveConfiguredRealtimeVoiceProviderParams, ): ResolvedRealtimeVoiceProvider { @@ -35,6 +48,8 @@ export function resolveConfiguredRealtimeVoiceProvider( getRealtimeVoiceProvider(providerId, params.cfg), listProviders: () => providers, resolveProviderConfig: ({ provider, cfg, rawConfig }) => { + // Provider config resolution should see the default model as if it came + // from config, while explicit provider config still wins. const rawConfigWithModel = params.defaultModel && rawConfig.model === undefined ? { ...rawConfig, model: params.defaultModel } @@ -43,6 +58,8 @@ export function resolveConfiguredRealtimeVoiceProvider( ...rawConfigWithModel, ...params.providerConfigOverrides, }; + // Per-call overrides are applied before provider normalization so provider + // implementations can validate and coerce them consistently. return ( provider.resolveConfig?.({ cfg, rawConfig: rawConfigWithOverrides }) ?? rawConfigWithOverrides diff --git a/src/talk/session-runtime.ts b/src/talk/session-runtime.ts index 3a0e4174951f..3448f75f6600 100644 --- a/src/talk/session-runtime.ts +++ b/src/talk/session-runtime.ts @@ -13,6 +13,9 @@ import type { RealtimeVoiceToolResultOptions, } from "./provider-types.js"; +/** + * Transport-facing audio target used by realtime voice bridge sessions. + */ export type RealtimeVoiceAudioSink = { isOpen?: () => boolean; sendAudio: (audio: Buffer) => void; @@ -20,8 +23,14 @@ export type RealtimeVoiceAudioSink = { sendMark?: (markName: string) => void; }; +/** + * Controls how provider playback marks are bridged to transports that may or may not ack marks. + */ export type RealtimeVoiceMarkStrategy = "transport" | "ack-immediately" | "ignore"; +/** + * Stable session facade handed to gateway code and provider tool callbacks. + */ export type RealtimeVoiceBridgeSession = { bridge: RealtimeVoiceBridge; acknowledgeMark(): void; @@ -35,6 +44,9 @@ export type RealtimeVoiceBridgeSession = { triggerGreeting(instructions?: string): void; }; +/** + * Provider bridge inputs plus transport callbacks for one realtime voice session. + */ export type RealtimeVoiceBridgeSessionParams = { provider: RealtimeVoiceProviderPlugin; cfg?: OpenClawConfig; @@ -56,6 +68,9 @@ export type RealtimeVoiceBridgeSessionParams = { onClose?: (reason: RealtimeVoiceCloseReason) => void; }; +/** + * Creates a realtime voice bridge session and wires provider events to the configured audio sink. + */ export function createRealtimeVoiceBridgeSession( params: RealtimeVoiceBridgeSessionParams, ): RealtimeVoiceBridgeSession { @@ -66,6 +81,8 @@ export function createRealtimeVoiceBridgeSession( } return bridgeRef.current; }; + // The provider may call callbacks during createBridge(); keep the public session facade + // stable while blocking use until the bridge object has actually been returned. const session: RealtimeVoiceBridgeSession = { get bridge() { return requireBridge(); @@ -101,6 +118,8 @@ export function createRealtimeVoiceBridgeSession( } }, onMark: (markName) => { + // Some transports send mark acks, some need immediate provider acks, and some ignore + // playback marks entirely. Keep that policy centralized at the bridge boundary. if (!canSendAudio() || params.markStrategy === "ignore") { return; } diff --git a/src/talk/talk-events.ts b/src/talk/talk-events.ts index 5887c70632db..fd2b0451e3df 100644 --- a/src/talk/talk-events.ts +++ b/src/talk/talk-events.ts @@ -1,3 +1,6 @@ +/** + * Canonical event names emitted by Talk sessions across realtime and STT/TTS flows. + */ export const TALK_EVENT_TYPES = [ "session.started", "session.ready", @@ -29,14 +32,29 @@ export const TALK_EVENT_TYPES = [ "health.changed", ] as const; +/** + * Talk event name accepted by the event sequencer. + */ export type TalkEventType = (typeof TALK_EVENT_TYPES)[number]; +/** + * High-level media mode used to group Talk session telemetry. + */ export type TalkMode = "realtime" | "stt-tts" | "transcription"; +/** + * Transport family carrying Talk audio and session control. + */ export type TalkTransport = "webrtc" | "provider-websocket" | "gateway-relay" | "managed-room"; +/** + * Brain mode that explains whether Talk output is agent-mediated, tool-only, or passive. + */ export type TalkBrain = "agent-consult" | "direct-tools" | "none"; +/** + * Session-level correlation fields copied onto every Talk event. + */ export type TalkEventContext = { sessionId: string; mode: TalkMode; @@ -45,6 +63,9 @@ export type TalkEventContext = { provider?: string; }; +/** + * Sequenced Talk event envelope delivered to observers and gateway clients. + */ export type TalkEvent = TalkEventContext & { id: string; type: TalkEventType; @@ -59,6 +80,9 @@ export type TalkEvent = TalkEventContext & { payload: TPayload; }; +/** + * Caller-supplied event payload before session context, id, sequence, and timestamp are attached. + */ export type TalkEventInput = { type: TalkEventType; payload: TPayload; @@ -71,10 +95,15 @@ export type TalkEventInput = { parentId?: string; }; +/** + * Per-session event sequencer that enforces correlation ids before emitting events. + */ export type TalkEventSequencer = { next(input: TalkEventInput): TalkEvent; }; +// Turn-scoped event names must carry turnId so mixed audio/text/tool streams can be +// reconstructed without guessing from sequence order alone. const TURN_SCOPED_TALK_EVENT_TYPES = new Set([ "turn.started", "turn.ended", @@ -94,6 +123,7 @@ const TURN_SCOPED_TALK_EVENT_TYPES = new Set([ "tool.error", ]); +// Capture-scoped events describe microphone capture lifecycle, which can overlap turns. const CAPTURE_SCOPED_TALK_EVENT_TYPES = new Set([ "capture.started", "capture.stopped", @@ -110,6 +140,9 @@ function assertTalkEventCorrelation(input: TalkEventInput): void { } } +/** + * Creates a sequencer that stamps Talk events with stable session context and monotonic ids. + */ export function createTalkEventSequencer( context: TalkEventContext, options: { now?: () => Date | string } = {}, diff --git a/src/talk/talk-session-controller.ts b/src/talk/talk-session-controller.ts index cc878ba1d362..6ddbe1b44a7d 100644 --- a/src/talk/talk-session-controller.ts +++ b/src/talk/talk-session-controller.ts @@ -10,26 +10,44 @@ import { type TalkTransport, } from "./talk-events.js"; +/** + * Why a turn-scoped Talk operation could not emit an event. + */ export type TalkTurnFailureReason = "no_active_turn" | "stale_turn"; +/** + * Successful turn operation with the emitted Talk event. + */ export type TalkTurnSuccess = { event: TalkEvent; ok: true; turnId: string; }; +/** + * Failed turn operation when the requested turn does not match controller state. + */ export type TalkTurnFailure = { ok: false; reason: TalkTurnFailureReason; }; +/** + * Result for ending or cancelling an active Talk turn. + */ export type TalkTurnResult = TalkTurnSuccess | TalkTurnFailure; +/** + * Result for operations that ensure a turn exists and may emit a start event. + */ export type TalkEnsureTurnResult = { event?: TalkEvent; turnId: string; }; +/** + * Stateful Talk event controller for one session's turns, output audio, and recent event buffer. + */ export type TalkSessionController = { readonly activeTurnId: string | undefined; readonly context: TalkEventContext; @@ -45,11 +63,17 @@ export type TalkSessionController = { startOutputAudio(params?: { payload?: unknown; turnId?: string }): TalkEnsureTurnResult; }; +/** + * Session context plus controller retention settings. + */ export type TalkSessionControllerParams = TalkEventContext & { maxRecentEvents?: number; turnIdPrefix?: string; }; +/** + * Optional controller hooks and sequencer overrides for tests and observers. + */ export type TalkSessionControllerOptions = { now?: () => Date | string; onEvent?: (event: TalkEvent) => void; @@ -60,6 +84,9 @@ function defaultTalkEventPayload(payload: unknown): unknown { return payload === undefined ? {} : payload; } +/** + * Creates a per-session Talk controller that emits correlated turn and output-audio events. + */ export function createTalkSessionController( params: TalkSessionControllerParams, options: TalkSessionControllerOptions = {}, @@ -72,6 +99,8 @@ export function createTalkSessionController( let turnSeq = 0; const remember = (event: TalkEvent): TalkEvent => { + // Keep only recent events for diagnostics; the authoritative transcript lives with + // downstream observers/loggers, so this bounded buffer must not grow with session length. recentEvents.push(event as TalkEvent); if (recentEvents.length > maxRecentEvents) { recentEvents.splice(0, recentEvents.length - maxRecentEvents); @@ -89,6 +118,7 @@ export function createTalkSessionController( }; const resolveActiveTurn = (requestedTurnId: string | undefined): string | TalkTurnFailure => { + // Caller-supplied turn ids protect async output callbacks from closing a newer turn. if (!activeTurnId) { return { ok: false, reason: "no_active_turn" }; } @@ -185,6 +215,8 @@ export function createTalkSessionController( startOutputAudio(paramsForOutput = {}) { const turn = ensureTurn({ turnId: paramsForOutput.turnId, payload: {} }); if (outputAudioActive) { + // Providers can emit duplicate start notifications; return the active turn without + // emitting a second start event so observers see one output-audio span. return { turnId: turn.turnId }; } outputAudioActive = true; @@ -200,6 +232,9 @@ export function createTalkSessionController( }; } +/** + * Normalizes legacy realtime transport names into Talk transport families. + */ export function normalizeTalkTransport(value: string | undefined): string | undefined { const normalized = normalizeOptionalString(value); if (!normalized) { diff --git a/src/talk/turn-context-tracker.ts b/src/talk/turn-context-tracker.ts index 07834b8e69bb..58ea7681ca33 100644 --- a/src/talk/turn-context-tracker.ts +++ b/src/talk/turn-context-tracker.ts @@ -1,6 +1,9 @@ const DEFAULT_REALTIME_VOICE_TURN_CONTEXT_LIMIT = 32; const DEFAULT_REALTIME_VOICE_IGNORED_CONTEXT_TTL_MS = 10_000; +/** + * Retention and clock controls for realtime voice turn context tracking. + */ export type RealtimeVoiceTurnContextTrackerOptions = { limit?: number; ignoredContextTtlMs?: number; @@ -8,6 +11,9 @@ export type RealtimeVoiceTurnContextTrackerOptions = { deferUntilAudio?: boolean; }; +/** + * Mutable handle for a single realtime voice turn and caller-owned per-turn metadata. + */ export type RealtimeVoiceTurnContextHandle< TContext, TExtra extends object = Record, @@ -24,6 +30,9 @@ type RealtimeVoiceTurnContextOpenArgs = keyof TExtra exte ? [extra?: TExtra] : [extra: TExtra]; +/** + * Tracks which realtime voice turn context should be attached to the next audio-bearing response. + */ export type RealtimeVoiceTurnContextTracker< TContext, TExtra extends object = Record, @@ -43,6 +52,8 @@ export type RealtimeVoiceTurnContextTracker< clear(): void; }; +// Ignored context is kept outside the turn queue so one discarded response can still be +// correlated if provider audio arrives just after the response was cancelled. type RecentIgnoredContext = { context: TContext; createdAt: number; @@ -64,6 +75,8 @@ export function createRealtimeVoiceTurnContextTracker< const turns: RealtimeVoiceTurnContextHandle[] = []; let recentIgnoredContext: RecentIgnoredContext | undefined; let nextId = 0; + // Handles are mutable and can be passed around; the private owner mark prevents a + // different tracker from closing or consuming another tracker's turn state. const owner = Symbol("realtimeVoiceTurnContextTracker"); const now = options.now ?? Date.now; const limit = normalizeNonNegativeInteger( @@ -77,6 +90,8 @@ export function createRealtimeVoiceTurnContextTracker< const deferUntilAudio = options.deferUntilAudio === true; const prune = () => { + // Silent closed turns have no audio to correlate, so keeping them would only push out + // useful audio-bearing turns under the bounded retention limit. for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (turn?.closed && !turn.hasAudio) { @@ -90,6 +105,8 @@ export function createRealtimeVoiceTurnContextTracker< }; const expireClosedTurnsBeforeLaterAudio = () => { + // Once later audio exists, an older closed audio turn cannot be the active response. + // Drop it before reads so callers do not attach stale context to fresh provider audio. let hasLaterAudio = false; for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; @@ -177,6 +194,7 @@ export function createRealtimeVoiceTurnContextTracker< if (context === undefined) { return; } + // Preserve falsy but valid contexts, such as numeric zero or an empty string. recentIgnoredContext = { context, createdAt: now() }; }, consumeIgnoredContext() { diff --git a/src/tasks/agent-harness-task-runtime-scope.ts b/src/tasks/agent-harness-task-runtime-scope.ts index 433769557847..e86c32285a04 100644 --- a/src/tasks/agent-harness-task-runtime-scope.ts +++ b/src/tasks/agent-harness-task-runtime-scope.ts @@ -3,6 +3,7 @@ import type { DeliveryContext } from "../utils/delivery-context.types.js"; const scopeRegistryKey = Symbol.for("openclaw.agentHarnessTaskRuntimeScope.registry"); +// Host-issued scopes prevent plugins from fabricating requester ownership for task runs. type ScopeRegistry = { hostIssuedScopes: WeakSet; }; @@ -24,6 +25,7 @@ export type AgentHarnessTaskRuntimeScope = { readonly requesterOrigin?: DeliveryContext; }; +/** Creates a host-issued task runtime scope for agent harness task execution. */ export function createAgentHarnessTaskRuntimeScope(params: { requesterSessionKey: string; requesterOrigin?: DeliveryContext; diff --git a/src/tasks/codex-native-subagent-task.ts b/src/tasks/codex-native-subagent-task.ts index 5a04875a84a3..52cb2bc21165 100644 --- a/src/tasks/codex-native-subagent-task.ts +++ b/src/tasks/codex-native-subagent-task.ts @@ -1,10 +1,12 @@ import type { TaskRecord } from "./task-registry.types.js"; +/** Runtime label used for Codex-native subagent task records. */ export const CODEX_NATIVE_SUBAGENT_RUNTIME = "subagent"; export const CODEX_NATIVE_SUBAGENT_TASK_KIND = "codex-native"; export const CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX = "codex-thread:"; export const CODEX_NATIVE_SUBAGENT_STALE_ERROR = "Codex native subagent stopped reporting progress"; +/** Detects native Codex subagent tasks that have no child session to recover from. */ export function isChildlessCodexNativeSubagentTask(task: TaskRecord): boolean { if ( task.runtime !== CODEX_NATIVE_SUBAGENT_RUNTIME || diff --git a/src/tasks/detached-task-runtime-state.ts b/src/tasks/detached-task-runtime-state.ts index 5b47005d7bb1..c9f21d5b66af 100644 --- a/src/tasks/detached-task-runtime-state.ts +++ b/src/tasks/detached-task-runtime-state.ts @@ -5,8 +5,10 @@ import type { export type { DetachedTaskLifecycleRuntime, DetachedTaskLifecycleRuntimeRegistration }; +// Process-wide detached task runtime registration, owned by plugin activation. let detachedTaskLifecycleRuntimeRegistration: DetachedTaskLifecycleRuntimeRegistration | undefined; +/** Registers the active detached task lifecycle runtime implementation. */ export function registerDetachedTaskLifecycleRuntime( pluginId: string, runtime: DetachedTaskLifecycleRuntime, diff --git a/src/tasks/detached-task-runtime.ts b/src/tasks/detached-task-runtime.ts index 254130b0d74c..c6943799b16f 100644 --- a/src/tasks/detached-task-runtime.ts +++ b/src/tasks/detached-task-runtime.ts @@ -30,6 +30,7 @@ const DETACHED_TASK_RECOVERY_WARN_MS = 5_000; export type { DetachedTaskLifecycleRuntime, DetachedTaskLifecycleRuntimeRegistration }; +// Default runtime keeps detached task APIs usable before plugins install custom lifecycle hooks. const DEFAULT_DETACHED_TASK_LIFECYCLE_RUNTIME: DetachedTaskLifecycleRuntime = { createQueuedTaskRun: createQueuedTaskRunFromExecutor, createRunningTaskRun: createRunningTaskRunFromExecutor, @@ -138,6 +139,7 @@ export async function tryRecoverTaskBeforeMarkLost( } const startedAt = Date.now(); try { + // Recovery hooks are best-effort; invalid/slow/failing hooks must not block mark-lost cleanup. const result = await hook(params); const elapsedMs = Date.now() - startedAt; if (elapsedMs >= DETACHED_TASK_RECOVERY_WARN_MS) { diff --git a/src/tasks/import-boundary.test-helpers.ts b/src/tasks/import-boundary.test-helpers.ts index e2ccbef967e0..5cbb82910068 100644 --- a/src/tasks/import-boundary.test-helpers.ts +++ b/src/tasks/import-boundary.test-helpers.ts @@ -5,6 +5,7 @@ const TASK_ROOT = path.resolve(import.meta.dirname); const TASK_BOUNDARY_SRC_ROOT = path.resolve(TASK_ROOT, ".."); +/** Converts source paths to stable task-boundary test paths. */ export function toTaskBoundaryRelativePath(file: string, root = TASK_BOUNDARY_SRC_ROOT): string { return path.relative(root, file).replaceAll(path.sep, "/"); } diff --git a/src/tasks/runtime-internal.ts b/src/tasks/runtime-internal.ts index 3d0ed7a080e4..0b07a3f96db5 100644 --- a/src/tasks/runtime-internal.ts +++ b/src/tasks/runtime-internal.ts @@ -1,3 +1,4 @@ +// Internal task registry facade used by runtime modules without exposing public SDK surface. export { cancelTaskById, createTaskRecord, diff --git a/src/tasks/task-completion-contract.ts b/src/tasks/task-completion-contract.ts index 2a46595e57fc..788ce95e9622 100644 --- a/src/tasks/task-completion-contract.ts +++ b/src/tasks/task-completion-contract.ts @@ -1,5 +1,6 @@ import type { TaskTerminalOutcome } from "./task-registry.types.js"; +/** Terminal fields required when a mandatory detached task completion is invalid. */ export type RequiredCompletionTerminalResult = { terminalOutcome?: Extract; terminalSummary?: string; diff --git a/src/tasks/task-domain-views.ts b/src/tasks/task-domain-views.ts index 1ea7fdb5cfb5..f626df33f23b 100644 --- a/src/tasks/task-domain-views.ts +++ b/src/tasks/task-domain-views.ts @@ -9,6 +9,7 @@ import type { TaskFlowRecord } from "./task-flow-registry.types.js"; import { summarizeTaskRecords } from "./task-registry.summary.js"; import type { TaskRecord, TaskRegistrySummary } from "./task-registry.types.js"; +/** Maps internal task summary counts to the plugin task-domain view contract. */ export function mapTaskRunAggregateSummary(summary: TaskRegistrySummary): TaskRunAggregateSummary { return { total: summary.total, diff --git a/src/tasks/task-executor-policy.ts b/src/tasks/task-executor-policy.ts index f7c0a4a3fca2..45fc34d28d28 100644 --- a/src/tasks/task-executor-policy.ts +++ b/src/tasks/task-executor-policy.ts @@ -1,6 +1,7 @@ import type { TaskEventRecord, TaskRecord, TaskStatus } from "./task-registry.types.js"; import { formatTaskStatusTitleText, sanitizeTaskStatusText } from "./task-status.js"; +/** Returns whether a task status is terminal for delivery and retention policy. */ export function isTerminalTaskStatus(status: TaskStatus): boolean { return ( status === "succeeded" || diff --git a/src/tasks/task-executor.ts b/src/tasks/task-executor.ts index 8dabc2d1e177..37aafb54bf2e 100644 --- a/src/tasks/task-executor.ts +++ b/src/tasks/task-executor.ts @@ -43,6 +43,7 @@ import type { const log = createSubsystemLogger("tasks/executor"); +// One-task flows give detached ACP/subagent runs a flow handle for status and retry surfaces. function isOneTaskFlowEligible(task: TaskRecord): boolean { if (task.parentFlowId?.trim() || task.scopeKind !== "session") { return false; diff --git a/src/tasks/task-flow-owner-access.ts b/src/tasks/task-flow-owner-access.ts index 6e1b7143236f..9bd00f3961f3 100644 --- a/src/tasks/task-flow-owner-access.ts +++ b/src/tasks/task-flow-owner-access.ts @@ -6,6 +6,7 @@ import { } from "./task-flow-registry.js"; import type { TaskFlowRecord } from "./task-flow-registry.types.js"; +/** Reads a flow only when it belongs to the caller owner key. */ export function getTaskFlowByIdForOwner(params: { flowId: string; callerOwnerKey: string; diff --git a/src/tasks/task-flow-registry.audit.ts b/src/tasks/task-flow-registry.audit.ts index d53b4a51ab4d..2eb64b0db822 100644 --- a/src/tasks/task-flow-registry.audit.ts +++ b/src/tasks/task-flow-registry.audit.ts @@ -3,6 +3,7 @@ import { getTaskFlowRegistryRestoreFailure, listTaskFlowRecords } from "./task-f import type { TaskFlowRecord } from "./task-flow-registry.types.js"; import type { TaskRecord } from "./task-registry.types.js"; +/** Severity used by task-flow registry audit findings. */ export type TaskFlowAuditSeverity = "warn" | "error"; export type TaskFlowAuditCode = | "restore_failed" diff --git a/src/tasks/task-flow-registry.maintenance.ts b/src/tasks/task-flow-registry.maintenance.ts index d403d03cfba2..90d8eba32eb7 100644 --- a/src/tasks/task-flow-registry.maintenance.ts +++ b/src/tasks/task-flow-registry.maintenance.ts @@ -14,6 +14,7 @@ import type { TaskFlowRecord } from "./task-flow-registry.types.js"; const TASK_FLOW_RETENTION_MS = 7 * 24 * 60 * 60_000; +/** Counts task-flow registry maintenance actions without exposing individual records. */ export type TaskFlowRegistryMaintenanceSummary = { reconciled: number; pruned: number; diff --git a/src/tasks/task-flow-registry.store.sqlite.ts b/src/tasks/task-flow-registry.store.sqlite.ts index 025f4c2a7835..defb4e1b3c22 100644 --- a/src/tasks/task-flow-registry.store.sqlite.ts +++ b/src/tasks/task-flow-registry.store.sqlite.ts @@ -32,6 +32,7 @@ type FlowRegistryDatabase = { path: string; }; +// SQLite-backed task-flow store mirrors the in-process registry into openclaw-state.db. let cachedDatabase: FlowRegistryDatabase | null = null; function normalizeNumber(value: number | bigint | null): number | undefined { @@ -57,6 +58,7 @@ function parseJsonValue(raw: string | null): JsonValue | undefined { } function rowToSyncMode(row: FlowRegistryRow): TaskFlowSyncMode { + // Older single_task rows did not persist sync_mode; preserve their mirrored semantics. const syncMode = parseOptionalTaskFlowSyncMode(row.sync_mode); if (syncMode) { return syncMode; diff --git a/src/tasks/task-flow-registry.store.types.ts b/src/tasks/task-flow-registry.store.types.ts index 19837f987080..8ce5e7869a74 100644 --- a/src/tasks/task-flow-registry.store.types.ts +++ b/src/tasks/task-flow-registry.store.types.ts @@ -1,5 +1,6 @@ import type { TaskFlowRecord } from "./task-flow-registry.types.js"; +/** Full task-flow registry snapshot used for persistence restore and replacement writes. */ export type TaskFlowRegistryStoreSnapshot = { flows: Map; }; diff --git a/src/tasks/task-flow-registry.types.ts b/src/tasks/task-flow-registry.types.ts index a0d2d458a019..bf6d48dcdb81 100644 --- a/src/tasks/task-flow-registry.types.ts +++ b/src/tasks/task-flow-registry.types.ts @@ -1,6 +1,7 @@ import type { DeliveryContext } from "../utils/delivery-context.types.js"; import type { TaskNotifyPolicy } from "./task-registry.types.js"; +/** JSON value shape persisted with task-flow state and wait metadata. */ export type JsonValue = | null | boolean @@ -11,6 +12,7 @@ export type JsonValue = export type TaskFlowSyncMode = "task_mirrored" | "managed"; +/** Lifecycle status for multi-step task flows. */ export type TaskFlowStatus = | "queued" | "running" diff --git a/src/tasks/task-flow-runtime-internal.ts b/src/tasks/task-flow-runtime-internal.ts index 57b75975f62a..24671b49045e 100644 --- a/src/tasks/task-flow-runtime-internal.ts +++ b/src/tasks/task-flow-runtime-internal.ts @@ -1,3 +1,4 @@ +// Internal task-flow registry facade for runtime modules. export { createTaskFlowForTask, createManagedTaskFlow, diff --git a/src/tasks/task-registry-control.runtime.ts b/src/tasks/task-registry-control.runtime.ts index b4ee4f13d06c..4795b7bd3dcf 100644 --- a/src/tasks/task-registry-control.runtime.ts +++ b/src/tasks/task-registry-control.runtime.ts @@ -1,2 +1,3 @@ +// Runtime control seam for cancelling ACP sessions and subagent runs from task APIs. export { getAcpSessionManager } from "../acp/control-plane/manager.js"; export { killSubagentRunAdmin } from "../agents/subagent-control.js"; diff --git a/src/tasks/task-registry-control.types.ts b/src/tasks/task-registry-control.types.ts index 05a6937b553d..ae51dc8180dd 100644 --- a/src/tasks/task-registry-control.types.ts +++ b/src/tasks/task-registry-control.types.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Admin cancellation hook for ACP sessions owned by task records. */ export type CancelAcpSessionAdmin = (params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/tasks/task-registry-delivery-runtime.ts b/src/tasks/task-registry-delivery-runtime.ts index 0ffc31c2431f..21edf0b524e7 100644 --- a/src/tasks/task-registry-delivery-runtime.ts +++ b/src/tasks/task-registry-delivery-runtime.ts @@ -1 +1,2 @@ +// Runtime delivery seam for task terminal/state-change notifications. export { sendMessage } from "../infra/outbound/message.js"; diff --git a/src/tasks/task-registry.audit.shared.ts b/src/tasks/task-registry.audit.shared.ts index 31383fa4bef4..7e0f0e0745ca 100644 --- a/src/tasks/task-registry.audit.shared.ts +++ b/src/tasks/task-registry.audit.shared.ts @@ -1,5 +1,6 @@ import type { TaskRecord } from "./task-registry.types.js"; +/** Severity used by task registry audit findings. */ export type TaskAuditSeverity = "warn" | "error"; export type TaskAuditCode = | "stale_queued" diff --git a/src/tasks/task-registry.audit.ts b/src/tasks/task-registry.audit.ts index f65c2be59ac2..788aaf15d10a 100644 --- a/src/tasks/task-registry.audit.ts +++ b/src/tasks/task-registry.audit.ts @@ -28,6 +28,7 @@ export type { TaskAuditCode, TaskAuditFinding, TaskAuditSeverity, TaskAuditSumma let taskAuditTaskProvider: () => TaskRecord[] = () => []; +/** Installs the task source used by inspectable task audits. */ export function configureTaskAuditTaskProvider(provider: () => TaskRecord[]): void { taskAuditTaskProvider = provider; } diff --git a/src/tasks/task-registry.process-state.ts b/src/tasks/task-registry.process-state.ts index f59714eec7d1..e3856afc6261 100644 --- a/src/tasks/task-registry.process-state.ts +++ b/src/tasks/task-registry.process-state.ts @@ -1,5 +1,6 @@ import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js"; +/** Process-local indexes backing task lookup, owner access, and pending delivery scans. */ export type TaskRegistryProcessState = { tasks: Map; taskDeliveryStates: Map; @@ -12,6 +13,7 @@ export type TaskRegistryProcessState = { const TASK_REGISTRY_PROCESS_STATE_KEY = Symbol.for("openclaw.taskRegistry.state"); +/** Returns the singleton in-process task registry state. */ export function getTaskRegistryProcessState(): TaskRegistryProcessState { const globalState = globalThis as typeof globalThis & { [TASK_REGISTRY_PROCESS_STATE_KEY]?: TaskRegistryProcessState; diff --git a/src/tasks/task-registry.reconcile.ts b/src/tasks/task-registry.reconcile.ts index 276009cbe865..7d20976dd682 100644 --- a/src/tasks/task-registry.reconcile.ts +++ b/src/tasks/task-registry.reconcile.ts @@ -1,3 +1,4 @@ +// Public reconciliation facade for task lookup/status surfaces. export { reconcileInspectableTasks, reconcileTaskLookupToken, diff --git a/src/tasks/task-registry.store.sqlite.ts b/src/tasks/task-registry.store.sqlite.ts index b6b18b3e1f1a..7d7bbdc2c507 100644 --- a/src/tasks/task-registry.store.sqlite.ts +++ b/src/tasks/task-registry.store.sqlite.ts @@ -43,6 +43,7 @@ type TaskRegistryDatabase = { path: string; }; +// SQLite-backed task store mirrors task records and delivery state into openclaw-state.db. const TASK_RUN_SELECT_COLUMNS = [ "task_id", "runtime", @@ -92,6 +93,7 @@ function rowToTaskRecord(row: TaskRegistryRow): TaskRecord { const cleanupAfter = normalizeNumber(row.cleanup_after); const scopeKind = parseTaskScopeKind(row.scope_kind); const terminalOutcome = parseOptionalTaskTerminalOutcome(row.terminal_outcome); + // System tasks intentionally have no requester session; ownerKey is the lookup anchor. const requesterSessionKey = scopeKind === "system" ? "" : row.requester_session_key?.trim() || row.owner_key; return { diff --git a/src/tasks/task-registry.store.types.ts b/src/tasks/task-registry.store.types.ts index 8c9fd3e1ab3c..731e604bb195 100644 --- a/src/tasks/task-registry.store.types.ts +++ b/src/tasks/task-registry.store.types.ts @@ -1,5 +1,6 @@ import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js"; +/** Full task registry snapshot used for persistence restore and replacement writes. */ export type TaskRegistryStoreSnapshot = { tasks: Map; deliveryStates: Map; diff --git a/src/tasks/task-registry.summary.ts b/src/tasks/task-registry.summary.ts index 9589f888700b..e1b94e08bcfa 100644 --- a/src/tasks/task-registry.summary.ts +++ b/src/tasks/task-registry.summary.ts @@ -5,6 +5,7 @@ import type { TaskStatusCounts, } from "./task-registry.types.js"; +// Summary helpers keep task status/runtime counters stable for UI and plugin views. function createEmptyTaskStatusCounts(): TaskStatusCounts { return { queued: 0, diff --git a/src/tasks/task-registry.types.ts b/src/tasks/task-registry.types.ts index 20bcdb7d01bd..0047488acca6 100644 --- a/src/tasks/task-registry.types.ts +++ b/src/tasks/task-registry.types.ts @@ -1,5 +1,6 @@ import type { DeliveryContext } from "../utils/delivery-context.types.js"; +/** Runtime family that owns a task run lifecycle. */ export type TaskRuntime = "subagent" | "acp" | "cli" | "cron"; export type TaskStatus = @@ -21,6 +22,7 @@ export type TaskDeliveryStatus = export type TaskNotifyPolicy = "done_only" | "state_changes" | "silent"; +/** Semantic success detail for required-completion task outcomes. */ export type TaskTerminalOutcome = "succeeded" | "blocked"; export type TaskScopeKind = "session" | "system"; diff --git a/src/tasks/task-retention.ts b/src/tasks/task-retention.ts index cd1e688e1f8a..9069191a7d49 100644 --- a/src/tasks/task-retention.ts +++ b/src/tasks/task-retention.ts @@ -1,5 +1,6 @@ import type { TaskRecord, TaskStatus } from "./task-registry.types.js"; +/** Default retention for terminal task records before maintenance prunes them. */ export const DEFAULT_TASK_RETENTION_MS = 7 * 24 * 60 * 60_000; export const LOST_TASK_RETENTION_MS = 24 * 60 * 60_000; diff --git a/src/tasks/task-status-access.ts b/src/tasks/task-status-access.ts index 89eaf32e9978..ce819f5a6c4e 100644 --- a/src/tasks/task-status-access.ts +++ b/src/tasks/task-status-access.ts @@ -7,6 +7,7 @@ import { } from "./task-registry.js"; import type { TaskRecord } from "./task-registry.types.js"; +/** Returns only the session lookup fields needed by task status commands. */ export function getTaskSessionLookupByIdForStatus( taskId: string, ): Pick | undefined { diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index 54eaa421b70b..0a0f7433e86e 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -8,6 +8,7 @@ import type { TaskRecord } from "./task-registry.types.js"; const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const FAILURE_TASK_STATUSES = new Set(["failed", "timed_out", "lost"]); +/** Window for showing recently completed tasks in compact status output. */ export const TASK_STATUS_RECENT_WINDOW_MS = 5 * 60_000; export const TASK_STATUS_TITLE_MAX_CHARS = 80; export const TASK_STATUS_DETAIL_MAX_CHARS = 120; @@ -47,6 +48,7 @@ function truncateTaskStatusText(value: string, maxChars: number): string { } function stripInlineLeakedInternalContext(value: string): string { + // Completion text can accidentally include hidden runtime context; strip it before status output. const beginIndex = value.indexOf(INTERNAL_RUNTIME_CONTEXT_BEGIN); if ( beginIndex !== -1 && diff --git a/src/test-helpers/http.ts b/src/test-helpers/http.ts index 2aa6f21ba6ca..439744ad25d7 100644 --- a/src/test-helpers/http.ts +++ b/src/test-helpers/http.ts @@ -1,3 +1,5 @@ +// Minimal HTTP test fixtures for fetch/provider tests. They keep Response and +// Request normalization consistent across tests without pulling in server code. export function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -5,6 +7,7 @@ export function jsonResponse(body: unknown, status = 200): Response { }); } +// Normalize fetch inputs back to a URL string for assertions in mocked fetches. export function requestUrl(input: string | URL | Request): string { if (typeof input === "string") { return input; @@ -15,6 +18,8 @@ export function requestUrl(input: string | URL | Request): string { return input.url; } +// Test helpers only support string request bodies; absent/non-string bodies use +// an empty JSON object so assertions stay deterministic. export function requestBodyText(body: BodyInit | null | undefined): string { return typeof body === "string" ? body : "{}"; } diff --git a/src/test-helpers/network-interfaces.ts b/src/test-helpers/network-interfaces.ts index ec8e1a7fb125..9300843c8fcd 100644 --- a/src/test-helpers/network-interfaces.ts +++ b/src/test-helpers/network-interfaces.ts @@ -1,6 +1,7 @@ import os from "node:os"; import type { NetworkInterfacesSnapshot } from "../infra/network-interfaces.js"; +// Builders for deterministic os.networkInterfaces() snapshots in network tests. type NetworkInterfaceEntry = NonNullable[string]>[number]; type NetworkInterfaceEntryInput = { diff --git a/src/test-helpers/resolve-target-error-cases.ts b/src/test-helpers/resolve-target-error-cases.ts index e1dabd29f35f..53e0f672d551 100644 --- a/src/test-helpers/resolve-target-error-cases.ts +++ b/src/test-helpers/resolve-target-error-cases.ts @@ -1,5 +1,7 @@ import { expect, it } from "vitest"; +// Shared resolve-target negative cases used by messaging/channel tests. The +// target resolver shape is intentionally tiny so each channel can adapt it. export type ResolveTargetMode = "explicit" | "implicit" | "heartbeat"; export type ResolveTargetResult = { diff --git a/src/test-helpers/ssrf.ts b/src/test-helpers/ssrf.ts index 5a11e54036ac..5ed86849b8e9 100644 --- a/src/test-helpers/ssrf.ts +++ b/src/test-helpers/ssrf.ts @@ -3,6 +3,8 @@ import { normalizeHostname } from "../infra/net/hostname.js"; import * as ssrf from "../infra/net/ssrf.js"; import type { LookupFn } from "../infra/net/ssrf.js"; +// Pin SSRF hostname resolution to deterministic addresses for tests that must +// exercise policy checks without depending on live DNS. export function mockPinnedHostnameResolution(addresses: string[] = ["93.184.216.34"]) { const resolvePinnedHostname = ssrf.resolvePinnedHostname; const resolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; diff --git a/src/test-helpers/state-dir-env.ts b/src/test-helpers/state-dir-env.ts index ad0b5ec5f3ff..92d87286ec3c 100644 --- a/src/test-helpers/state-dir-env.ts +++ b/src/test-helpers/state-dir-env.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { captureEnv } from "../test-utils/env.js"; import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; +// OPENCLAW_STATE_DIR test helpers isolate stateful tests and restore the caller +// environment even when session cleanup fails. export function snapshotStateDirEnv() { return captureEnv(["OPENCLAW_STATE_DIR"]); } @@ -28,6 +30,8 @@ export async function withStateDirEnv( try { return await fn({ tempRoot, stateDir }); } finally { + // Session state cleanup may race with assertions in failing tests; never let + // that cleanup failure hide the original test error or skip env restoration. await cleanupSessionStateForTest().catch(() => undefined); restoreStateDirEnv(snapshot); await fs.rm(tempRoot, { recursive: true, force: true }); diff --git a/src/test-helpers/temp-dir.ts b/src/test-helpers/temp-dir.ts index 30d77c9e0811..55b92af95eff 100644 --- a/src/test-helpers/temp-dir.ts +++ b/src/test-helpers/temp-dir.ts @@ -3,6 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +// Temp-dir helpers share one mkdtemp root per suite prefix and hand out numbered +// case dirs. That reduces filesystem churn while preserving per-test cleanup. type PrefixRootState = { path: string; activeCount: number; @@ -30,6 +32,8 @@ async function acquireAsyncPrefixRoot(options: { } const pending = pendingAsyncPrefixRoots.get(key); if (pending) { + // Concurrent tests with the same prefix wait for the same root creation + // instead of racing multiple mkdtemp roots. const state = await pending; state.activeCount += 1; return state; @@ -131,6 +135,8 @@ export async function withTempDir( } } +// Suite-level tracker for tests that need a stable root across multiple cases +// while still creating isolated child directories. export function createSuiteTempRootTracker(options: { prefix: string; parentDir?: string }) { let root = ""; let nextIndex = 0; diff --git a/src/test-helpers/windows-cmd-shim.ts b/src/test-helpers/windows-cmd-shim.ts index ce73d0f83984..40a8e09a895c 100644 --- a/src/test-helpers/windows-cmd-shim.ts +++ b/src/test-helpers/windows-cmd-shim.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +// Creates a tiny Windows .cmd shim plus target script for command-resolution +// tests that need to verify shim parsing without invoking npm-installed bins. export async function createWindowsCmdShimFixture(params: { shimPath: string; scriptPath: string; diff --git a/src/test-helpers/workspace.ts b/src/test-helpers/workspace.ts index 6106e492d9dc..6df0bf141414 100644 --- a/src/test-helpers/workspace.ts +++ b/src/test-helpers/workspace.ts @@ -2,10 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +// Workspace fixture helpers for tests that need a real cwd with small files. export async function makeTempWorkspace(prefix = "openclaw-workspace-"): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } +// Write a file under a temp workspace and return its absolute path for callers +// that pass fixture files into CLI/runtime APIs. export async function writeWorkspaceFile(params: { dir: string; name: string; diff --git a/src/test-utils/auth-token-assertions.ts b/src/test-utils/auth-token-assertions.ts index f9a47d559ac6..3512d6e43155 100644 --- a/src/test-utils/auth-token-assertions.ts +++ b/src/test-utils/auth-token-assertions.ts @@ -1,6 +1,7 @@ import { expect } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Asserts the generated Gateway auth token is both returned and persisted. */ export function expectGeneratedTokenPersistedToGatewayAuth(params: { generatedToken?: string; authToken?: string; diff --git a/src/test-utils/camera-url-test-helpers.ts b/src/test-utils/camera-url-test-helpers.ts index 15b6010fc9d0..630b5751294c 100644 --- a/src/test-utils/camera-url-test-helpers.ts +++ b/src/test-utils/camera-url-test-helpers.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import { vi } from "vitest"; import { withFetchPreconnect } from "./fetch-mock.js"; +/** Stubs fetch with the preconnect marker expected by camera URL tests. */ export function stubFetchResponse(response: Response) { vi.stubGlobal("fetch", withFetchPreconnect(vi.fn(async () => response))); } diff --git a/src/test-utils/channel-plugin-test-fixtures.ts b/src/test-utils/channel-plugin-test-fixtures.ts index 7fcc3f396b1a..0b57d5acaebb 100644 --- a/src/test-utils/channel-plugin-test-fixtures.ts +++ b/src/test-utils/channel-plugin-test-fixtures.ts @@ -1,5 +1,6 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +/** Builds the smallest direct-message channel plugin shape used by config tests. */ export function makeDirectPlugin(params: { id: string; label: string; diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 47199668c696..5632a2198b88 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -7,6 +7,7 @@ import type { } from "../channels/plugins/types.public.js"; import type { PluginRegistry } from "../plugins/registry.js"; +/** Registry entry shape used by channel tests without loading real plugins. */ export type TestChannelRegistration = { pluginId: string; plugin: unknown; diff --git a/src/test-utils/chunk-test-helpers.ts b/src/test-utils/chunk-test-helpers.ts index 09b4d5c8a54d..43cc1f2a5f88 100644 --- a/src/test-utils/chunk-test-helpers.ts +++ b/src/test-utils/chunk-test-helpers.ts @@ -1,3 +1,4 @@ +/** Markdown chunk helpers shared by prompt and streaming tests. */ export function countLines(text: string): number { return text.split("\n").length; } diff --git a/src/test-utils/command-runner.ts b/src/test-utils/command-runner.ts index 82f6182d3c3b..b0c2e8574b13 100644 --- a/src/test-utils/command-runner.ts +++ b/src/test-utils/command-runner.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; +/** Runs a CLI registrar against Commander using user-style argv. */ export async function runRegisteredCli(params: { register: (program: Command) => void; argv: string[]; diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts index b57a9829aa6a..f193ae31d0b3 100644 --- a/src/test-utils/env.ts +++ b/src/test-utils/env.ts @@ -1,5 +1,6 @@ import path from "node:path"; +/** Captures selected process.env keys so tests can restore exact prior state. */ export function captureEnv(keys: string[]) { const snapshot = new Map(); for (const key of keys) { @@ -40,6 +41,7 @@ const PATH_RESOLUTION_ENV_KEYS = [ "OPENCLAW_DISABLE_BUNDLED_PLUGINS", ] as const; +// Windows home resolution depends on split drive/path env vars, not only HOME. function resolveWindowsHomeParts(homeDir: string): { homeDrive?: string; homePath?: string } { if (process.platform !== "win32") { return {}; diff --git a/src/test-utils/exec-assertions.ts b/src/test-utils/exec-assertions.ts index 98092e50e7a9..4f0ba821d007 100644 --- a/src/test-utils/exec-assertions.ts +++ b/src/test-utils/exec-assertions.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { expect } from "vitest"; +// macOS exposes /tmp through /private/var; normalize both spellings for assertions. function normalizeDarwinTmpPath(filePath: string): string { return process.platform === "darwin" && filePath.startsWith("/private/var/") ? filePath.slice("/private".length) @@ -17,6 +18,7 @@ function canonicalizeComparableDir(dirPath: string): string { } } +/** Verifies secure npm install staging uses ignore-scripts and the expected target parent. */ export function expectSingleNpmInstallIgnoreScriptsCall(params: { calls: Array<[unknown, { cwd?: string } | undefined]>; expectedTargetDir: string; diff --git a/src/test-utils/fetch-mock.ts b/src/test-utils/fetch-mock.ts index 2d387c8052c9..69ef1806678b 100644 --- a/src/test-utils/fetch-mock.ts +++ b/src/test-utils/fetch-mock.ts @@ -1,3 +1,4 @@ +/** Fetch mock shape used by tests that replace global fetch. */ export type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise; type FetchPreconnectOptions = { diff --git a/src/test-utils/fixture-suite.ts b/src/test-utils/fixture-suite.ts index 0a3a9498b407..85df3cbdaab0 100644 --- a/src/test-utils/fixture-suite.ts +++ b/src/test-utils/fixture-suite.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +/** Creates a temp fixture root with deterministic per-case subdirectories. */ export function createFixtureSuite(rootPrefix: string) { let fixtureRoot = ""; let fixtureCount = 0; diff --git a/src/test-utils/frozen-time.ts b/src/test-utils/frozen-time.ts index f5e626fad212..e586d9325cd0 100644 --- a/src/test-utils/frozen-time.ts +++ b/src/test-utils/frozen-time.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; +/** Freezes Vitest's fake clock for tests that assert timestamps or timers. */ export function useFrozenTime(at: string | number | Date): void { vi.useFakeTimers(); vi.setSystemTime(at); diff --git a/src/test-utils/fs-scan-assertions.ts b/src/test-utils/fs-scan-assertions.ts index 4b311373514c..4a7b780da09b 100644 --- a/src/test-utils/fs-scan-assertions.ts +++ b/src/test-utils/fs-scan-assertions.ts @@ -9,6 +9,7 @@ type NodeFsScanResult = { result: T; }; +/** Asserts a synchronous block did not enumerate directories. */ export function expectNoReaddirSyncDuring(run: () => T): T { return expectNoFsSyncDuring(run, ["readdirSync"]); } diff --git a/src/test-utils/generation-live-test-helpers.ts b/src/test-utils/generation-live-test-helpers.ts index aa534ebbc2c3..00e66c7184e1 100644 --- a/src/test-utils/generation-live-test-helpers.ts +++ b/src/test-utils/generation-live-test-helpers.ts @@ -1,6 +1,7 @@ import { loadShellEnvFallback } from "../infra/shell-env.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; +/** Loads shell env only when a live generation provider declares missing key names. */ export function maybeLoadShellEnvForGenerationProviders(providerIds: string[]): void { const expectedKeys = [ ...new Set(providerIds.flatMap((providerId) => getProviderEnvVars(providerId))), diff --git a/src/test-utils/internal-hook-event-payload.ts b/src/test-utils/internal-hook-event-payload.ts index 68fe0fbea835..ceec6193e5a6 100644 --- a/src/test-utils/internal-hook-event-payload.ts +++ b/src/test-utils/internal-hook-event-payload.ts @@ -1,3 +1,4 @@ +/** Builds a stable internal hook event payload for tests that do not need full messages. */ export function createInternalHookEventPayload( type: string, action: string, diff --git a/src/test-utils/mock-call-assertions.ts b/src/test-utils/mock-call-assertions.ts index a044743c6a0b..db45cf60f3c2 100644 --- a/src/test-utils/mock-call-assertions.ts +++ b/src/test-utils/mock-call-assertions.ts @@ -1,5 +1,6 @@ import { expect } from "vitest"; +/** Returns a mock call with a useful failure when the call is missing. */ export function mockCall(mock: unknown, index = 0): Array { const calls = (mock as { mock?: { calls?: Array> } }).mock?.calls ?? []; const call = calls.at(index); diff --git a/src/test-utils/mock-http-response.ts b/src/test-utils/mock-http-response.ts index cc0a3192740e..82727f620568 100644 --- a/src/test-utils/mock-http-response.ts +++ b/src/test-utils/mock-http-response.ts @@ -1,6 +1,7 @@ import type { ServerResponse } from "node:http"; import { lowercasePreservingWhitespace } from "@openclaw/normalization-core/string-coerce"; +/** Minimal ServerResponse double for route tests that inspect headers and body. */ export function createMockServerResponse(): ServerResponse & { body?: string } { const headers: Record = {}; const res: { diff --git a/src/test-utils/node-process.ts b/src/test-utils/node-process.ts index 8544844bcebd..6be3095a76e5 100644 --- a/src/test-utils/node-process.ts +++ b/src/test-utils/node-process.ts @@ -15,6 +15,7 @@ type SpawnNodeEvalOptions = Omit[2]>, " encoding?: BufferEncoding; }; +/** Builds node args for ESM eval snippets used by subprocess boundary tests. */ export function createNodeEvalArgs(source: string, options: NodeEvalArgsOptions = {}): string[] { const args = (options.imports ?? []).flatMap((specifier) => ["--import", specifier]); args.push("--input-type=module", options.evalFlag ?? "--eval", source); diff --git a/src/test-utils/npm-spec-install-test-helpers.ts b/src/test-utils/npm-spec-install-test-helpers.ts index c500a7459c27..a582559b7809 100644 --- a/src/test-utils/npm-spec-install-test-helpers.ts +++ b/src/test-utils/npm-spec-install-test-helpers.ts @@ -25,6 +25,7 @@ type NpmViewMetadata = { shasum?: string; }; +// Keep spawn doubles shaped like the real process helper so install tests stay narrow. function createSuccessfulSpawnResult(stdout = ""): SpawnResult { return { code: 0, @@ -36,6 +37,7 @@ function createSuccessfulSpawnResult(stdout = ""): SpawnResult { }; } +/** Mocks npm view JSON metadata for package install validation tests. */ export function mockNpmViewMetadataResult( run: { mockImplementation: ( diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts index ba37d4f14ffd..5f08daf58092 100644 --- a/src/test-utils/plugin-registration.ts +++ b/src/test-utils/plugin-registration.ts @@ -11,6 +11,7 @@ import type { VideoGenerationProviderPlugin, } from "../plugins/types.js"; +/** Captured registration helpers for provider plugin tests. */ export { createCapturedPluginRegistration }; type RegistrablePlugin = { @@ -28,6 +29,7 @@ export type RegisteredProviderCollections = { modelCatalogProviders: UnifiedModelCatalogProviderPlugin[]; }; +/** Registers one provider plugin callback and returns its first provider. */ export async function registerSingleProviderPlugin(params: { register(api: OpenClawPluginApi): void; }): Promise { diff --git a/src/test-utils/plugin-runtime-env.ts b/src/test-utils/plugin-runtime-env.ts index 8968513cd1d4..aae3e5be802f 100644 --- a/src/test-utils/plugin-runtime-env.ts +++ b/src/test-utils/plugin-runtime-env.ts @@ -5,6 +5,7 @@ type RuntimeEnvOptions = { throwOnExit?: boolean; }; +/** Creates a plugin runtime env with test-safe defaults and optional exit throwing. */ export function createRuntimeEnv(options?: RuntimeEnvOptions): OutputRuntimeEnv { const throwOnExit = options?.throwOnExit ?? true; return { diff --git a/src/test-utils/plugin-setup-wizard.ts b/src/test-utils/plugin-setup-wizard.ts index b4615264ca10..cdfb5a2c98c8 100644 --- a/src/test-utils/plugin-setup-wizard.ts +++ b/src/test-utils/plugin-setup-wizard.ts @@ -4,6 +4,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createRuntimeEnv } from "./plugin-runtime-env.js"; +/** Wizard prompt doubles shared by plugin setup flow tests. */ export type { WizardPrompter } from "../wizard/prompts.js"; type UnknownMock = Mock<(...args: unknown[]) => unknown>; type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; @@ -101,6 +102,7 @@ type SetupWizardTestPlugin = { config: Record; } & Record; +// Tests pass plugin-like stubs; require the declarative wizard shape before adapting. function isDeclarativeSetupWizard( setupWizard: ChannelPlugin["setupWizard"], ): setupWizard is SetupWizard { diff --git a/src/test-utils/provider-usage-fetch.ts b/src/test-utils/provider-usage-fetch.ts index c20f3c6e5d9e..dd4403ca7966 100644 --- a/src/test-utils/provider-usage-fetch.ts +++ b/src/test-utils/provider-usage-fetch.ts @@ -7,6 +7,7 @@ type UsageFetchMock = ReturnType< typeof vi.fn<(input: UsageFetchInput, init?: RequestInit) => Promise> >; +/** Creates JSON usage-provider responses without depending on a real fetch implementation. */ export function makeResponse(status: number, body: unknown): Response { const payload = typeof body === "string" ? body : JSON.stringify(body); const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; diff --git a/src/test-utils/repo-files.ts b/src/test-utils/repo-files.ts index 2d71a0bb1aeb..b283d3300698 100644 --- a/src/test-utils/repo-files.ts +++ b/src/test-utils/repo-files.ts @@ -3,6 +3,7 @@ import path from "node:path"; const gitTrackedFilesCache = new Map(); +/** Normalizes file paths to repo-style forward slash separators. */ export function toRepoPath(filePath: string): string { return filePath.replaceAll("\\", "/"); } diff --git a/src/test-utils/secret-file-fixture.ts b/src/test-utils/secret-file-fixture.ts index 9b609721002f..6828216fb8b2 100644 --- a/src/test-utils/secret-file-fixture.ts +++ b/src/test-utils/secret-file-fixture.ts @@ -7,6 +7,7 @@ type SecretFiles = { tokenFile?: string; }; +/** Writes temporary secret files for config tests and removes the directory afterward. */ export async function withTempSecretFiles( prefix: string, secrets: { password?: string; token?: string }, diff --git a/src/test-utils/session-conversation-registry.ts b/src/test-utils/session-conversation-registry.ts index 742ebc240a5c..5a62555d0359 100644 --- a/src/test-utils/session-conversation-registry.ts +++ b/src/test-utils/session-conversation-registry.ts @@ -1,6 +1,7 @@ import { parseThreadSessionSuffix } from "../sessions/session-key-utils.js"; import { createTestRegistry } from "./channel-plugins.js"; +// Mirrors generic thread suffix handling without loading real channel plugins. function resolveGenericSessionConversation(params: { rawId: string }) { const parsed = parseThreadSessionSuffix(params.rawId); const id = parsed.baseSessionKey ?? params.rawId; @@ -49,6 +50,7 @@ function resolveFeishuSessionConversation(params: { kind: "group" | "channel"; r }; } +/** Builds channel registry stubs with conversation resolvers for session tests. */ export function createSessionConversationTestRegistry() { return createTestRegistry([ { diff --git a/src/test-utils/session-state-cleanup.ts b/src/test-utils/session-state-cleanup.ts index fba5a7418e17..16ec7e8c1ca9 100644 --- a/src/test-utils/session-state-cleanup.ts +++ b/src/test-utils/session-state-cleanup.ts @@ -8,6 +8,7 @@ let sessionStoreWriterQueueDrainerForTests: typeof drainSessionStoreWriterQueues null; let sessionWriteLockDrainerForTests: typeof drainSessionWriteLockStateForTest | null = null; +/** Overrides cleanup hooks so tests can drain mocked session state modules. */ export function setSessionStateCleanupRuntimeForTests(params: { drainFileLockStateForTest?: typeof drainFileLockStateForTest | null; drainSessionStoreWriterQueuesForTest?: typeof drainSessionStoreWriterQueuesForTest | null; diff --git a/src/test-utils/session-write-lock-module-mock.ts b/src/test-utils/session-write-lock-module-mock.ts index 0d4013ae84e1..2ee42b970e17 100644 --- a/src/test-utils/session-write-lock-module-mock.ts +++ b/src/test-utils/session-write-lock-module-mock.ts @@ -2,6 +2,7 @@ import type * as SessionWriteLockModule from "../agents/session-write-lock.js"; type SessionWriteLockModuleShape = typeof SessionWriteLockModule; +/** Creates a session-write-lock module mock while preserving untouched exports. */ export async function buildSessionWriteLockModuleMock( loadActual: () => Promise, acquireSessionWriteLock: SessionWriteLockModuleShape["acquireSessionWriteLock"], diff --git a/src/test-utils/symlink-rebind-race.ts b/src/test-utils/symlink-rebind-race.ts index f0f381c5f028..30013f654e19 100644 --- a/src/test-utils/symlink-rebind-race.ts +++ b/src/test-utils/symlink-rebind-race.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { vi } from "vitest"; +/** Repoints a symlink or junction to a new target for realpath race tests. */ export async function createRebindableDirectoryAlias(params: { aliasPath: string; targetPath: string; @@ -21,6 +22,7 @@ export async function withRealpathSymlinkRebindRace(params: { }): Promise { const realRealpath = fs.realpath.bind(fs); let flipped = false; + // Flip exactly once around realpath so tests can model TOCTOU behavior deterministically. const realpathSpy = vi .spyOn(fs, "realpath") .mockImplementation(async (...args: Parameters) => { diff --git a/src/test-utils/system-run-prepare-payload.ts b/src/test-utils/system-run-prepare-payload.ts index 201cb829d11d..07cb5b20b584 100644 --- a/src/test-utils/system-run-prepare-payload.ts +++ b/src/test-utils/system-run-prepare-payload.ts @@ -8,6 +8,7 @@ type SystemRunPrepareInput = { sessionKey?: unknown; }; +/** Builds the normalized system-run prepare payload used by approval tests. */ export function buildSystemRunPreparePayload(params: SystemRunPrepareInput) { const argv = Array.isArray(params.command) ? params.command.map(String) : []; const previewCommand = diff --git a/src/test-utils/talk-test-provider.ts b/src/test-utils/talk-test-provider.ts index f78e5fca38b4..b2f98e2f1724 100644 --- a/src/test-utils/talk-test-provider.ts +++ b/src/test-utils/talk-test-provider.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Test-only speech provider identity used by talk config assertions. */ export const TALK_TEST_PROVIDER_ID = "acme-speech"; export const TALK_TEST_PROVIDER_LABEL = "Acme Speech"; export const TALK_TEST_PROVIDER_API_KEY_PATH = `talk.providers.${TALK_TEST_PROVIDER_ID}.apiKey`; diff --git a/src/test-utils/task-registry-runtime.ts b/src/test-utils/task-registry-runtime.ts index c9ccbe16ea7b..401723eb2280 100644 --- a/src/test-utils/task-registry-runtime.ts +++ b/src/test-utils/task-registry-runtime.ts @@ -5,6 +5,7 @@ import { } from "../tasks/task-registry.store.js"; import type { TaskDeliveryState, TaskRecord } from "../tasks/task-registry.types.js"; +// Clone snapshots across load/save so tests catch accidental shared mutation. function cloneTask(task: TaskRecord): TaskRecord { return { ...task }; } @@ -16,6 +17,7 @@ function cloneDeliveryState(state: TaskDeliveryState): TaskDeliveryState { }; } +/** Installs an in-memory task registry store for tests that avoid disk state. */ export function installInMemoryTaskRegistryRuntime(): { taskStore: TaskRegistryStore; } { diff --git a/src/test-utils/temp-dir.ts b/src/test-utils/temp-dir.ts index 0efe486af20e..c82e7416c23c 100644 --- a/src/test-utils/temp-dir.ts +++ b/src/test-utils/temp-dir.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +/** Runs a test body in a temporary directory and removes it afterward. */ export async function withTempDir(prefix: string, run: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); try { diff --git a/src/test-utils/temp-home.ts b/src/test-utils/temp-home.ts index 427c0f80ed20..69905437d59f 100644 --- a/src/test-utils/temp-home.ts +++ b/src/test-utils/temp-home.ts @@ -17,6 +17,7 @@ export type TempHomeEnv = { restore: () => Promise; }; +// Reuse prefix roots to keep temp-home-heavy suites fast without sharing per-test homes. const prefixRoots = new Map(); const pendingPrefixRoots = new Map>(); let nextHomeIndex = 0; @@ -41,6 +42,7 @@ async function ensurePrefixRoot(prefix: string): Promise { } } +/** Creates a temporary OpenClaw home and process env override for stateful tests. */ export async function createTempHomeEnv(prefix: string): Promise { const prefixRoot = await ensurePrefixRoot(prefix); const home = path.join(prefixRoot, `home-${String(nextHomeIndex)}`); diff --git a/src/test-utils/tracked-temp-dirs.ts b/src/test-utils/tracked-temp-dirs.ts index 22e77467adf1..b0a3bd222e3e 100644 --- a/src/test-utils/tracked-temp-dirs.ts +++ b/src/test-utils/tracked-temp-dirs.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +/** Allocates temp directories under reusable roots with explicit cleanup control. */ export function createTrackedTempDirs() { const prefixRoots = new Map(); const pendingPrefixRoots = new Map>(); diff --git a/src/test-utils/typed-cases.ts b/src/test-utils/typed-cases.ts index 41fb0b47b2ac..e3787a6915b8 100644 --- a/src/test-utils/typed-cases.ts +++ b/src/test-utils/typed-cases.ts @@ -1,3 +1,4 @@ +/** Preserves inferred literal case types while returning a plain array. */ export function typedCases(cases: T[]): T[] { return cases; } diff --git a/src/test-utils/vitest-module-mocks.ts b/src/test-utils/vitest-module-mocks.ts index 5fdf6c5a2666..5cd6b91731c2 100644 --- a/src/test-utils/vitest-module-mocks.ts +++ b/src/test-utils/vitest-module-mocks.ts @@ -1,3 +1,4 @@ +/** Merges a real module with per-test Vitest mock overrides. */ export async function mergeMockedModule( actual: TModule, buildOverrides: (actual: TModule) => Partial | Promise>, diff --git a/src/test-utils/vitest-spies.ts b/src/test-utils/vitest-spies.ts index 6554ce51c130..8666fa24bb39 100644 --- a/src/test-utils/vitest-spies.ts +++ b/src/test-utils/vitest-spies.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; +/** Minimal mock contract for helpers that restore spies after a scoped run. */ export type RestorableMock = { mockRestore(): void; }; diff --git a/src/tools/availability.ts b/src/tools/availability.ts index 5d342e83d997..78507197d2a4 100644 --- a/src/tools/availability.ts +++ b/src/tools/availability.ts @@ -9,6 +9,12 @@ import type { ToolDescriptor, } from "./types.js"; +/** + * Tool availability evaluator for descriptor-driven tool planning. + * + * Descriptors express why a tool can be shown as small signals; this module + * turns those signals into diagnostics without knowing any concrete tool owner. + */ function isRecord(value: JsonValue | undefined): value is JsonObject { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -37,6 +43,7 @@ function hasConfiguredValue(params: { return false; } if ((signal.check ?? "exists") === "available") { + // "available" delegates semantic checks, for example provider auth that is configured but stale. return ( params.context.isConfigValueAvailable?.({ value, @@ -142,6 +149,7 @@ function evaluateExpression( ]; } const diagnostics = expression.anyOf.map((entry) => evaluateExpression(entry, context)); + // anyOf is available when at least one branch has no diagnostics; otherwise preserve all reasons. return diagnostics.some((entries) => entries.length === 0) ? [] : diagnostics.flat(); } return [ @@ -152,6 +160,7 @@ function evaluateExpression( ]; } +/** Evaluate one descriptor against runtime context and return hidden-tool diagnostics. */ export function evaluateToolAvailability(params: { descriptor: ToolDescriptor; context?: ToolAvailabilityContext; diff --git a/src/tools/descriptors.ts b/src/tools/descriptors.ts index b843ccb365cf..5493105f4446 100644 --- a/src/tools/descriptors.ts +++ b/src/tools/descriptors.ts @@ -1,9 +1,17 @@ import type { ToolDescriptor } from "./types.js"; +/** + * Identity helpers for authoring tool descriptors with stable inferred types. + * + * Callers use these at declaration sites so descriptor arrays keep readonly + * shapes while still validating against the public ToolDescriptor contract. + */ +/** Define one tool descriptor without changing its runtime shape. */ export function defineToolDescriptor(descriptor: ToolDescriptor): ToolDescriptor { return descriptor; } +/** Define a readonly descriptor list without changing runtime order or entries. */ export function defineToolDescriptors( descriptors: readonly ToolDescriptor[], ): readonly ToolDescriptor[] { diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts index f74495028772..e76456a01995 100644 --- a/src/tools/diagnostics.ts +++ b/src/tools/diagnostics.ts @@ -1,5 +1,13 @@ +/** + * Diagnostics used when descriptor planning violates tool contract invariants. + * + * These are programmer errors, not availability diagnostics, so callers can + * distinguish broken tool registration from intentionally hidden tools. + */ +/** Stable contract error code emitted by the tool planner. */ export type ToolPlanContractErrorCode = "duplicate-tool-name" | "missing-executor"; +/** Error thrown when a visible tool plan cannot be built from descriptors. */ export class ToolPlanContractError extends Error { readonly code: ToolPlanContractErrorCode; readonly toolName: string; diff --git a/src/tools/execution.ts b/src/tools/execution.ts index 1a483af482ac..1ff1b3f3fbe2 100644 --- a/src/tools/execution.ts +++ b/src/tools/execution.ts @@ -1,5 +1,12 @@ import type { ToolExecutorRef } from "./types.js"; +/** + * Formatting helpers for tool executor references. + * + * Executor refs are closed discriminated unions; the formatted string is for + * diagnostics/logging and must not become a parser contract. + */ +/** Render an executor ref as a compact diagnostic label. */ export function formatToolExecutorRef(ref: ToolExecutorRef): string { switch (ref.kind) { case "core": diff --git a/src/tools/index.ts b/src/tools/index.ts index 6a4e4933712d..3dc66545a0cc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,3 +1,9 @@ +/** + * Public barrel for descriptor-driven tool planning. + * + * Runtime owners import this surface to define tools, evaluate availability, + * build visible/hidden plans, and convert descriptors to protocol payloads. + */ export { evaluateToolAvailability } from "./availability.js"; export { defineToolDescriptor, defineToolDescriptors } from "./descriptors.js"; export { ToolPlanContractError } from "./diagnostics.js"; diff --git a/src/tools/planner.ts b/src/tools/planner.ts index 2c7ae30db105..bcd00a95b37c 100644 --- a/src/tools/planner.ts +++ b/src/tools/planner.ts @@ -8,6 +8,12 @@ import type { ToolPlanEntry, } from "./types.js"; +/** + * Deterministic planner for descriptor-backed tools. + * + * The planner sorts descriptors, hides unavailable tools with diagnostics, and + * throws only when visible tool descriptors violate executor/name contracts. + */ function compareDescriptors(left: ToolDescriptor, right: ToolDescriptor): number { return ( (left.sortKey ?? left.name).localeCompare(right.sortKey ?? right.name) || @@ -29,6 +35,7 @@ function assertUniqueNames(descriptors: readonly ToolDescriptor[]): void { } } +/** Build the visible and hidden tool plan for a runtime context. */ export function buildToolPlan(options: BuildToolPlanOptions): ToolPlan { const descriptors = options.descriptors.toSorted(compareDescriptors); assertUniqueNames(descriptors); @@ -45,6 +52,7 @@ export function buildToolPlan(options: BuildToolPlanOptions): ToolPlan { continue; } if (!descriptor.executor) { + // Hidden tools may omit executors; visible tools must be callable after planning. throw new ToolPlanContractError({ code: "missing-executor", toolName: descriptor.name, diff --git a/src/tools/types.ts b/src/tools/types.ts index b774da851030..6e68711524e9 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,24 +1,36 @@ +/** + * Public descriptor contracts for the generic OpenClaw tool planner. + * + * These types keep tool ownership, execution, availability, and protocol + * metadata separate so core, plugins, channels, and MCP servers share one plan. + */ +/** JSON primitive accepted in descriptor schemas and availability context values. */ export type JsonPrimitive = string | number | boolean | null; +/** Readonly JSON value accepted by public descriptor metadata. */ export type JsonValue = | JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; +/** Readonly JSON object accepted by public descriptor metadata. */ export type JsonObject = { readonly [key: string]: JsonValue }; +/** Owner family responsible for defining a tool descriptor. */ export type ToolOwnerRef = | { readonly kind: "core" } | { readonly kind: "plugin"; readonly pluginId: string } | { readonly kind: "channel"; readonly channelId: string; readonly pluginId?: string } | { readonly kind: "mcp"; readonly serverId: string }; +/** Runtime executor target used after a tool has passed availability planning. */ export type ToolExecutorRef = | { readonly kind: "core"; readonly executorId: string } | { readonly kind: "plugin"; readonly pluginId: string; readonly toolName: string } | { readonly kind: "channel"; readonly channelId: string; readonly actionId: string } | { readonly kind: "mcp"; readonly serverId: string; readonly toolName: string }; +/** Atomic condition used to decide whether a tool is visible. */ export type ToolAvailabilitySignal = | { readonly kind: "always" } | { readonly kind: "auth"; readonly providerId: string } @@ -31,11 +43,13 @@ export type ToolAvailabilitySignal = | { readonly kind: "plugin-enabled"; readonly pluginId: string } | { readonly kind: "context"; readonly key: string; readonly equals?: JsonPrimitive }; +/** Boolean expression over tool availability signals. */ export type ToolAvailabilityExpression = | ToolAvailabilitySignal | { readonly allOf: readonly ToolAvailabilityExpression[] } | { readonly anyOf: readonly ToolAvailabilityExpression[] }; +/** Public descriptor for a tool before runtime availability planning. */ export type ToolDescriptor = { readonly name: string; readonly title?: string; @@ -49,6 +63,7 @@ export type ToolDescriptor = { readonly sortKey?: string; }; +/** Runtime facts used to evaluate descriptor availability expressions. */ export type ToolAvailabilityContext = { readonly authProviderIds?: ReadonlySet; readonly config?: JsonObject; @@ -62,6 +77,7 @@ export type ToolAvailabilityContext = { readonly values?: Readonly>; }; +/** Stable reason code for an unavailable descriptor. */ export type ToolUnavailableReason = | "auth-missing" | "config-missing" @@ -70,27 +86,32 @@ export type ToolUnavailableReason = | "plugin-disabled" | "unsupported-signal"; +/** Diagnostic explaining why a descriptor is hidden from the visible plan. */ export type ToolAvailabilityDiagnostic = { readonly reason: ToolUnavailableReason; readonly signal?: ToolAvailabilitySignal; readonly message: string; }; +/** Visible, callable tool entry selected by the planner. */ export type ToolPlanEntry = { readonly descriptor: ToolDescriptor; readonly executor: ToolExecutorRef; }; +/** Hidden descriptor plus diagnostics explaining why it is unavailable. */ export type HiddenToolPlanEntry = { readonly descriptor: ToolDescriptor; readonly diagnostics: readonly ToolAvailabilityDiagnostic[]; }; +/** Complete planner output split into visible and hidden descriptors. */ export type ToolPlan = { readonly visible: readonly ToolPlanEntry[]; readonly hidden: readonly HiddenToolPlanEntry[]; }; +/** Inputs required to build a tool plan. */ export type BuildToolPlanOptions = { readonly descriptors: readonly ToolDescriptor[]; readonly availability?: ToolAvailabilityContext; diff --git a/src/trajectory/command-export.ts b/src/trajectory/command-export.ts index dc301540a337..ac54636ca68c 100644 --- a/src/trajectory/command-export.ts +++ b/src/trajectory/command-export.ts @@ -4,6 +4,8 @@ import { pathExists } from "../infra/fs-safe.js"; import { isPathInside } from "../infra/path-guards.js"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; +// CLI-facing trajectory export wrapper: resolves safe workspace-local paths, +// writes the diagnostic bundle, and formats the terse success summary. export type TrajectoryCommandExportSummary = { outputDir: string; displayPath: string; @@ -98,6 +100,8 @@ export async function resolveTrajectoryCommandOutputDir(params: { existingParent = next; } const realExistingParent = await fsp.realpath(existingParent); + // Validate the first existing ancestor by realpath so a missing child cannot + // be smuggled through a symlinked parent outside the export root. if (!isPathInside(realBase, realExistingParent)) { throw new Error("Output path must stay inside the real trajectory exports directory"); } @@ -147,6 +151,8 @@ export async function exportTrajectoryForCommand(params: { }; } +// Human CLI output contract. Keep this stable for docs/tests that snapshot the +// command text, but keep raw paths in the structured summary above. export function formatTrajectoryCommandExportSummary( summary: TrajectoryCommandExportSummary, ): string { diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index 30a9d5c816f5..c8b499fbfd20 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -29,6 +29,9 @@ import type { TrajectoryToolDefinition, } from "./types.js"; +// Trajectory bundle exporter: joins persisted session JSONL with runtime +// trace JSONL, redacts local/support-sensitive data, and writes a portable +// support bundle for debugging agent behavior. type BuildTrajectoryBundleParams = { outputDir: string; sessionFile: string; @@ -118,6 +121,8 @@ function migrateLegacySessionEntries(entries: FileEntry[]): void { const header = entries.find((entry): entry is SessionHeader => entry.type === "session"); const version = header?.version ?? 1; if (version < 2) { + // Older session logs predate entry ids. Synthetic ids preserve branch order + // long enough to export the reachable suffix without mutating source files. let previousId: string | null = null; let index = 0; for (const entry of entries) { @@ -569,6 +574,8 @@ function redactWorkspacePathString(value: string, redaction: TrajectoryExportRed function maybeRedactPathString(value: string, redaction: TrajectoryExportRedaction): string { const workspaceRedacted = redactWorkspacePathString(value, redaction); + // Redact only strings that look path-like after workspace substitution. This + // keeps ordinary model text readable while still removing local host details. if ( workspaceRedacted !== value || path.isAbsolute(workspaceRedacted) || @@ -627,6 +634,8 @@ function redactTrajectoryExportObjectKeys( const next: Record = {}; for (const [key, entry] of Object.entries(value)) { const redactedKey = redactToolPayloadText(maybeRedactPathString(key, redaction)); + // Object keys can contain file paths or tool payload snippets too. Preserve + // all entries even when redaction collapses two original keys together. next[uniqueRedactedObjectKey(redactedKey, usedKeys)] = redactTrajectoryExportObjectKeys( entry, redaction, @@ -728,6 +737,8 @@ function markInvokedSkills(params: { skills: unknown; events: TrajectoryEvent[] return collectPotentialPathStrings(event.data?.arguments); }), ); + // Skill invocation is inferred from tool-call file paths in captured prompts; + // this keeps the export self-contained without re-reading skill state later. const normalizedInvokedPaths = new Set( [...invokedPaths].map((value) => normalizePathForMatch(value)), ); @@ -920,6 +931,8 @@ export function resolveDefaultTrajectoryExportDir(params: { ); } +// Public export API used by CLI/tests. The bundle is intentionally sanitized +// before writing so sharing it should not expose credentials or local paths. export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): Promise<{ manifest: TrajectoryBundleManifest; outputDir: string; diff --git a/src/trajectory/metadata.ts b/src/trajectory/metadata.ts index 424b4f3f47fa..2887cd3f4a86 100644 --- a/src/trajectory/metadata.ts +++ b/src/trajectory/metadata.ts @@ -14,6 +14,8 @@ import { getActivePluginRegistry, listImportedRuntimePluginIds } from "../plugin import type { SkillSnapshot } from "../skills/types.js"; import { VERSION } from "../version.js"; +// Runtime metadata capture for trajectory events. This records enough config, +// plugin, skill, and prompt context to explain a run after logs are exported. type BuildTrajectoryRunMetadataParams = { env?: NodeJS.ProcessEnv; config?: OpenClawConfig; @@ -138,6 +140,8 @@ function buildPluginsFromManifest(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }) { + // Startup captures can happen before runtime activation. Fall back to the + // manifest snapshot so exported runs still show configured plugin surfaces. const snapshot = loadPluginMetadataSnapshot({ config: params.config ?? {}, workspaceDir: params.workspaceDir, @@ -182,6 +186,8 @@ function buildSkillsCapture( (skill) => typeof skill.name === "string" && skill.name.length > 0, ) ?? []; const entries = + // Prefer resolved skill files when available; older call sites may only + // have the summarized skill catalog, which is still useful for support. filteredResolvedSkills.length > 0 ? filteredResolvedSkills.map((skill) => ({ id: skill.name, @@ -300,6 +306,8 @@ export function buildTrajectoryRunMetadata( }; } +// Completion artifact schema mirrored into trajectory export artifacts.json. +// Keep field names close to runtime event data to make bundle diffs readable. export function buildTrajectoryArtifacts( params: BuildTrajectoryArtifactsParams, ): Record { diff --git a/src/trajectory/paths.ts b/src/trajectory/paths.ts index 64edd63ba6b4..4ae6e1af2d85 100644 --- a/src/trajectory/paths.ts +++ b/src/trajectory/paths.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { isPathInside } from "../infra/path-guards.js"; +// Runtime trajectory path helpers. Paths are either beside the session file or +// inside OPENCLAW_TRAJECTORY_DIR, with names scrubbed for filesystem safety. export const TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES = 10 * 1024 * 1024; export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024; export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024; @@ -18,6 +20,8 @@ export function safeTrajectorySessionFileName(sessionId: string): string { return /[A-Za-z0-9]/u.test(safe) ? safe : "session"; } +// Pointer files are overwritten atomically by callers. O_NOFOLLOW is optional +// because some platforms do not expose it, but use it when Node provides it. export function resolveTrajectoryPointerOpenFlags( constants: TrajectoryPointerOpenFlagConstants = fs.constants, ): number { @@ -63,6 +67,8 @@ export function resolveTrajectoryFilePath(params: { : `${params.sessionFile}.trajectory.jsonl`; } +// Sidecar pointer naming contract used to discover runtime trace files from a +// persisted session file during support-bundle export. export function resolveTrajectoryPointerFilePath(sessionFile: string): string { return sessionFile.endsWith(".jsonl") ? `${sessionFile.slice(0, -".jsonl".length)}.trajectory-path.json` diff --git a/src/trajectory/runtime-file.ts b/src/trajectory/runtime-file.ts index 75480337e464..d8fdec4a00ed 100644 --- a/src/trajectory/runtime-file.ts +++ b/src/trajectory/runtime-file.ts @@ -6,6 +6,8 @@ import { safeTrajectorySessionFileName, } from "./paths.js"; +// Runtime trajectory file discovery for exporters. Pointer files are treated as +// advisory only and must resolve to regular non-symlink files before use. function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -48,6 +50,8 @@ async function readRuntimePointerFile( sessionId, }), ); + // Accept the default sibling path or a runtime-dir file with the sanitized + // session basename; reject arbitrary pointers from stale or edited sidecars. if (runtimeFile !== defaultRuntimeFile && path.basename(runtimeFile) !== safeRuntimeFileName) { return undefined; } diff --git a/src/trajectory/types.ts b/src/trajectory/types.ts index f57b52c15521..4af5a06fce96 100644 --- a/src/trajectory/types.ts +++ b/src/trajectory/types.ts @@ -1,11 +1,15 @@ +// Shared trajectory support-bundle schema. Runtime, transcript, and export code +// all emit this versioned JSONL shape so external debugging tools can replay it. export type TrajectoryEventSource = "runtime" | "transcript" | "export"; +// Serialized tool definition captured with compiled context events. export type TrajectoryToolDefinition = { name: string; description?: string; parameters?: unknown; }; +// Versioned event envelope for runtime and transcript-derived trajectory rows. export type TrajectoryEvent = { traceSchema: "openclaw-trajectory"; schemaVersion: 1; @@ -27,6 +31,7 @@ export type TrajectoryEvent = { data?: Record; }; +// Bundle manifest written beside events.jsonl in trajectory exports. export type TrajectoryBundleManifest = { traceSchema: "openclaw-trajectory"; schemaVersion: 1; @@ -52,6 +57,7 @@ export type TrajectoryBundleManifest = { warnings?: TrajectoryBundleWarning[]; }; +// Parse/export warnings are grouped in the manifest with sample row numbers. export type TrajectoryBundleWarning = { source: "session" | "runtime"; code: diff --git a/src/transcripts/config.ts b/src/transcripts/config.ts index e3892adf19ed..0a4304cd4192 100644 --- a/src/transcripts/config.ts +++ b/src/transcripts/config.ts @@ -1,5 +1,12 @@ import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce"; +/** + * Configuration normalization for transcript capture/import. + * + * Raw config can contain optional auto-start provider locators; resolution + * returns bounded defaults and drops malformed entries before runtime startup. + */ +/** Raw auto-start transcript source entry from config. */ export type TranscriptsAutoStartConfig = { providerId: string; sessionId?: string; @@ -10,6 +17,7 @@ export type TranscriptsAutoStartConfig = { meetingUrl?: string; }; +/** Normalized auto-start source entry consumed by transcript runtime code. */ export type ResolvedTranscriptsAutoStartConfig = { providerId: string; sessionId?: string; @@ -20,12 +28,14 @@ export type ResolvedTranscriptsAutoStartConfig = { meetingUrl?: string; }; +/** Raw transcripts config block. */ export type TranscriptsConfig = { enabled?: boolean; maxUtterances?: number; autoStart?: TranscriptsAutoStartConfig[]; }; +/** Resolved transcripts config with defaults applied. */ export type ResolvedTranscriptsConfig = { enabled: boolean; maxUtterances: number; @@ -56,6 +66,7 @@ function resolveAutoStart(raw: unknown): ResolvedTranscriptsAutoStartConfig[] { .filter((entry): entry is ResolvedTranscriptsAutoStartConfig => entry !== undefined); } +/** Normalize raw transcripts config into runtime settings. */ export function resolveTranscriptsConfig(raw: unknown): ResolvedTranscriptsConfig { const config = raw && typeof raw === "object" ? (raw as Record) : {}; const maxUtterances = diff --git a/src/transcripts/manual-source.ts b/src/transcripts/manual-source.ts index fea43dee964f..2db46c160f1c 100644 --- a/src/transcripts/manual-source.ts +++ b/src/transcripts/manual-source.ts @@ -1,5 +1,11 @@ import type { TranscriptSourceProvider } from "./provider-types.js"; +/** + * Manual transcript import provider. + * + * This provider turns pasted text into final transcript utterances, optionally + * splitting "Speaker: text" prefixes into speaker labels. + */ function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } { const match = /^([^:\n]{1,80}):\s+(.+)$/.exec(line.trim()); if (!match) { @@ -8,6 +14,7 @@ function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } return { speakerLabel: match[1]?.trim(), text: match[2]?.trim() ?? "" }; } +/** Built-in provider for post-hoc transcript text imports. */ export const manualTranscriptSourceProvider: TranscriptSourceProvider = { id: "manual-transcript", aliases: ["import", "transcript"], diff --git a/src/transcripts/provider-registry.ts b/src/transcripts/provider-registry.ts index ac080578dab0..168e190c41b7 100644 --- a/src/transcripts/provider-registry.ts +++ b/src/transcripts/provider-registry.ts @@ -9,6 +9,13 @@ import { } from "../plugins/provider-registry-shared.js"; import type { TranscriptSourceProvider } from "./provider-types.js"; +/** + * Transcript source provider registry. + * + * Transcript providers are plugin capability providers; this module exposes + * canonical/alias lookup and keeps direct plugin resolution ahead of map fallback. + */ +/** Normalize transcript source provider ids for registry lookup. */ export function normalizeTranscriptSourceProviderId( providerId: string | undefined, ): string | undefined { @@ -29,10 +36,12 @@ function buildProviderMaps(cfg?: OpenClawConfig): { return buildCapabilityProviderMaps(resolveTranscriptsSourceProviderEntries(cfg)); } +/** List canonical transcript source providers for a config snapshot. */ export function listTranscriptSourceProviders(cfg?: OpenClawConfig): TranscriptSourceProvider[] { return [...buildProviderMaps(cfg).canonical.values()]; } +/** Resolve a transcript provider by canonical id or alias. */ export function getTranscriptSourceProvider( providerId: string | undefined, cfg?: OpenClawConfig, diff --git a/src/transcripts/provider-types.ts b/src/transcripts/provider-types.ts index 5ede51c106d7..9f3750690bcf 100644 --- a/src/transcripts/provider-types.ts +++ b/src/transcripts/provider-types.ts @@ -1,11 +1,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** + * Public contracts for transcript source providers. + * + * Providers can stream live utterances, import post-hoc transcript text, expose + * status, and stop active sessions using shared session/source descriptors. + */ +/** Supported source families for transcript providers. */ export type TranscriptSourceKind = | "live-audio" | "live-caption" | "posthoc-transcript" | "recording-stt"; +/** Provider-specific locator for a live, recorded, or imported transcript source. */ export type TranscriptSourceLocator = { providerId: string; kind?: TranscriptSourceKind; @@ -18,11 +26,13 @@ export type TranscriptSourceLocator = { [key: string]: string | undefined; }; +/** Speaker/participant identity attached to an utterance. */ export type TranscriptParticipant = { id?: string; label: string; }; +/** One captured or imported transcript utterance. */ export type TranscriptUtterance = { id?: string; sessionId?: string; @@ -34,6 +44,7 @@ export type TranscriptUtterance = { metadata?: Record; }; +/** Durable transcript session metadata. */ export type TranscriptSessionDescriptor = { sessionId: string; title?: string; @@ -43,6 +54,7 @@ export type TranscriptSessionDescriptor = { metadata?: Record; }; +/** Request passed to providers that can start live transcript capture. */ export type TranscriptStartRequest = { cfg?: OpenClawConfig; session: TranscriptSessionDescriptor; @@ -52,6 +64,7 @@ export type TranscriptStartRequest = { onStatus?: (status: TranscriptSourceStatus) => void | Promise; }; +/** Result from starting a transcript source provider. */ export type TranscriptsStartResult = | { ok: true; @@ -62,6 +75,7 @@ export type TranscriptsStartResult = error: string; }; +/** Request passed to providers that can stop live transcript capture. */ export type TranscriptStopRequest = { cfg?: OpenClawConfig; sessionId: string; @@ -69,6 +83,7 @@ export type TranscriptStopRequest = { reason?: string; }; +/** Result from stopping a transcript source provider. */ export type TranscriptsStopResult = | { ok: true; @@ -80,6 +95,7 @@ export type TranscriptsStopResult = error: string; }; +/** Runtime status reported by transcript source providers. */ export type TranscriptSourceStatus = { sessionId?: string; active: boolean; @@ -87,6 +103,7 @@ export type TranscriptSourceStatus = { source?: TranscriptSourceLocator; }; +/** Request passed to providers that import post-hoc transcript text. */ export type TranscriptImportRequest = { cfg?: OpenClawConfig; session: TranscriptSessionDescriptor; @@ -94,6 +111,7 @@ export type TranscriptImportRequest = { speakerLabel?: string; }; +/** Provider contract for transcript capture/import integrations. */ export type TranscriptSourceProvider = { id: string; aliases?: readonly string[]; diff --git a/src/transcripts/store.ts b/src/transcripts/store.ts index e782b659014b..49ad8d71ebb6 100644 --- a/src/transcripts/store.ts +++ b/src/transcripts/store.ts @@ -7,12 +7,20 @@ import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provide import type { TranscriptsSummary } from "./summary.js"; import { renderTranscriptsMarkdown } from "./summary.js"; +/** + * File-backed transcript session store. + * + * Sessions are stored by date/session id with metadata JSON, append-only + * utterance JSONL, and rendered summary artifacts. + */ +/** Stored session metadata plus the resolved session directory. */ export type TranscriptsSessionEntry = { session: TranscriptSessionDescriptor; sessionDir: string; }; function safeSegment(value: string): string { + // Session ids can come from external providers; path segments stay conservative. return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session"; } @@ -46,9 +54,11 @@ function sameSessionIdentity( return left.sessionId === right.sessionId && left.startedAt === right.startedAt; } +/** Durable transcript store rooted at a caller-provided directory. */ export class TranscriptsStore { constructor(private readonly rootDir: string) {} + /** Resolve the dated directory for a transcript session. */ sessionDir(session: TranscriptSessionDescriptor): string { return path.join(this.rootDir, dateSegment(session.startedAt), safeSegment(session.sessionId)); } @@ -107,6 +117,7 @@ export class TranscriptsStore { } } if (matches.length > 1) { + // Ambiguous bare ids require an explicit date prefix to avoid reading the wrong session. throw new Error( `multiple transcripts sessions match ${selector}; use a YYYY-MM-DD/${selector} selector`, ); @@ -114,16 +125,19 @@ export class TranscriptsStore { return matches[0]; } + /** Persist transcript session metadata. */ async writeSession(session: TranscriptSessionDescriptor): Promise { const dir = this.sessionDir(session); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "metadata.json"), `${JSON.stringify(session, null, 2)}\n`); } + /** Read one session descriptor by session id or qualified date/id selector. */ async readSession(sessionId: string): Promise { return (await this.readSessionEntry(sessionId))?.session; } + /** Read one session descriptor plus its directory. */ async readSessionEntry(sessionId: string): Promise { const dir = await this.findSessionDir(sessionId); if (!dir) { @@ -135,6 +149,7 @@ export class TranscriptsStore { return session ? { session, sessionDir: dir } : undefined; } + /** Append an utterance by session id, creating a dated session directory if needed. */ async appendUtterance(sessionId: string, utterance: TranscriptUtterance): Promise { const dir = (await this.findSessionDir(sessionId)) ?? @@ -142,6 +157,7 @@ export class TranscriptsStore { await this.appendUtteranceToDir(dir, sessionId, utterance); } + /** Append an utterance for an exact session descriptor. */ async appendUtteranceForSession( session: TranscriptSessionDescriptor, utterance: TranscriptUtterance, @@ -162,6 +178,7 @@ export class TranscriptsStore { ); } + /** Read utterances for an exact session descriptor. */ async readUtterancesForSession( session: TranscriptSessionDescriptor, options: { maxUtterances?: number } = {}, @@ -169,6 +186,7 @@ export class TranscriptsStore { return await this.readUtterancesFromDir(await this.findSessionDirForSession(session), options); } + /** Read utterances directly from a known session directory. */ async readUtterancesFromSessionDir( sessionDir: string, options: { maxUtterances?: number } = {}, @@ -176,6 +194,7 @@ export class TranscriptsStore { return await this.readUtterancesFromDir(sessionDir, options); } + /** Read utterances by session id or qualified date/id selector. */ async readUtterances( sessionId: string, options: { maxUtterances?: number } = {}, @@ -206,6 +225,7 @@ export class TranscriptsStore { } utterances.push(JSON.parse(line) as TranscriptUtterance); if (utterances.length > maxUtterances) { + // Stream and keep only the tail so large transcripts do not require full-file memory. utterances.shift(); } } @@ -232,6 +252,7 @@ export class TranscriptsStore { .map((line) => JSON.parse(line) as TranscriptUtterance); } + /** Mark a transcript session as stopped when metadata exists. */ async updateStopped(sessionId: string, stoppedAt: string): Promise { const dir = await this.findSessionDir(sessionId); if (!dir) { @@ -249,6 +270,7 @@ export class TranscriptsStore { ); } + /** Write summary artifacts for a session and return the markdown path. */ async writeSummary( summary: TranscriptsSummary, session?: TranscriptSessionDescriptor, @@ -261,6 +283,7 @@ export class TranscriptsStore { return await this.writeSummaryToDir(summary, dir); } + /** Write summary JSON and markdown to a known directory. */ async writeSummaryToDir(summary: TranscriptsSummary, dir: string): Promise { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); diff --git a/src/transcripts/summary.ts b/src/transcripts/summary.ts index 3c36182093c9..195ecc901d26 100644 --- a/src/transcripts/summary.ts +++ b/src/transcripts/summary.ts @@ -1,6 +1,13 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js"; +/** + * Lightweight transcript summarization and markdown rendering. + * + * This is a deterministic heuristic summary used for captured/imported + * transcripts when no model-backed summarizer is involved. + */ +/** Summary artifact written alongside transcript sessions. */ export type TranscriptsSummary = { sessionId: string; title: string; @@ -46,6 +53,7 @@ function formatTranscript(utterances: TranscriptUtterance[]): string[] { return utterances.map(formatSpeakerLine).filter(Boolean); } +/** Build a deterministic summary from transcript utterances. */ export function summarizeTranscripts(params: { session: TranscriptSessionDescriptor; utterances: TranscriptUtterance[]; @@ -69,6 +77,7 @@ function renderList(items: string[]): string { return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- None captured"; } +/** Render a transcript summary as markdown for local artifacts. */ export function renderTranscriptsMarkdown(summary: TranscriptsSummary): string { return [ `# ${summary.title}`, diff --git a/src/tts/directive-number.ts b/src/tts/directive-number.ts index 848d2ec7b89f..db5754d0c464 100644 --- a/src/tts/directive-number.ts +++ b/src/tts/directive-number.ts @@ -5,6 +5,7 @@ import type { SpeechProviderOverrides, } from "./provider-types.js"; +/** Numeric directive parsing shared by speech providers with bounded knobs. */ type DirectiveNumberRange = { min?: number; max?: number; @@ -22,6 +23,7 @@ function isInDirectiveNumberRange(value: number, range: DirectiveNumberRange): b return true; } +/** Parse a numeric speech directive token and return provider overrides when policy allows it. */ export function parseSpeechDirectiveNumberOverride(params: { ctx: SpeechDirectiveTokenParseContext; overrideKey: string; diff --git a/src/tts/directives.ts b/src/tts/directives.ts index b1f8bcf8a1df..b4f8a9f2f225 100644 --- a/src/tts/directives.ts +++ b/src/tts/directives.ts @@ -22,6 +22,7 @@ type TextRange = { end: number; }; +/** Streaming cleaner used to strip TTS tags before final text parsing is available. */ export type TtsDirectiveTextStreamCleaner = { push: (text: string) => string; flush: () => string; @@ -148,6 +149,8 @@ function replaceOutsideMarkdownCode( if (typeof offset === "number" && isInsideRange(offset, codeRanges)) { return match; } + // String.replace passes captures before offset/input; keep the callback + // typed without depending on the exact regexp arity for each directive. const captures = args.slice(1, -2).map((capture) => String(capture)); return replace(match, captures); }); @@ -176,6 +179,7 @@ function classifyTtsTag(body: string): "hidden-open" | "hidden-close" | "tts" | return "other"; } +/** Create an incremental cleaner for hiding [[tts:*]] directive text while streaming. */ export function createTtsDirectiveTextStreamCleaner(): TtsDirectiveTextStreamCleaner { let pending = ""; let insideHiddenTextBlock = false; @@ -202,6 +206,8 @@ export function createTtsDirectiveTextStreamCleaner(): TtsDirectiveTextStreamCle const tagEnd = input.indexOf("]]", tagStart + 2); if (tagEnd === -1) { + // Directive delimiters can cross chunk boundaries; buffer from the + // opener so a partial tag never leaks to a streamed client. pending = input.slice(tagStart); break; } @@ -232,6 +238,7 @@ export function createTtsDirectiveTextStreamCleaner(): TtsDirectiveTextStreamCle }; } +/** Parse TTS directives from final message text, leaving markdown code spans unchanged. */ export function parseTtsDirectives( text: string, policy: SpeechModelOverridePolicy, @@ -388,6 +395,8 @@ export function parseTtsDirectives( break; } if (!handled && declaredProviderId && directiveProvidersLocal.length > 0) { + // Unknown keys are only actionable when the user explicitly selected a + // provider; auto-selected providers may not support each other's knobs. warnings.push(`unsupported ${declaredProviderId} directive key "${key}"`); } } diff --git a/src/tts/openai-compatible-speech-provider.ts b/src/tts/openai-compatible-speech-provider.ts index ddf29d9c11e2..fd7a54b6c214 100644 --- a/src/tts/openai-compatible-speech-provider.ts +++ b/src/tts/openai-compatible-speech-provider.ts @@ -23,14 +23,17 @@ type OpenAiCompatibleSpeechProviderBaseConfig = { responseFormat?: string; }; +/** Normalized config shape for OpenAI-compatible speech HTTP providers. */ export type OpenAiCompatibleSpeechProviderConfig< ExtraConfig extends Record = Record, > = OpenAiCompatibleSpeechProviderBaseConfig & ExtraConfig; +/** Base URL normalization policy for providers that share OpenAI-style endpoints. */ export type OpenAiCompatibleSpeechProviderBaseUrlPolicy = | { kind: "trim-trailing-slash" } | { kind: "canonical"; aliases?: readonly string[]; allowCustom?: boolean }; +/** Extra config field to forward into the JSON body under an optional request key. */ export type OpenAiCompatibleSpeechProviderExtraJsonBodyField< ExtraConfig extends Record, > = { @@ -38,6 +41,7 @@ export type OpenAiCompatibleSpeechProviderExtraJsonBodyField< requestKey?: string; }; +/** Factory options for a speech provider backed by /audio/speech-compatible HTTP APIs. */ export type OpenAiCompatibleSpeechProviderOptions< ExtraConfig extends Record = Record, > = { @@ -101,6 +105,9 @@ function normalizeBaseUrl(params: { return normalized; } const canonical = trimTrailingBaseUrl(params.fallback, params.fallback); + // Some hosted providers publish multiple equivalent URLs. Canonicalizing + // aliases keeps SSRF policy and status output stable while still allowing + // explicit custom URLs when the provider opts in. const aliases = new Set( [canonical, ...(params.policy.aliases ?? [])].map((entry) => trimTrailingBaseUrl(entry, canonical), @@ -185,6 +192,7 @@ function buildExtraJsonBodyFields>( return body; } +/** Build a complete SpeechProviderPlugin for OpenAI-compatible speech endpoints. */ export function createOpenAiCompatibleSpeechProvider< ExtraConfig extends Record = Record, >(options: OpenAiCompatibleSpeechProviderOptions): SpeechProviderPlugin { @@ -361,6 +369,8 @@ export function createOpenAiCompatibleSpeechProvider< transport: "http", }); + // Keep request construction here so provider implementations only supply + // static metadata and extra body fields, not duplicated HTTP behavior. const { response, release } = await postJsonRequest({ url: `${baseUrl}/audio/speech`, headers, diff --git a/src/tts/provider-registry-core.ts b/src/tts/provider-registry-core.ts index 6d5315c7e69e..c0c0c38f59cf 100644 --- a/src/tts/provider-registry-core.ts +++ b/src/tts/provider-registry-core.ts @@ -6,17 +6,20 @@ import { import type { SpeechProviderPlugin } from "../plugins/types.js"; import type { SpeechProviderId } from "./provider-types.js"; +/** Resolver contract used by default and loaded-only speech provider registries. */ export type SpeechProviderRegistryResolver = { getProvider: (providerId: string, cfg?: OpenClawConfig) => SpeechProviderPlugin | undefined; listProviders: (cfg?: OpenClawConfig) => SpeechProviderPlugin[]; }; +/** Normalize user/provider IDs into the canonical speech provider ID shape. */ export function normalizeSpeechProviderId( providerId: string | undefined, ): SpeechProviderId | undefined { return normalizeCapabilityProviderId(providerId); } +/** Create a registry facade with canonical listing, alias lookup, and ID canonicalization. */ export function createSpeechProviderRegistry(resolver: SpeechProviderRegistryResolver) { const buildResolvedProviderMaps = (cfg?: OpenClawConfig) => buildCapabilityProviderMaps(resolver.listProviders(cfg)); diff --git a/src/tts/provider-registry.ts b/src/tts/provider-registry.ts index 308b158f56c8..a70c1be37f54 100644 --- a/src/tts/provider-registry.ts +++ b/src/tts/provider-registry.ts @@ -11,6 +11,7 @@ import { type SpeechProviderRegistryResolver, } from "./provider-registry-core.js"; +/** Resolve speech providers from configured plugin capabilities. */ function resolveSpeechProviderPluginEntries(cfg?: OpenClawConfig): SpeechProviderPlugin[] { return resolvePluginCapabilityProviders({ key: "speechProviders", @@ -32,10 +33,12 @@ const defaultSpeechProviderRegistryResolver: SpeechProviderRegistryResolver = { listProviders: resolveSpeechProviderPluginEntries, }; +/** Config-aware registry used by setup/status/runtime paths before plugins are loaded. */ const defaultSpeechProviderRegistry = createSpeechProviderRegistry( defaultSpeechProviderRegistryResolver, ); +/** Loaded-only registry for runtime paths that must not rediscover plugin manifests. */ const loadedSpeechProviderRegistry = createSpeechProviderRegistry({ getProvider: (providerId) => resolveLoadedSpeechProviderPluginEntries().find((provider) => { @@ -47,8 +50,12 @@ const loadedSpeechProviderRegistry = createSpeechProviderRegistry({ listProviders: () => resolveLoadedSpeechProviderPluginEntries(), }); +/** List configured speech providers using manifest/capability discovery. */ export const listSpeechProviders = defaultSpeechProviderRegistry.listSpeechProviders; +/** List currently loaded speech providers from the active runtime registry. */ export const listLoadedSpeechProviders = loadedSpeechProviderRegistry.listSpeechProviders; +/** Resolve a configured speech provider by canonical ID or alias. */ export const getSpeechProvider = defaultSpeechProviderRegistry.getSpeechProvider; +/** Resolve an input provider ID or alias to the provider's canonical ID. */ export const canonicalizeSpeechProviderId = defaultSpeechProviderRegistry.canonicalizeSpeechProviderId; diff --git a/src/tts/provider-types.ts b/src/tts/provider-types.ts index fefa4d869372..8d579009c94a 100644 --- a/src/tts/provider-types.ts +++ b/src/tts/provider-types.ts @@ -2,14 +2,19 @@ import type { TalkProviderConfig } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.js"; import type { ResolvedTtsPersona } from "../config/types.tts.js"; +/** Canonical speech provider identifier after provider registry normalization. */ export type SpeechProviderId = string; +/** Output context requested from a speech provider. */ export type SpeechSynthesisTarget = "audio-file" | "voice-note" | "telephony"; +/** Provider-owned normalized config map. */ export type SpeechProviderConfig = Record; +/** Provider-owned per-request directive/persona overrides. */ export type SpeechProviderOverrides = Record; +/** Policy controlling which [[tts:*]] directive fields can affect synthesis. */ export type SpeechModelOverridePolicy = { enabled: boolean; allowText: boolean; @@ -21,12 +26,14 @@ export type SpeechModelOverridePolicy = { allowSeed: boolean; }; +/** Parsed directive overrides grouped by provider. */ export type TtsDirectiveOverrides = { ttsText?: string; provider?: SpeechProviderId; providerOverrides?: Record; }; +/** Result of parsing TTS directives from message text. */ export type TtsDirectiveParseResult = { cleanedText: string; ttsText?: string; @@ -35,12 +42,14 @@ export type TtsDirectiveParseResult = { warnings: string[]; }; +/** Context for checking whether a provider has enough config to synthesize. */ export type SpeechProviderConfiguredContext = { cfg?: OpenClawConfig; providerConfig: SpeechProviderConfig; timeoutMs: number; }; +/** Request for buffered speech synthesis. */ export type SpeechSynthesisRequest = { text: string; cfg: OpenClawConfig; @@ -50,6 +59,7 @@ export type SpeechSynthesisRequest = { timeoutMs: number; }; +/** Buffered speech synthesis result plus file/voice-note compatibility metadata. */ export type SpeechSynthesisResult = { audioBuffer: Buffer; outputFormat: string; @@ -59,6 +69,7 @@ export type SpeechSynthesisResult = { export type SpeechSynthesisStreamRequest = SpeechSynthesisRequest; +/** Streaming speech synthesis result; release frees provider transport resources. */ export type SpeechSynthesisStreamResult = { audioStream: ReadableStream; outputFormat: string; @@ -67,6 +78,7 @@ export type SpeechSynthesisStreamResult = { release?: () => Promise; }; +/** Telephony synthesis request for provider output that needs a fixed sample rate. */ export type SpeechTelephonySynthesisRequest = { text: string; cfg: OpenClawConfig; @@ -75,12 +87,14 @@ export type SpeechTelephonySynthesisRequest = { timeoutMs: number; }; +/** Telephony synthesis result with sample-rate metadata for call transports. */ export type SpeechTelephonySynthesisResult = { audioBuffer: Buffer; outputFormat: string; sampleRate: number; }; +/** Provider hook input for applying persona/config before synthesis. */ export type SpeechProviderPrepareSynthesisContext = { text: string; cfg: OpenClawConfig; @@ -92,12 +106,14 @@ export type SpeechProviderPrepareSynthesisContext = { timeoutMs: number; }; +/** Optional provider-prepared synthesis overrides. */ export type SpeechProviderPreparedSynthesis = { text?: string; providerConfig?: SpeechProviderConfig; providerOverrides?: SpeechProviderOverrides; }; +/** Voice metadata returned by provider list-voices hooks. */ export type SpeechVoiceOption = { id: string; name?: string; @@ -108,6 +124,7 @@ export type SpeechVoiceOption = { personalities?: string[]; }; +/** Provider voice-listing request with optional direct auth/URL overrides. */ export type SpeechListVoicesRequest = { cfg?: OpenClawConfig; providerConfig?: SpeechProviderConfig; @@ -115,12 +132,14 @@ export type SpeechListVoicesRequest = { baseUrl?: string; }; +/** Provider hook input for resolving normalized config from raw OpenClaw config. */ export type SpeechProviderResolveConfigContext = { cfg: OpenClawConfig; rawConfig: Record; timeoutMs: number; }; +/** One parsed directive key/value plus current provider override state. */ export type SpeechDirectiveTokenParseContext = { key: string; value: string; @@ -130,12 +149,14 @@ export type SpeechDirectiveTokenParseContext = { currentOverrides?: SpeechProviderOverrides; }; +/** Provider directive parser result. */ export type SpeechDirectiveTokenParseResult = { handled: boolean; overrides?: SpeechProviderOverrides; warnings?: string[]; }; +/** Provider hook input for resolving talk-command speech config. */ export type SpeechProviderResolveTalkConfigContext = { cfg: OpenClawConfig; baseTtsConfig: Record; @@ -143,6 +164,7 @@ export type SpeechProviderResolveTalkConfigContext = { timeoutMs: number; }; +/** Provider hook input for per-call talk-command overrides. */ export type SpeechProviderResolveTalkOverridesContext = { talkProviderConfig: TalkProviderConfig; params: Record; diff --git a/src/tts/tts-auto-mode.ts b/src/tts/tts-auto-mode.ts index 71c351fe4c19..3199b06545bb 100644 --- a/src/tts/tts-auto-mode.ts +++ b/src/tts/tts-auto-mode.ts @@ -1,8 +1,10 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { TtsAutoMode } from "../config/types.tts.js"; +/** Accepted TTS auto modes from config, prefs, and session-level overrides. */ export const TTS_AUTO_MODES = new Set(["off", "always", "inbound", "tagged"]); +/** Normalize an unknown value into a supported TTS auto mode. */ export function normalizeTtsAutoMode(value: unknown): TtsAutoMode | undefined { if (typeof value !== "string") { return undefined; diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts index 7d9bfaac672d..6197d432f2dd 100644 --- a/src/tts/tts-config.ts +++ b/src/tts/tts-config.ts @@ -14,6 +14,7 @@ export { normalizeTtsAutoMode } from "./tts-auto-mode.js"; const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); +/** Routing context used to layer global, agent, channel, and account TTS config. */ export type TtsConfigResolutionContext = { agentId?: string; channelId?: string; @@ -27,6 +28,8 @@ function deepMergeDefined(base: unknown, override: unknown): unknown { const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { + // TTS overrides are user-editable config. Skip prototype mutation keys while + // preserving deep merge semantics for real nested provider/persona config. if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) { continue; } @@ -118,6 +121,7 @@ function resolveAccountTtsOverride( return asTtsConfig(asObjectRecord(accountConfig)?.tts); } +/** Resolve effective TTS config after applying global, agent, channel, and account layers. */ export function resolveEffectiveTtsConfig( cfg: OpenClawConfig, contextOrAgentId?: string | TtsConfigResolutionContext, @@ -134,6 +138,7 @@ export function resolveEffectiveTtsConfig( return merged as TtsConfig; } +/** Resolve the configured TTS mode, defaulting to final-answer synthesis. */ export function resolveConfiguredTtsMode( cfg: OpenClawConfig, contextOrAgentId?: string | TtsConfigResolutionContext, @@ -173,6 +178,7 @@ function readTtsPrefsAutoMode(prefsPath: string): TtsAutoMode | undefined { return undefined; } +/** Return whether this payload should attempt TTS based on session, prefs, and config. */ export function shouldAttemptTtsPayload(params: { cfg: OpenClawConfig; ttsAuto?: string; @@ -198,6 +204,7 @@ export function shouldAttemptTtsPayload(params: { return raw?.enabled === true; } +/** Return whether TTS directive markup should be stripped from user-visible text. */ export function shouldCleanTtsDirectiveText(params: { cfg: OpenClawConfig; ttsAuto?: string; diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index 084ad6045ad0..c8f0d3193d04 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -77,6 +77,7 @@ function isTextContentBlock(block: { type: string }): block is TextContent { return block.type === "text"; } +/** Summarize long text before synthesis using the configured summary model. */ export async function summarizeText( params: { text: string; @@ -110,6 +111,8 @@ export async function summarizeText( const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { + // Keep summarization on the simple-completion path so provider auth, + // aliases, and timeout behavior match other lightweight model calls. const res = await deps.completeSimple( completionModel, { diff --git a/src/tts/tts-types.ts b/src/tts/tts-types.ts index ed74e0cb26ca..2dc8d4a36772 100644 --- a/src/tts/tts-types.ts +++ b/src/tts/tts-types.ts @@ -8,8 +8,10 @@ import type { } from "../config/types.tts.js"; import type { SpeechModelOverridePolicy, SpeechProviderConfig } from "./provider-types.js"; +/** Resolved directive override policy after config defaults are applied. */ export type ResolvedTtsModelOverrides = SpeechModelOverridePolicy; +/** Fully resolved TTS runtime config consumed by synthesis and status paths. */ export type ResolvedTtsConfig = { auto: TtsAutoMode; mode: TtsMode; diff --git a/src/tts/tts.runtime.ts b/src/tts/tts.runtime.ts index 2e20509b9e3f..912d3d112e67 100644 --- a/src/tts/tts.runtime.ts +++ b/src/tts/tts.runtime.ts @@ -1 +1,2 @@ +/** Lazy runtime facade for payload-level TTS application. */ export { maybeApplyTtsToPayload } from "./tts.js"; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 02c0b514e88f..a5c2a30a89a0 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -1,3 +1,7 @@ +/** + * Public TTS runtime barrel exposed to core callers and plugin SDK facades. + * Implementation stays in plugin-sdk/tts-runtime so provider surfaces share one contract. + */ export { testApi as _test, testApi, diff --git a/src/tui/components/btw-inline-message.ts b/src/tui/components/btw-inline-message.ts index d0bcc67616c4..c34fca5c7efe 100644 --- a/src/tui/components/btw-inline-message.ts +++ b/src/tui/components/btw-inline-message.ts @@ -2,18 +2,21 @@ import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; +// Inline overlay message for BTW follow-up answers inside the chat log. type BtwInlineMessageParams = { question: string; text: string; isError?: boolean; }; +/** Renders a dismissible BTW result, with error text or assistant markdown content. */ export class BtwInlineMessage extends Container { constructor(params: BtwInlineMessageParams) { super(); this.setResult(params); } + /** Replaces the current BTW content without reallocating the host component. */ setResult(params: BtwInlineMessageParams) { this.clear(); this.addChild(new Spacer(1)); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 0caefb5fd63b..1e2fc14fdcab 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -6,6 +6,7 @@ import { BtwInlineMessage } from "./btw-inline-message.js"; import { ToolExecutionComponent } from "./tool-execution.js"; import { UserMessageComponent } from "./user-message.js"; +// Tolerates history timestamps slightly before locally pending messages. const PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS = 60_000; type RepeatableSystemMessage = { @@ -15,6 +16,7 @@ type RepeatableSystemMessage = { count: number; }; +/** Scrollback container that tracks pending users, streaming assistant runs, tools, and notices. */ export class ChatLog extends Container { private readonly maxComponents: number; private toolById = new Map(); @@ -37,6 +39,7 @@ export class ChatLog extends Container { this.maxComponents = Math.max(20, Math.floor(maxComponents)); } + // Pruning must clear side maps so future stream/tool updates do not target detached components. private dropComponentReferences(component: Component) { for (const [toolId, tool] of this.toolById.entries()) { if (tool === component) { @@ -218,6 +221,7 @@ export class ChatLog extends Container { timestamp?: number | null; }>, ) { + // Gateway history may echo a just-submitted local message; remove pending rows when it does. const normalizedHistory = historyUsers .map((entry) => ({ text: entry.text.trim(), diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts index d7e998a2d063..83655d66eaf2 100644 --- a/src/tui/components/custom-editor.ts +++ b/src/tui/components/custom-editor.ts @@ -1,5 +1,6 @@ import { Editor, isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui"; +// Kitty keyboard protocol uses CSI-u sequences for AltGr on international layouts. const KITTY_CSI_U_SUFFIX_REGEX = /^(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/u; const KITTY_MODIFIERS = { alt: 2, @@ -7,6 +8,7 @@ const KITTY_MODIFIERS = { }; const LOCK_MODIFIER_MASK = 64 + 128; +// Decodes Ctrl+Alt layout output into the intended printable AltGr character. function decodeAltGrPrintable(data: string): string | undefined { if (!data.startsWith("\u001b[")) { return undefined; @@ -39,6 +41,7 @@ function decodeAltGrPrintable(data: string): string | undefined { } } +/** Editor with OpenClaw TUI shortcuts layered on top of pi-tui text editing. */ export class CustomEditor extends Editor { onEscape?: () => void; onCtrlC?: () => void; @@ -52,6 +55,7 @@ export class CustomEditor extends Editor { onAltEnter?: () => void; onAltUp?: () => void; + /** Dispatches TUI shortcuts before falling back to normal editor input handling. */ override handleInput(data: string): void { if (isKeyRelease(data)) { return; diff --git a/src/tui/components/markdown-message.ts b/src/tui/components/markdown-message.ts index 38e21572fb73..b40387817c3b 100644 --- a/src/tui/components/markdown-message.ts +++ b/src/tui/components/markdown-message.ts @@ -2,8 +2,10 @@ import { Container, Spacer } from "@earendil-works/pi-tui"; import { markdownTheme } from "../theme/theme.js"; import { HyperlinkMarkdown } from "./hyperlink-markdown.js"; +// Shared markdown message wrapper with a leading spacer for chat-log rows. type MarkdownOptions = ConstructorParameters[4]; +/** Container-backed markdown message that can update text in place. */ export class MarkdownMessageComponent extends Container { private body: HyperlinkMarkdown; @@ -14,6 +16,7 @@ export class MarkdownMessageComponent extends Container { this.addChild(this.body); } + /** Updates the rendered markdown without replacing the component. */ setText(text: string) { this.body.setText(text); } diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index 9b523778ce7d..71a2fba8fad0 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -7,14 +7,17 @@ import { import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js"; +/** Creates a themed searchable select list for TUI overlays. */ export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) { return new SearchableSelectList(items, maxVisible, searchableSelectListTheme); } +/** Creates a themed filterable select list for TUI overlays. */ export function createFilterableSelectList(items: FilterableSelectItem[], maxVisible = 7) { return new FilterableSelectList(items, maxVisible, filterableSelectListTheme); } +/** Creates a themed settings list with change and cancel callbacks. */ export function createSettingsList( items: SettingItem[], onChange: (id: string, value: string) => void, diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index a764e1016edd..0ec3ca07a728 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -3,6 +3,7 @@ import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display. import { markdownTheme, theme } from "../theme/theme.js"; import { sanitizeRenderableText } from "../tui-formatters.js"; +// Rendering model for live tool calls in the chat log. type ToolResultContent = { type?: string; text?: string; @@ -18,6 +19,7 @@ type ToolResult = { const PREVIEW_LINES = 12; +// Prefer curated display summaries, then fall back to sanitized JSON args. function formatArgs(toolName: string, args: unknown): string { const display = resolveToolDisplay({ name: toolName, args }); const detail = formatToolDetail(display); @@ -34,6 +36,7 @@ function formatArgs(toolName: string, args: unknown): string { } } +// Extracts visible text and compact media placeholders from tool result payloads. function extractText(result?: ToolResult): string { if (!result?.content) { return ""; @@ -52,6 +55,7 @@ function extractText(result?: ToolResult): string { return lines.join("\n").trim(); } +/** Displays a running or completed tool call with optional expandable output. */ export class ToolExecutionComponent extends Container { private box: Box; private header: Text; @@ -82,16 +86,19 @@ export class ToolExecutionComponent extends Container { this.refresh(); } + /** Re-renders tool arguments when streaming tool call input changes. */ setArgs(args: unknown) { this.args = args; this.refresh(); } + /** Toggles preview/full output rendering for long tool results. */ setExpanded(expanded: boolean) { this.expanded = expanded; this.refresh(); } + /** Marks the tool call complete and renders final output. */ setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) { this.result = result; this.isPartial = false; @@ -99,6 +106,7 @@ export class ToolExecutionComponent extends Container { this.refresh(); } + /** Renders partial output while the tool call is still running. */ setPartialResult(result: ToolResult | undefined) { this.result = result; this.isPartial = true; diff --git a/src/tui/components/user-message.ts b/src/tui/components/user-message.ts index 83ab23838756..1c7fd5209fc1 100644 --- a/src/tui/components/user-message.ts +++ b/src/tui/components/user-message.ts @@ -1,6 +1,7 @@ import { theme } from "../theme/theme.js"; import { MarkdownMessageComponent } from "./markdown-message.js"; +/** Markdown chat-log row styled as user input. */ export class UserMessageComponent extends MarkdownMessageComponent { constructor(text: string) { super(text, 1, { diff --git a/src/tui/local-run-shutdown.ts b/src/tui/local-run-shutdown.ts index ce8bb51cfff4..0a074a3df745 100644 --- a/src/tui/local-run-shutdown.ts +++ b/src/tui/local-run-shutdown.ts @@ -1,7 +1,9 @@ import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +// Local TUI runs get extra shutdown time because embedded agents/providers may still be closing. const LOCAL_RUN_SHUTDOWN_GRACE_MS = 120_000; +/** Resolves the hard-exit grace period for local TUI shutdown. */ export function resolveLocalRunShutdownGraceMs(): number { const raw = process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS?.trim(); const parsed = parseStrictNonNegativeInteger(raw); diff --git a/src/tui/setup-launch-env.ts b/src/tui/setup-launch-env.ts index 12c1af510199..98bdfce1fd66 100644 --- a/src/tui/setup-launch-env.ts +++ b/src/tui/setup-launch-env.ts @@ -1,2 +1,3 @@ +// Environment flags passed from setup flows into relaunched TUI processes. export const TUI_SETUP_AUTH_SOURCE_ENV = "OPENCLAW_TUI_SETUP_AUTH_SOURCE"; export const TUI_SETUP_AUTH_SOURCE_CONFIG = "config"; diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index b0edec88dc3e..bd4843a53602 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -7,6 +7,8 @@ import type { } from "../../packages/gateway-protocol/src/index.js"; import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; +// Transport-agnostic backend contract consumed by the TUI runtime. +/** Options for sending one chat turn through a TUI backend. */ export type ChatSendOptions = { sessionKey: string; agentId?: string; @@ -18,18 +20,21 @@ export type ChatSendOptions = { runId?: string; }; +/** Options for forwarding a goal command to a backend session. */ export type TuiGoalCommandOptions = { sessionKey: string; agentId?: string; command: string; }; +/** Event envelope delivered from Gateway or the embedded backend into the TUI. */ export type TuiEvent = { event: string; payload?: unknown; seq?: number; }; +/** Session-list payload rendered by session pickers and status surfaces. */ export type TuiSessionList = { ts: number; path: string; @@ -87,6 +92,7 @@ export type TuiSessionList = { >; }; +/** Agent-list payload used by TUI agent switching. */ export type TuiAgentsList = { defaultId: string; mainKey: string; @@ -97,6 +103,7 @@ export type TuiAgentsList = { }>; }; +/** Model choice payload shown by TUI model pickers. */ export type TuiModelChoice = { id: string; name: string; @@ -105,6 +112,7 @@ export type TuiModelChoice = { reasoning?: boolean; }; +/** Result shape returned by session mutation commands. */ export type TuiSessionMutationResult = { ok?: boolean; key?: string; @@ -118,6 +126,7 @@ export type TuiSessionMutationResult = { }; }; +/** Minimal backend interface shared by Gateway and embedded local TUI modes. */ export type TuiBackend = { connection: { url: string; diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index 622fd34ff66a..b0b8ae1f93cf 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -6,6 +6,7 @@ import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.j import type { TuiSessionList } from "./tui-backend.js"; import type { SessionScope } from "./tui-types.js"; +// Persists the last human-selected TUI session per connection/agent/scope. type LastSessionRecord = { sessionKey: string; updatedAt: number; @@ -13,10 +14,12 @@ type LastSessionRecord = { type LastSessionStore = Record; +/** Resolves the private state file for remembered TUI sessions. */ export function resolveTuiLastSessionStatePath(stateDir = resolveStateDir()): string { return path.join(stateDir, "tui", "last-session.json"); } +/** Builds a stable private-store key for the current TUI connection, agent, and session scope. */ export function buildTuiLastSessionScopeKey(params: { connectionUrl: string; agentId: string; @@ -51,6 +54,7 @@ function isHeartbeatSessionKey(sessionKey: string): boolean { return normalizeMarker(sessionKey).endsWith(":heartbeat"); } +/** Detects heartbeat/system sessions that should not become the remembered human session. */ export function isHeartbeatLikeTuiSession(session: TuiSessionList["sessions"][number]): boolean { if (isHeartbeatSessionKey(session.key)) { return true; @@ -67,6 +71,7 @@ export function isHeartbeatLikeTuiSession(session: TuiSessionList["sessions"][nu return markers.some((marker) => normalizeMarker(marker) === "heartbeat"); } +/** Reads the remembered session key for a scope, ignoring missing or malformed stores. */ export async function readTuiLastSessionKey(params: { scopeKey: string; stateDir?: string; @@ -76,6 +81,7 @@ export async function readTuiLastSessionKey(params: { return typeof value === "string" && value.trim() ? value.trim() : null; } +/** Writes the remembered session key unless it is empty, unknown, or heartbeat-owned. */ export async function writeTuiLastSessionKey(params: { scopeKey: string; sessionKey: string; @@ -96,6 +102,7 @@ export async function writeTuiLastSessionKey(params: { }); } +/** Resolves a remembered key to a currently listed session for the active agent. */ export function resolveRememberedTuiSessionKey(params: { rememberedKey: string | null | undefined; currentAgentId: string; @@ -114,6 +121,7 @@ export function resolveRememberedTuiSessionKey(params: { return null; } const rememberedRest = parsed?.rest ?? rememberedKey; + // Agent-prefixed and bare keys can refer to the same session; compare the session rest too. const match = params.sessions.find((session) => { if (isHeartbeatLikeTuiSession(session)) { return false; diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts index df459b261821..743fb47f3d23 100644 --- a/src/tui/tui-launch.ts +++ b/src/tui/tui-launch.ts @@ -5,6 +5,7 @@ import { attachChildProcessBridge } from "../process/child-process-bridge.js"; import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js"; import type { TuiOptions } from "./tui.js"; +// Relaunch helper used when setup wants to hand control to an inherited-stdio TUI process. type TuiLaunchOptions = { authSource?: "config"; gatewayUrl?: string; @@ -21,6 +22,7 @@ function filterTuiExecArgv(execArgv: readonly string[]): string[] { const filtered: string[] = []; for (let index = 0; index < execArgv.length; index += 1) { const arg = execArgv[index] ?? ""; + // Strip inspector flags so a relaunched TUI does not fight the parent debug port. if ( arg === "--inspect" || arg.startsWith("--inspect=") || @@ -77,6 +79,7 @@ function buildTuiCliArgs(opts: TuiOptions): string[] { return args; } +/** Launches a child TUI process with inherited stdio and setup-specific environment hints. */ export async function launchTuiCli( opts: TuiOptions, launchOptions: TuiLaunchOptions = {}, @@ -95,6 +98,7 @@ export async function launchTuiCli( const stdinWasPaused = typeof process.stdin.isPaused === "function" ? process.stdin.isPaused() : false; + // Pause parent stdin while the child owns the terminal, then restore the previous state. process.stdin.pause(); await new Promise((resolve, reject) => { diff --git a/src/tui/tui-overlays.ts b/src/tui/tui-overlays.ts index fc9fa42849e1..1596607c3e7b 100644 --- a/src/tui/tui-overlays.ts +++ b/src/tui/tui-overlays.ts @@ -1,7 +1,9 @@ import type { Component, TUI } from "@earendil-works/pi-tui"; +// Small adapter around pi-tui overlay focus behavior. type OverlayHost = Pick; +/** Creates open/close handlers that restore focus when no overlay is active. */ export function createOverlayHandlers(host: OverlayHost, fallbackFocus: Component) { const openOverlay = (component: Component) => { host.showOverlay(component); diff --git a/src/tui/tui-pty-test-support.ts b/src/tui/tui-pty-test-support.ts index ad49ac87bde4..65ac476fc0a9 100644 --- a/src/tui/tui-pty-test-support.ts +++ b/src/tui/tui-pty-test-support.ts @@ -2,6 +2,7 @@ import { appendFileSync } from "node:fs"; import * as nodePty from "@lydell/node-pty"; import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty"; +// Shared PTY harness utilities for fake-backend and local TUI smoke tests. type NodePtyRuntimeModule = typeof nodePty & { default?: Partial; }; @@ -10,6 +11,7 @@ type KillablePtyHandle = PtyHandle & { kill?: (signal?: string) => void; }; +/** Handle returned by PTY tests for input, output waits, and cleanup. */ export type PtyRun = { output: () => string; write: (data: string, opts?: { delay?: boolean }) => Promise; @@ -18,6 +20,7 @@ export type PtyRun = { dispose: () => void; }; +/** Polls until a reader returns a value or the timeout expires. */ export function waitFor(params: { timeoutMs: number; read: () => T | null; @@ -47,6 +50,7 @@ export function waitFor(params: { }); } +/** Async sleep used to simulate slower PTY typing. */ export function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -86,6 +90,7 @@ async function writePtyInput( return; } const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1; + // Chunked writes reproduce paste/type races without making every PTY test slow by default. for (let idx = 0; idx < data.length; idx += chunkSize) { pty.write(data.slice(idx, idx + chunkSize)); if (idx + chunkSize < data.length) { @@ -102,6 +107,7 @@ function mirrorPtyOutput(data: string) { appendFileSync(mirrorPath, data, "utf8"); } +/** Starts a PTY process and exposes deterministic output/exit wait helpers. */ export function startPty( command: string, args: string[], diff --git a/src/tui/tui-session-list-policy.ts b/src/tui/tui-session-list-policy.ts index 68965ac01091..e49142c3f70f 100644 --- a/src/tui/tui-session-list-policy.ts +++ b/src/tui/tui-session-list-policy.ts @@ -1,3 +1,4 @@ +// Session picker limits shared by TUI list and lookup flows. export const TUI_RECENT_SESSIONS_ACTIVE_MINUTES = 7 * 24 * 60; export const TUI_SESSION_PICKER_LIMIT = 50; export const TUI_SESSION_LOOKUP_LIMIT = 5; diff --git a/src/tui/tui-status-summary.ts b/src/tui/tui-status-summary.ts index dcbcd00329d2..69de9aa9d197 100644 --- a/src/tui/tui-status-summary.ts +++ b/src/tui/tui-status-summary.ts @@ -3,6 +3,7 @@ import { formatTokenCount } from "../utils/usage-format.js"; import { formatContextUsageLine } from "./tui-formatters.js"; import type { GatewayStatusSummary } from "./tui-types.js"; +/** Formats Gateway/session health into compact status lines for the TUI. */ export function formatStatusSummary(summary: GatewayStatusSummary) { const lines: string[] = []; lines.push("Gateway status"); @@ -66,6 +67,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) { if (recent.length > 0) { lines.push("Recent sessions:"); for (const entry of recent) { + // Keep each recent session on one scan-friendly line for narrow terminal output. const ageLabel = typeof entry.age === "number" ? formatTimeAgo(entry.age) : "no activity"; const model = entry.model ?? "unknown"; const usage = formatContextUsageLine({ diff --git a/src/tui/tui-stream-assembler.ts b/src/tui/tui-stream-assembler.ts index 89e9efd80831..fe640f7cff88 100644 --- a/src/tui/tui-stream-assembler.ts +++ b/src/tui/tui-stream-assembler.ts @@ -5,6 +5,7 @@ import { resolveFinalAssistantText, } from "./tui-formatters.js"; +// Per-run state used to merge streaming deltas with final assistant messages. type RunStreamState = { thinkingText: string; contentText: string; @@ -15,6 +16,7 @@ type RunStreamState = { type BoundaryDropMode = "off" | "streamed-only" | "streamed-or-incoming"; +// Pull text blocks out of provider-style content arrays while remembering non-text blocks. function extractTextBlocksAndSignals(message: unknown): { textBlocks: string[]; sawNonTextContentBlocks: boolean; @@ -57,6 +59,7 @@ function extractTextBlocksAndSignals(message: unknown): { return { textBlocks, sawNonTextContentBlocks }; } +// Detects final messages that dropped streamed boundary text around a non-text block. function isDroppedBoundaryTextBlockSubset(params: { streamedTextBlocks: string[]; finalTextBlocks: string[]; @@ -77,6 +80,7 @@ function isDroppedBoundaryTextBlockSubset(params: { return finalTextBlocks.every((block, index) => streamedTextBlocks[suffixStart + index] === block); } +// Some providers omit text adjacent to images/files in the final message; preserve streamed text. function shouldPreserveBoundaryDroppedText(params: { boundaryDropMode: BoundaryDropMode; streamedSawNonTextContentBlocks: boolean; @@ -100,6 +104,7 @@ function shouldPreserveBoundaryDroppedText(params: { }); } +/** Assembles assistant stream deltas and final messages into stable TUI display text. */ export class TuiStreamAssembler { private runs = new Map(); @@ -160,6 +165,7 @@ export class TuiStreamAssembler { state.displayText = displayText; } + /** Ingests a streaming delta and returns updated display text only when it changed. */ ingestDelta(runId: string, message: unknown, showThinking: boolean): string | null { const state = this.getOrCreateRun(runId); const previousDisplayText = state.displayText; @@ -174,6 +180,7 @@ export class TuiStreamAssembler { return state.displayText; } + /** Finalizes a run, combines any error text, and drops stored stream state. */ finalize(runId: string, message: unknown, showThinking: boolean, errorMessage?: string): string { const state = this.getOrCreateRun(runId); const streamedDisplayText = state.displayText; @@ -199,6 +206,7 @@ export class TuiStreamAssembler { return finalText; } + /** Drops stored stream state for an aborted or discarded run. */ drop(runId: string) { this.runs.delete(runId); } diff --git a/src/tui/tui-submit-test-helpers.ts b/src/tui/tui-submit-test-helpers.ts index a701a96c7162..1ee5ceb28824 100644 --- a/src/tui/tui-submit-test-helpers.ts +++ b/src/tui/tui-submit-test-helpers.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import { createEditorSubmitHandler } from "./tui-submit.js"; +// Test harness for submit-handler specs without constructing a full TUI. type MockFn = ReturnType; type SubmitHarness = { @@ -16,6 +17,7 @@ type SubmitHarness = { onSubmit: (text: string) => void; }; +/** Creates editor/command/message mocks wired to the real submit handler. */ export function createSubmitHarness(params?: { canSubmitMessage?: (value: string) => boolean; }): SubmitHarness { diff --git a/src/tui/tui-waiting.ts b/src/tui/tui-waiting.ts index acf810ef3f1f..d6dd8797f953 100644 --- a/src/tui/tui-waiting.ts +++ b/src/tui/tui-waiting.ts @@ -1,9 +1,11 @@ +// Waiting-status helpers kept pure so animation text can be tested without a TUI. type MinimalTheme = { dim: (s: string) => string; bold: (s: string) => string; accentSoft: (s: string) => string; }; +/** Default phrase cycle for animated waiting status. */ export const defaultWaitingPhrases = [ "flibbertigibbeting", "kerfuffling", @@ -17,11 +19,13 @@ export const defaultWaitingPhrases = [ "conjuring", ]; +/** Picks a stable phrase for a timer tick. */ export function pickWaitingPhrase(tick: number, phrases = defaultWaitingPhrases) { const idx = Math.floor(tick / 10) % phrases.length; return phrases[idx] ?? phrases[0] ?? "waiting"; } +/** Applies a moving highlight window to status text. */ export function shimmerText(theme: MinimalTheme, text: string, tick: number) { const width = 6; const hi = (ch: string) => theme.bold(theme.accentSoft(ch)); @@ -38,6 +42,7 @@ export function shimmerText(theme: MinimalTheme, text: string, tick: number) { return out; } +/** Builds the single-line waiting status shown while a TUI run is active. */ export function buildWaitingStatusMessage(params: { theme: MinimalTheme; tick: number; diff --git a/src/types/create-markdown-preview.d.ts b/src/types/create-markdown-preview.d.ts index 88a9ce8fcbdf..99063224375a 100644 --- a/src/types/create-markdown-preview.d.ts +++ b/src/types/create-markdown-preview.d.ts @@ -1,7 +1,10 @@ +/** Ambient types for the create-markdown preview package used by docs rendering. */ declare module "@create-markdown/preview" { + /** Theme options accepted by the preview renderer. */ export type PreviewThemeOptions = { sanitize?: ((html: string) => string) | undefined; }; + /** Apply the package's preview theme to an HTML string. */ export function applyPreviewTheme(html: string, options?: PreviewThemeOptions): string; } diff --git a/src/types/lydell-node-pty.d.ts b/src/types/lydell-node-pty.d.ts index be7c40b76422..42902f6e5491 100644 --- a/src/types/lydell-node-pty.d.ts +++ b/src/types/lydell-node-pty.d.ts @@ -1,6 +1,10 @@ +/** Minimal ambient types for the @lydell/node-pty runtime dependency. */ declare module "@lydell/node-pty" { + /** Exit event emitted by a PTY handle. */ export type PtyExitEvent = { exitCode: number; signal?: number }; + /** Listener callback shape used by the PTY API. */ export type PtyListener = (event: T) => void; + /** Spawned PTY handle used by terminal-backed runtimes. */ export type PtyHandle = { pid: number; write: (data: string | Buffer) => void; @@ -8,6 +12,7 @@ declare module "@lydell/node-pty" { onExit: (listener: PtyListener) => void; }; + /** PTY spawn function signature consumed by OpenClaw. */ export type PtySpawn = ( file: string, args: string[] | string, @@ -20,5 +25,6 @@ declare module "@lydell/node-pty" { }, ) => PtyHandle; + /** Spawn a PTY-backed child process. */ export const spawn: PtySpawn; } diff --git a/src/types/microsoft-teams-sdk.d.ts b/src/types/microsoft-teams-sdk.d.ts index 15397f771650..21b1282652fe 100644 --- a/src/types/microsoft-teams-sdk.d.ts +++ b/src/types/microsoft-teams-sdk.d.ts @@ -1,4 +1,6 @@ +/** Minimal ambient types for Microsoft Teams SDK packages used by the Teams plugin. */ declare module "@microsoft/teams.apps" { + /** Teams app auth helper used to fetch bot and Graph tokens. */ export class App { constructor(options: { clientId: string; clientSecret: string; tenantId?: string }); @@ -8,6 +10,7 @@ declare module "@microsoft/teams.apps" { } declare module "@microsoft/teams.api" { + /** Teams API client subset used for conversation activity sends. */ export class Client { constructor( serviceUrl: string, diff --git a/src/types/modelcontextprotocol-sdk-subpaths.d.ts b/src/types/modelcontextprotocol-sdk-subpaths.d.ts index db3c0d6f8128..6ebe61e3d864 100644 --- a/src/types/modelcontextprotocol-sdk-subpaths.d.ts +++ b/src/types/modelcontextprotocol-sdk-subpaths.d.ts @@ -1,10 +1,13 @@ +/** Ambient subpath type shim for MCP SDK streamable HTTP server transport. */ declare module "@modelcontextprotocol/sdk/server/streamableHttp.js" { import type { IncomingMessage, ServerResponse } from "node:http"; + /** Options accepted by the streamable HTTP transport constructor. */ export type StreamableHTTPServerTransportOptions = { sessionIdGenerator?: (() => string) | undefined; }; + /** Server transport subset consumed by OpenClaw's MCP HTTP surfaces. */ export class StreamableHTTPServerTransport { constructor(options?: StreamableHTTPServerTransportOptions); get sessionId(): string | undefined; diff --git a/src/types/node-edge-tts.d.ts b/src/types/node-edge-tts.d.ts index b800c986cb8c..9d69ccb1488a 100644 --- a/src/types/node-edge-tts.d.ts +++ b/src/types/node-edge-tts.d.ts @@ -1,4 +1,6 @@ +/** Minimal ambient types for node-edge-tts voice synthesis. */ declare module "node-edge-tts" { + /** Options passed to the Edge TTS wrapper. */ export type EdgeTTSOptions = { voice?: string; lang?: string; @@ -11,6 +13,7 @@ declare module "node-edge-tts" { timeout?: number; }; + /** Edge TTS class subset used by OpenClaw audio generation. */ export class EdgeTTS { constructor(options?: EdgeTTSOptions); ttsPromise(text: string, outputPath: string): Promise; @@ -18,7 +21,10 @@ declare module "node-edge-tts" { } declare module "node-edge-tts/dist/drm.js" { + /** Chromium version constant required by the upstream token generator. */ export const CHROMIUM_FULL_VERSION: string; + /** Trusted client token required by the upstream token generator. */ export const TRUSTED_CLIENT_TOKEN: string; + /** Generate the DRM token needed by Edge TTS requests. */ export function generateSecMsGecToken(): string; } diff --git a/src/types/node-llama-cpp.d.ts b/src/types/node-llama-cpp.d.ts index 03eed42554ae..8286cbe0902d 100644 --- a/src/types/node-llama-cpp.d.ts +++ b/src/types/node-llama-cpp.d.ts @@ -1,15 +1,20 @@ +/** Minimal ambient types for node-llama-cpp embedding support. */ declare module "node-llama-cpp" { + /** Log levels used when initializing llama.cpp. */ export enum LlamaLogLevel { error = 0, } + /** Embedding vector returned by llama.cpp. */ export type LlamaEmbedding = { vector: Float32Array | number[] }; + /** Embedding context subset used for local memory/vector features. */ export type LlamaEmbeddingContext = { getEmbeddingFor: (text: string) => Promise; dispose?: () => Promise | void; }; + /** Loaded model subset used to create embedding contexts. */ export type LlamaModel = { createEmbeddingContext: (options?: { contextSize?: number | "auto"; @@ -18,17 +23,21 @@ declare module "node-llama-cpp" { dispose?: () => Promise | void; }; + /** Options accepted by model-file resolution. */ export type ResolveModelFileOptions = { directory?: string; signal?: AbortSignal; }; + /** Top-level llama.cpp runtime subset used by OpenClaw. */ export type Llama = { loadModel: (params: { modelPath: string; loadSignal?: AbortSignal }) => Promise; dispose?: () => Promise | void; }; + /** Initialize the llama.cpp runtime. */ export function getLlama(params: { logLevel: LlamaLogLevel }): Promise; + /** Resolve a model file path from a directory or options object. */ export function resolveModelFile( modelPath: string, optionsOrDirectory?: string | ResolveModelFileOptions, diff --git a/src/types/qrcode.d.ts b/src/types/qrcode.d.ts index 6f9e883ac11d..353d2824e1f7 100644 --- a/src/types/qrcode.d.ts +++ b/src/types/qrcode.d.ts @@ -1,4 +1,6 @@ +/** Minimal ambient types for the qrcode package. */ declare module "qrcode" { + /** Error correction level accepted by qrcode renderers. */ export type QrCodeErrorCorrectionLevel = | "L" | "M" @@ -9,11 +11,13 @@ declare module "qrcode" { | "quartile" | "high"; + /** Foreground/background color options for rendered QR codes. */ export type QrCodeColorOptions = { dark?: string; light?: string; }; + /** Shared QR render options used by string, data URL, and file outputs. */ export type QrCodeRenderOptions = { color?: QrCodeColorOptions; errorCorrectionLevel?: QrCodeErrorCorrectionLevel; @@ -24,6 +28,7 @@ declare module "qrcode" { width?: number; }; + /** Symbol matrix returned by qrcode.create. */ export type QrCodeSymbol = { modules: { data: ArrayLike; @@ -31,15 +36,20 @@ declare module "qrcode" { }; }; + /** Create an in-memory QR symbol. */ export function create(text: string, options?: QrCodeRenderOptions): QrCodeSymbol; + /** Render a QR code to a string format. */ export function toString(text: string, options?: QrCodeRenderOptions): Promise; + /** Render a QR code to a data URL. */ export function toDataURL(text: string, options?: QrCodeRenderOptions): Promise; + /** Render a QR code to a file. */ export function toFile( filePath: string, text: string, options?: QrCodeRenderOptions, ): Promise; + /** Default qrcode export with the functions OpenClaw uses. */ const qrcode: { create: typeof create; toString: typeof toString; diff --git a/src/types/web-push.d.ts b/src/types/web-push.d.ts index c1c742f79a9f..bde5e55765d2 100644 --- a/src/types/web-push.d.ts +++ b/src/types/web-push.d.ts @@ -1,4 +1,6 @@ +/** Minimal ambient types for the web-push package. */ declare module "web-push" { + /** Browser push subscription payload used by web-push. */ export type PushSubscription = { endpoint: string; keys: { @@ -7,21 +9,26 @@ declare module "web-push" { }; }; + /** Result returned after a push notification send attempt. */ export type SendResult = { statusCode: number; body: string; headers: Record; }; + /** VAPID public/private key pair. */ export type VAPIDKeys = { publicKey: string; privateKey: string; }; + /** Generate a VAPID key pair. */ export function generateVAPIDKeys(): VAPIDKeys; + /** Configure VAPID details before sending notifications. */ export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void; + /** Send one web push notification. */ export function sendNotification( subscription: PushSubscription, payload?: string | Buffer | null, diff --git a/src/utils/account-id.ts b/src/utils/account-id.ts index 21287c84b760..42a867c74c9b 100644 --- a/src/utils/account-id.ts +++ b/src/utils/account-id.ts @@ -1,5 +1,12 @@ import { normalizeOptionalAccountId } from "../routing/account-id.js"; +/** + * Compatibility wrapper for account-id normalization. + * + * Runtime code imports this utility when it needs the older utils path while + * the canonical normalization logic lives in routing/account-id. + */ +/** Normalize an optional account id, returning undefined for blank/invalid input. */ export function normalizeAccountId(value?: string): string | undefined { return normalizeOptionalAccountId(value); } diff --git a/src/utils/boolean.ts b/src/utils/boolean.ts index 2ee5a5d3dcb5..d204b2470b72 100644 --- a/src/utils/boolean.ts +++ b/src/utils/boolean.ts @@ -1,8 +1,17 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +/** + * Shared boolean coercion helpers for config, env, and plugin SDK runtime inputs. + * + * `asBoolean` is intentionally strict; string parsing is opt-in through + * `parseBooleanValue` so schema callers do not silently accept ambiguous text. + */ + /** Accepted string literals for boolean parsing beyond actual booleans. */ export type BooleanParseOptions = { + /** Lowercase string values that should parse as true. */ truthy?: string[]; + /** Lowercase string values that should parse as false. */ falsy?: string[]; }; diff --git a/src/utils/delivery-context.shared.ts b/src/utils/delivery-context.shared.ts index 8d58a986bdf1..3f26c99a00ea 100644 --- a/src/utils/delivery-context.shared.ts +++ b/src/utils/delivery-context.shared.ts @@ -16,6 +16,14 @@ import { normalizeMessageChannel } from "./message-channel-core.js"; import { isDeliverableMessageChannel } from "./message-channel-normalize.js"; export type { DeliveryContext, DeliveryContextSessionSource } from "./delivery-context.types.js"; +/** + * Delivery-context normalization and projection helpers. + * + * Sessions still carry route metadata plus older `last*` fields; this module + * keeps those shapes converged on the canonical SDK channel-route contract. + */ + +/** Normalizes a delivery context into canonical channel route fields, dropping invalid routes. */ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryContext | undefined { if (!context) { return undefined; @@ -44,6 +52,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon return normalized; } +/** Normalizes an unknown channel route payload from persisted session/plugin metadata. */ export function normalizeDeliveryChannelRoute(route?: unknown): ChannelRouteRef | undefined { if (!route || typeof route !== "object" || Array.isArray(route)) { return undefined; @@ -61,6 +70,7 @@ export function normalizeDeliveryChannelRoute(route?: unknown): ChannelRouteRef }); } +/** Converts a normalized channel route reference into a delivery context. */ export function deliveryContextFromChannelRoute( route?: ChannelRouteRef, ): DeliveryContext | undefined { @@ -73,6 +83,7 @@ export function deliveryContextFromChannelRoute( }); } +/** Converts delivery context fields into the SDK channel route reference shape. */ export function channelRouteFromDeliveryContext( context?: DeliveryContext, ): ChannelRouteRef | undefined { @@ -114,6 +125,9 @@ function mergeExternalDeliveryContextOverInternalRoute( deliveryContext?: DeliveryContext, internalContext?: DeliveryContext, ): DeliveryContext | undefined { + // Internal webchat/heartbeat routes are session plumbing. When a real channel + // target is also present, preserve internal account/thread hints but let the + // external channel/to pair own delivery. return normalizeDeliveryContext({ channel: deliveryContext?.channel, to: deliveryContext?.to, @@ -122,6 +136,7 @@ function mergeExternalDeliveryContextOverInternalRoute( }); } +/** Reconciles legacy session delivery fields, route metadata, and explicit delivery context. */ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): { route?: ChannelRouteRef; deliveryContext?: DeliveryContext; @@ -150,11 +165,15 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo threadId: source.lastThreadId, }); const deliveryContext = normalizeDeliveryContext(source.deliveryContext); + // Legacy webchat `last*` fields can outlive the external channel that should + // receive replies. Prefer an explicit deliverable context when it exists. const sessionContext = isInternalRouteContext(legacyContext) && hasExternalDeliveryTarget(deliveryContext) ? mergeExternalDeliveryContextOverInternalRoute(deliveryContext, legacyContext) : mergeDeliveryContext(legacyContext, deliveryContext); const routeInternalContext = mergeDeliveryContext(routeContext, legacyContext); + // Route metadata normally wins, except for internal fallback routes paired + // with an explicit external delivery target from newer session state. const routeIsInternalFallback = isInternalRouteContext(routeContext) && hasExternalDeliveryTarget(deliveryContext); const merged = routeIsInternalFallback @@ -185,6 +204,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo }; } +/** Derives the best delivery context from current and legacy session fields. */ export function deliveryContextFromSession( entry?: DeliveryContextSessionSource, ): DeliveryContext | undefined { @@ -204,6 +224,7 @@ export function deliveryContextFromSession( return normalizeSessionDeliveryFields(source).deliveryContext; } +/** Merges delivery contexts without mixing target/account/thread fields across channels. */ export function mergeDeliveryContext( primary?: DeliveryContext, fallback?: DeliveryContext, @@ -233,6 +254,7 @@ export function mergeDeliveryContext( }); } +/** Builds a compact stable key for a routable delivery context. */ export function deliveryContextKey(context?: DeliveryContext): string | undefined { return channelRouteCompactKey(normalizeDeliveryContext(context)); } diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 1a7eb8e25c52..41b6f0f0a1bb 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -40,6 +40,7 @@ function normalizeConversationTargetParams(params: ConversationTargetParams): { return { channel, conversationId, parentConversationId }; } +/** Formats a conversation id into a deliverable target, using plugin hooks before generic fallback. */ export function formatConversationTarget(params: ConversationTargetParams): string | undefined { const { channel, conversationId, parentConversationId } = normalizeConversationTargetParams(params); @@ -58,6 +59,7 @@ export function formatConversationTarget(params: ConversationTargetParams): stri return `channel:${conversationId}`; } +/** Resolves a channel conversation into target/thread fields for delivery routing. */ export function resolveConversationDeliveryTarget(params: { channel?: string; conversationId?: string | number; diff --git a/src/utils/delivery-context.types.ts b/src/utils/delivery-context.types.ts index a3feccaa7e20..67d3f30979b1 100644 --- a/src/utils/delivery-context.types.ts +++ b/src/utils/delivery-context.types.ts @@ -1,33 +1,52 @@ import type { ChannelRouteRef, ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; +/** Deferred outbound delivery intent attached to a session or task. */ export type DeliveryIntentRef = { + /** Stable queue/work item id. */ id: string; + /** Intent family; currently scoped to outbound queue delivery. */ kind: "outbound_queue"; + /** Whether queueing is mandatory or best-effort for this delivery. */ queuePolicy?: "required" | "best_effort"; }; +/** Canonical channel delivery target shared by sessions, cron, tasks, and plugins. */ export type DeliveryContext = Pick< ChannelRouteTargetInput, "accountId" | "channel" | "threadId" | "to" > & { + /** Channel/plugin id that owns the delivery target. */ channel?: string; + /** Channel-local destination id, preserved with channel-specific casing. */ to?: string; + /** Optional channel account/workspace id. */ accountId?: string; + /** Optional thread/topic id nested under `to`. */ threadId?: string | number; + /** Optional queued-delivery intent associated with this context. */ deliveryIntent?: DeliveryIntentRef; }; +/** Mixed legacy and modern session fields used to reconstruct a delivery context. */ export type DeliveryContextSessionSource = { + /** Modern SDK route metadata, preferred when present and routable. */ route?: ChannelRouteRef; + /** Original/current session channel; may be an internal channel such as webchat. */ channel?: string; + /** Legacy mirrored delivery channel. */ lastChannel?: string; + /** Legacy mirrored delivery target. */ lastTo?: string; + /** Legacy mirrored account/workspace id. */ lastAccountId?: string; + /** Legacy mirrored thread/topic id. */ lastThreadId?: string | number; + /** Older origin fields emitted before delivery context became canonical. */ origin?: { provider?: string; accountId?: string; threadId?: string | number; }; + /** Canonical delivery context stored on newer session records. */ deliveryContext?: DeliveryContext; }; diff --git a/src/utils/fetch-timeout.ts b/src/utils/fetch-timeout.ts index ecde110e8b2b..3e9fd7d6d7f6 100644 --- a/src/utils/fetch-timeout.ts +++ b/src/utils/fetch-timeout.ts @@ -31,6 +31,8 @@ function sanitizeTimeoutLogUrl(rawUrl: string | undefined): string | undefined { return undefined; } try { + // Strip credentials, query, and fragment before logging; timeout URLs often + // include provider tokens or signed request parameters. const parsed = new URL(trimmed); parsed.username = ""; parsed.password = ""; @@ -67,6 +69,8 @@ function abortDueToTimeout( const sanitizedUrl = sanitizeTimeoutLogUrl(url); const elapsedMs = Math.max(0, Date.now() - startedAtMs); const delayMs = Math.max(0, elapsedMs - timeoutMs); + // A large elapsed/timeout gap means the timer callback itself was starved, + // which is more useful for operators than another plain timeout message. const eventLoopDelayHint = delayMs >= Math.max(1000, timeoutMs * 0.5) ? `timer delayed ${delayMs}ms, likely event-loop starvation` @@ -93,6 +97,10 @@ function abortDueToTimeout( controller.abort(error); } +/** + * Builds an abort signal that combines an optional parent signal with a timeout. + * Callers must run `cleanup`; `refresh` restarts only the internal timeout timer. + */ export function buildTimeoutAbortSignal(params: TimeoutAbortSignalParams): { signal?: AbortSignal; cleanup: () => void; diff --git a/src/utils/message-channel-core.ts b/src/utils/message-channel-core.ts index dbe28900b012..05720298ab89 100644 --- a/src/utils/message-channel-core.ts +++ b/src/utils/message-channel-core.ts @@ -3,6 +3,14 @@ import { normalizeChatChannelId } from "../channels/ids.js"; import { normalizeAnyChannelId } from "../channels/registry-normalize.js"; import { INTERNAL_MESSAGE_CHANNEL } from "./message-channel-constants.js"; +/** + * Shared message-channel normalization for delivery, routing, config, and gateway headers. + * + * Built-in aliases normalize through channel ids, while plugin-owned channel ids + * stay accepted even when core has no bundled alias for them. + */ + +/** Normalizes raw channel names, aliases, and internal webchat into canonical ids. */ export function normalizeMessageChannel(raw?: string | null): string | undefined { const normalized = normalizeOptionalLowercaseString(raw); if (!normalized) { @@ -15,9 +23,12 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined if (builtIn) { return builtIn; } + // Preserve unknown-but-normalized ids so external plugin channels can route + // before their full runtime is loaded. return normalizeAnyChannelId(normalized) ?? normalized; } +/** Returns true only when a value is already a normalized, non-internal delivery channel id. */ export function isDeliverableMessageChannel(value: string): boolean { const normalized = normalizeMessageChannel(value); return ( diff --git a/src/utils/message-channel-normalize.ts b/src/utils/message-channel-normalize.ts index 1bc41974f8ff..d883cda5e29e 100644 --- a/src/utils/message-channel-normalize.ts +++ b/src/utils/message-channel-normalize.ts @@ -9,10 +9,13 @@ import { normalizeMessageChannel as normalizeMessageChannelCore } from "./messag type ChannelId = string & { readonly __openclawChannelIdBrand?: never }; +/** Channel id that can receive outbound messages from the Gateway. */ export type DeliverableMessageChannel = ChannelId; +/** Channel id accepted by Gateway protocol routing, including internal webchat. */ export type GatewayMessageChannel = DeliverableMessageChannel; +/** Normalizes built-in, plugin, and alias channel names to their canonical id. */ export function normalizeMessageChannel(raw?: string | null): string | undefined { return normalizeMessageChannelCore(raw); } @@ -21,6 +24,7 @@ const listPluginChannelIds = (): string[] => { return listRegisteredChannelPluginIds(); }; +/** Lists built-in and registered plugin channel ids that can receive delivery. */ export const listDeliverableMessageChannels = (): ChannelId[] => uniqueStrings([...CHANNEL_IDS, ...listPluginChannelIds()]) as ChannelId[]; @@ -29,14 +33,17 @@ const listGatewayMessageChannels = (): GatewayMessageChannel[] => [ INTERNAL_MESSAGE_CHANNEL, ]; +/** Returns whether a normalized id is valid for Gateway routing. */ export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel { return listGatewayMessageChannels().includes(value as GatewayMessageChannel); } +/** Returns whether a normalized id is a deliverable non-internal channel. */ export function isDeliverableMessageChannel(value: string): value is DeliverableMessageChannel { return listDeliverableMessageChannels().includes(value as DeliverableMessageChannel); } +/** Normalizes and validates a raw channel value for Gateway routing. */ export function resolveGatewayMessageChannel( raw?: string | null, ): GatewayMessageChannel | undefined { @@ -47,6 +54,7 @@ export function resolveGatewayMessageChannel( return isGatewayMessageChannel(normalized) ? normalized : undefined; } +/** Normalizes the primary channel or falls back to a secondary channel value. */ export function resolveMessageChannel( primary?: string | null, fallback?: string | null, diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index ebf21ccf3de8..91407812f40f 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -31,6 +31,12 @@ import { } from "./message-channel-constants.js"; import { normalizeMessageChannel } from "./message-channel-normalize.js"; +/** + * Message channel and Gateway client classification helpers. + * + * This module keeps channel normalization, client identity checks, and markdown + * capability lookup in one place for send/render decisions. + */ export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES }; export type { GatewayClientName, GatewayClientMode }; export { normalizeGatewayClientName, normalizeGatewayClientMode }; @@ -40,24 +46,29 @@ type GatewayClientInfoLike = { id?: string | null; }; +/** Return whether a Gateway client is the CLI transport. */ export function isGatewayCliClient(client?: GatewayClientInfoLike | null): boolean { return normalizeGatewayClientMode(client?.mode) === GATEWAY_CLIENT_MODES.CLI; } +/** Return whether a client is one of the operator UI clients. */ export function isOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || clientId === GATEWAY_CLIENT_NAMES.TUI; } +/** Return whether a client is the browser Control UI. */ export function isBrowserOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI; } +/** Return whether a raw channel id resolves to OpenClaw's internal channel. */ export function isInternalMessageChannel(raw?: string | null): raw is InternalMessageChannel { return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL; } +/** Return whether a Gateway client is the public webchat surface. */ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean { const mode = normalizeGatewayClientMode(client?.mode); if (mode === GATEWAY_CLIENT_MODES.WEBCHAT) { @@ -66,6 +77,7 @@ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.WEBCHAT_UI; } +/** Resolve whether a channel can receive markdown without plain-text downgrade. */ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean { const channel = normalizeMessageChannel(raw); if (!channel) { @@ -80,6 +92,7 @@ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean { if (builtInMeta) { return builtInMeta.markdownCapable === true; } + // Catalog metadata covers bundled channels whose runtime plugin is not loaded yet. const catalogMeta = listBundledChannelCatalogEntries().find( (entry) => entry.id === builtInChannel, ); diff --git a/src/utils/normalize-secret-input.ts b/src/utils/normalize-secret-input.ts index 07cca90a556c..7dc698378445 100644 --- a/src/utils/normalize-secret-input.ts +++ b/src/utils/normalize-secret-input.ts @@ -13,6 +13,10 @@ * Intentionally does NOT remove ordinary spaces inside the string to avoid * silently altering "Bearer " style values. */ +/** + * Normalizes a raw secret value from config, env, setup prompts, or plugin SDK callers. + * Returns an empty string for absent/invalid input so callers can keep boolean presence checks simple. + */ export function normalizeSecretInput(value: unknown): string { if (typeof value !== "string") { return ""; @@ -28,6 +32,10 @@ export function normalizeSecretInput(value: unknown): string { return latin1Only.trim(); } +/** + * Normalizes a raw secret value and converts empty normalized output to `undefined`. + * Use this at optional config boundaries where "not configured" is clearer than an empty string. + */ export function normalizeOptionalSecretInput(value: unknown): string | undefined { const normalized = normalizeSecretInput(value); return normalized ? normalized : undefined; diff --git a/src/utils/parse-json-compat.ts b/src/utils/parse-json-compat.ts index 2f5ab442526b..643c949f82f6 100644 --- a/src/utils/parse-json-compat.ts +++ b/src/utils/parse-json-compat.ts @@ -1,5 +1,10 @@ +/** + * JSON parser compatibility helper for persisted config, manifests, and legacy stores. + * Strict JSON stays the fast path; JSON5 is only the authored/legacy fallback. + */ import JSON5 from "json5"; +/** Parses strict JSON first, then accepts JSON5 syntax such as comments and trailing commas. */ export function parseJsonWithJson5Fallback(raw: string): unknown { try { return JSON.parse(raw); diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts index 1df67bd2bce2..3302a522ffba 100644 --- a/src/utils/provider-utils.ts +++ b/src/utils/provider-utils.ts @@ -1,3 +1,7 @@ +/** + * Provider behavior helpers shared by reply runners, embedded agents, and provider plugins. + * Keep policy here generic; provider-specific reasoning rules belong in provider runtime hooks. + */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js"; @@ -5,9 +9,9 @@ import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.typ import { resolveProviderReasoningOutputModeWithPlugin } from "../plugins/provider-runtime.js"; /** - * Utility functions for provider-specific logic and capabilities. + * Resolves whether a provider should emit reasoning via native fields or tagged text, + * using provider runtime hooks when available and defaulting to native output. */ - export function resolveReasoningOutputMode(params: { provider: string | undefined | null; config?: OpenClawConfig; @@ -23,6 +27,7 @@ export function resolveReasoningOutputMode(params: { return "native"; } + // Provider hooks own model/API-specific reasoning transport rules; core only supplies the default. const pluginMode = resolveProviderReasoningOutputModeWithPlugin({ provider, config: params.config, diff --git a/src/utils/queue-helpers.ts b/src/utils/queue-helpers.ts index 59a45488760f..89b9f2c7c7a2 100644 --- a/src/utils/queue-helpers.ts +++ b/src/utils/queue-helpers.ts @@ -1,21 +1,32 @@ +/** + * Shared queue overflow, debounce, and collection helpers. + * + * Queue owners use these helpers to cap pending work, summarize dropped items, + * debounce drains, and force individual collection when cross-channel ordering matters. + */ +/** Mutable summary state for a capped queue. */ export type QueueSummaryState = { dropPolicy: "summarize" | "old" | "new"; droppedCount: number; summaryLines: string[]; }; +/** Queue overflow strategy. */ export type QueueDropPolicy = QueueSummaryState["dropPolicy"]; +/** Generic capped queue state with shared overflow summary fields. */ export type QueueState = QueueSummaryState & { items: T[]; cap: number; }; +/** Clear accumulated overflow summary state after it has been emitted. */ export function clearQueueSummaryState(state: QueueSummaryState): void { state.droppedCount = 0; state.summaryLines = []; } +/** Build a summary prompt preview without mutating the source queue state. */ export function previewQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; @@ -32,6 +43,7 @@ export function previewQueueSummaryPrompt(params: { }); } +/** Apply runtime queue settings while preserving previous values for omitted fields. */ export function applyQueueRuntimeSettings(params: { target: { mode: TMode; @@ -58,6 +70,7 @@ export function applyQueueRuntimeSettings(params: { params.target.dropPolicy = params.settings.dropPolicy ?? params.target.dropPolicy; } +/** Trim queue summary text to a bounded single-line preview. */ export function elideQueueText(text: string, limit = 140): string { if (text.length <= limit) { return text; @@ -65,11 +78,13 @@ export function elideQueueText(text: string, limit = 140): string { return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; } +/** Normalize whitespace and elide one dropped item for queue summaries. */ export function buildQueueSummaryLine(text: string, limit = 160): string { const cleaned = text.replace(/\s+/g, " ").trim(); return elideQueueText(cleaned, limit); } +/** Run optional duplicate detection before an item enters a queue. */ export function shouldSkipQueueItem(params: { item: T; items: T[]; @@ -81,6 +96,7 @@ export function shouldSkipQueueItem(params: { return params.dedupe(params.item, params.items); } +/** Apply overflow policy before enqueueing another item. */ export function applyQueueDropPolicy(params: { queue: QueueState; summarize: (item: T) => string; @@ -102,6 +118,7 @@ export function applyQueueDropPolicy(params: { params.queue.droppedCount += 1; params.queue.summaryLines.push(buildQueueSummaryLine(params.summarize(item))); } + // Summary memory is bounded independently from the item cap to avoid prompt blowups. const limit = Math.max(0, params.summaryLimit ?? cap); while (params.queue.summaryLines.length > limit) { params.queue.summaryLines.shift(); @@ -110,11 +127,13 @@ export function applyQueueDropPolicy(params: { return true; } +/** Wait until the queue has been quiet for its debounce window. */ export function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number; }): Promise { if (process.env.OPENCLAW_TEST_FAST === "1") { + // Tests use this escape hatch so debounce logic does not slow deterministic queue specs. return Promise.resolve(); } const debounceMs = Math.max(0, queue.debounceMs); @@ -134,6 +153,7 @@ export function waitForQueueDebounce(queue: { }); } +/** Mark one queue as draining unless another drain is already active. */ export function beginQueueDrain( map: Map, key: string, @@ -146,6 +166,7 @@ export function beginQueueDrain( return queue; } +/** Run and remove the next queued item, returning false when empty. */ export async function drainNextQueueItem( items: T[], run: (item: T) => Promise, @@ -159,6 +180,7 @@ export async function drainNextQueueItem( return true; } +/** Drain one item when collect mode requires individual processing. */ export async function drainCollectItemIfNeeded(params: { forceIndividualCollect: boolean; isCrossChannel: boolean; @@ -170,12 +192,14 @@ export async function drainCollectItemIfNeeded(params: { return "skipped"; } if (params.isCrossChannel) { + // Once cross-channel items appear, future collection stays individual to preserve ordering. params.setForceIndividualCollect?.(true); } const drained = await drainNextQueueItem(params.items, params.run); return drained ? "drained" : "empty"; } +/** Drain one collect step using mutable queue collection state. */ export async function drainCollectQueueStep(params: { collectState: { forceIndividualCollect: boolean }; isCrossChannel: boolean; @@ -193,6 +217,7 @@ export async function drainCollectQueueStep(params: { }); } +/** Build and consume the queue overflow summary prompt. */ export function buildQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; @@ -216,6 +241,7 @@ export function buildQueueSummaryPrompt(params: { return lines.join("\n"); } +/** Render a collect prompt from queued items and optional overflow summary. */ export function buildCollectPrompt(params: { title: string; items: T[]; @@ -232,6 +258,7 @@ export function buildCollectPrompt(params: { return blocks.join("\n\n"); } +/** Return true when queued items span keys or explicitly mark cross-channel state. */ export function hasCrossChannelItems( items: T[], resolveKey: (item: T) => { key?: string; cross?: boolean }, diff --git a/src/utils/reaction-level.ts b/src/utils/reaction-level.ts index f2789d269391..6c2182d76607 100644 --- a/src/utils/reaction-level.ts +++ b/src/utils/reaction-level.ts @@ -1,5 +1,11 @@ +/** + * Shared reaction-level resolver for channel plugins that expose ACK and agent reaction controls. + * Channel adapters supply defaults/fallbacks; this helper owns the common flag expansion. + */ +/** User-configurable reaction behavior level for channel delivery. */ export type ReactionLevel = "off" | "ack" | "minimal" | "extensive"; +/** Expanded reaction flags consumed by runtime delivery and prompt guidance. */ export type ResolvedReactionLevel = { level: ReactionLevel; /** Whether ACK reactions (e.g., 👀 when processing) are enabled. */ @@ -12,6 +18,7 @@ export type ResolvedReactionLevel = { const LEVELS = new Set(["off", "ack", "minimal", "extensive"]); +/** Parses a raw config value while preserving missing vs invalid for fallback policy. */ function parseLevel( value: unknown, ): { kind: "missing" } | { kind: "invalid" } | { kind: "ok"; value: ReactionLevel } { @@ -31,6 +38,7 @@ function parseLevel( return { kind: "invalid" }; } +/** Resolves raw reaction config into ACK and agent-reaction runtime flags. */ export function resolveReactionLevel(params: { value: unknown; defaultLevel: ReactionLevel; diff --git a/src/utils/run-with-concurrency.ts b/src/utils/run-with-concurrency.ts index d130a89fc618..805d2cb9fe16 100644 --- a/src/utils/run-with-concurrency.ts +++ b/src/utils/run-with-concurrency.ts @@ -1,13 +1,32 @@ /** Controls whether the worker pool keeps scheduling after a task failure. */ export type ConcurrencyErrorMode = "continue" | "stop"; -/** Runs async tasks with bounded concurrency while preserving result indexes. */ -export async function runTasksWithConcurrency(params: { +/** Options for running a fixed list of promise factories through a bounded worker pool. */ +export type RunTasksWithConcurrencyOptions = { + /** Task factories are started lazily so the helper can enforce `limit`. */ tasks: Array<() => Promise>; + /** Maximum number of tasks allowed to run at the same time; clamped to at least one. */ limit: number; + /** `stop` prevents new work after the first failure; in-flight workers still settle. */ errorMode?: ConcurrencyErrorMode; + /** Called once per failed task with the original task index. */ onTaskError?: (error: unknown, index: number) => void; -}): Promise<{ results: T[]; firstError: unknown; hasError: boolean }> { +}; + +/** Ordered task results plus aggregate error state for callers that keep partial success. */ +export type RunTasksWithConcurrencyResult = { + /** Results are written at their original task indexes; failed or unscheduled indexes stay empty. */ + results: T[]; + /** First task error observed by the worker pool, if any. */ + firstError: unknown; + /** True when at least one task rejected. */ + hasError: boolean; +}; + +/** Runs async tasks with bounded concurrency while preserving result indexes. */ +export async function runTasksWithConcurrency( + params: RunTasksWithConcurrencyOptions, +): Promise> { const { tasks, limit, onTaskError } = params; const errorMode = params.errorMode ?? "continue"; if (tasks.length === 0) { @@ -25,6 +44,8 @@ export async function runTasksWithConcurrency(params: { if (errorMode === "stop" && hasError) { return; } + // Synchronous cursor adoption is the whole scheduling lock: each worker + // claims one stable index before awaiting task work. const index = next; next += 1; if (index >= tasks.length) { diff --git a/src/utils/safe-json.ts b/src/utils/safe-json.ts index f61c89f9f0b7..430ed924f6d1 100644 --- a/src/utils/safe-json.ts +++ b/src/utils/safe-json.ts @@ -1,3 +1,10 @@ +/** + * Defensive JSON stringify helper for diagnostics. + * + * The replacer handles values common in runtime logs that JSON.stringify would + * otherwise reject or erase, and returns null for circular structures. + */ +/** Safely stringify diagnostic values, preserving bigint/errors/functions in readable form. */ export function safeJsonStringify(value: unknown): string | null { try { return JSON.stringify(value, (_key, val) => { @@ -11,6 +18,7 @@ export function safeJsonStringify(value: unknown): string | null { return { name: val.name, message: val.message, stack: val.stack }; } if (val instanceof Uint8Array) { + // Binary payloads are base64 encoded so diagnostic JSON remains valid UTF-8 text. return { type: "Uint8Array", data: Buffer.from(val).toString("base64") }; } return val; diff --git a/src/utils/shell-argv.ts b/src/utils/shell-argv.ts index 14815ff9d1a3..7113530e3ea6 100644 --- a/src/utils/shell-argv.ts +++ b/src/utils/shell-argv.ts @@ -1,5 +1,7 @@ const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]); +// POSIX double quotes only consume the backslash before a small escape set; +// preserving other backslashes keeps command-risk analysis byte-faithful. function isDoubleQuoteEscape(next: string | undefined): next is string { return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next)); } @@ -61,7 +63,8 @@ export function splitShellArgs(raw: string): string[] | null { inDouble = true; continue; } - // In POSIX shells, "#" starts a comment only when it begins a word. + // In POSIX shells, "#" starts a comment only when it begins a word; keep + // inline hashes inside tokens so URLs/fragments are not truncated. if (ch === "#" && buf.length === 0) { break; } diff --git a/src/utils/transcript-tools.ts b/src/utils/transcript-tools.ts index 05439a60313a..f9ec7242f54c 100644 --- a/src/utils/transcript-tools.ts +++ b/src/utils/transcript-tools.ts @@ -1,3 +1,7 @@ +/** + * Transcript inspection helpers shared by session filesystem views and usage metrics. + * Keep provider-specific block aliases centralized so both surfaces classify tools consistently. + */ import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -8,6 +12,8 @@ type ToolResultCounts = { errors: number; }; +// Transcript providers disagree on tool-call block spellings; keep the accepted +// aliases centralized so display and metrics code classify the same payloads. const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]); const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]); @@ -15,6 +21,7 @@ const normalizeType = (value: unknown): string => { return typeof value === "string" ? (normalizeOptionalLowercaseString(value) ?? "") : ""; }; +/** Extracts de-duplicated tool names from direct fields and structured content blocks. */ export const extractToolCallNames = (message: Record): string[] => { const names = new Set(); const toolNameRaw = message.toolName ?? message.tool_name; @@ -47,9 +54,11 @@ export const extractToolCallNames = (message: Record): string[] return Array.from(names); }; +/** Returns whether a transcript message contains any recognized tool-call marker. */ export const hasToolCall = (message: Record): boolean => extractToolCallNames(message).length > 0; +/** Counts recognized tool-result blocks and the subset explicitly marked as errors. */ export const countToolResults = (message: Record): ToolResultCounts => { const content = message.content; if (!Array.isArray(content)) { diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 9d0a62e8487f..f9e30b3b7230 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -1,3 +1,7 @@ +/** + * Shared token/cost formatting and pricing lookup helpers for CLI, TUI, gateway, and status output. + * Keep this module synchronous; request paths call it while rendering usage summaries. + */ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; @@ -34,6 +38,7 @@ type RawPricingTier = { range: [number, number] | [number]; }; +/** Per-million-token model pricing used by usage summaries and cost estimates. */ export type ModelCostConfig = { input: number; output: number; @@ -94,6 +99,7 @@ let providerCostIndexByConfig = new WeakMap< let modelKeyCache = new Map(); let sortedPricingTiersByInput = new WeakMap(); +/** Formats a token count for compact human-facing status text. */ export function formatTokenCount(value?: number): string { if (value === undefined || !Number.isFinite(value)) { return "0"; @@ -113,6 +119,7 @@ export function formatTokenCount(value?: number): string { return String(Math.round(safe)); } +/** Formats a USD amount for usage summaries, keeping tiny costs visible. */ export function formatUsd(value?: number): string | undefined { if (value === undefined || !Number.isFinite(value)) { return undefined; @@ -586,6 +593,10 @@ function serializeCostIndex( return Array.from(entries.entries()).toSorted(([a], [b]) => a.localeCompare(b)); } +/** + * Fingerprints all model-pricing sources that can affect usage cost estimates. + * Consumers cache this value to know when resolved cost entries need recomputation. + */ export function resolveModelCostConfigFingerprint(config?: OpenClawConfig): string { return stableCostFingerprintValue({ configuredRaw: serializeCostIndex( @@ -598,6 +609,10 @@ export function resolveModelCostConfigFingerprint(config?: OpenClawConfig): stri }); } +/** + * Resolves pricing for a provider/model pair from local models.json, configured models, then gateway cache. + * Direct keys win before plugin normalization so configured pricing does not trigger provider discovery. + */ export function resolveModelCostConfig(params: { provider?: string; model?: string; @@ -707,6 +722,10 @@ function computeTieredCost( ); } +/** + * Estimates USD usage cost from normalized token totals. + * Tiered pricing selects one whole-request tier by input size; it does not blend tiers. + */ export function estimateUsageCost(params: { usage?: NormalizedUsage | UsageTotals | null; cost?: ModelCostConfig; diff --git a/src/utils/with-timeout.ts b/src/utils/with-timeout.ts index 225d2e965e0d..8734383d460f 100644 --- a/src/utils/with-timeout.ts +++ b/src/utils/with-timeout.ts @@ -1 +1,7 @@ +/** + * Compatibility export for timeout-wrapped operations. + * + * The implementation lives in infra/fs-safe; this keeps older utils imports on + * the same public helper without duplicating timeout behavior. + */ export { withTimeout } from "../infra/fs-safe.js"; diff --git a/src/utils/zod-parse.ts b/src/utils/zod-parse.ts index 32fb879558aa..4413dc956167 100644 --- a/src/utils/zod-parse.ts +++ b/src/utils/zod-parse.ts @@ -1,5 +1,12 @@ import type { ZodType } from "zod"; +/** + * Null-returning Zod parse helpers for plugin and runtime boundaries. + * + * Callers use these where invalid external payloads should be ignored or + * recovered from without constructing and catching validation errors. + */ + /** Safely validates an unknown value with a Zod schema, returning null on validation failure. */ export function safeParseWithSchema(schema: ZodType, value: unknown): T | null { const parsed = schema.safeParse(value); diff --git a/src/video-generation/capabilities.ts b/src/video-generation/capabilities.ts index 37451252b2eb..a42e692228c0 100644 --- a/src/video-generation/capabilities.ts +++ b/src/video-generation/capabilities.ts @@ -5,6 +5,8 @@ import type { VideoGenerationTransformCapabilities, } from "./types.js"; +// Video generation mode helpers derive the active mode from reference inputs +// and expose the provider capability block that applies to that mode/model. export function resolveVideoGenerationMode(params: { inputImageCount?: number; inputVideoCount?: number; @@ -56,6 +58,8 @@ export function resolveVideoGenerationModeCapabilities(params: { >( caps: T, ): T => { + // Model-specific caps narrow the provider defaults without mutating the + // registered provider object shared across requests. const model = params.model?.trim(); if (!caps || !model) { return caps; @@ -99,6 +103,8 @@ export function resolveVideoGenerationModeCapabilities(params: { }; } const videoToVideoCapabilities = withModelLimits(capabilities.videoToVideo); + // Mixed image+video references have no first-class mode label, but providers + // may support them through video-to-video capabilities that also accept images. if ( inputImageCount > 0 && inputVideoCount > 0 && diff --git a/src/video-generation/capability-overlays.ts b/src/video-generation/capability-overlays.ts index 41a56f388bd0..c2614f0dad49 100644 --- a/src/video-generation/capability-overlays.ts +++ b/src/video-generation/capability-overlays.ts @@ -8,6 +8,8 @@ import type { VideoGenerationTransformCapabilities, } from "./types.js"; +// Runtime/model capability overlays let a provider refine static manifest caps +// for the selected model without rebuilding the registry. function isVideoGenerationTransformCapabilities( capabilities: VideoGenerationModeCapabilities | VideoGenerationTransformCapabilities | undefined, ): capabilities is VideoGenerationTransformCapabilities { @@ -32,6 +34,8 @@ export function buildReferenceInputCapabilityFailure(params: { }); if (inputImageCount > 0 || inputVideoCount > 0) { + // Reference inputs must be explicitly supported. Falling back to a provider + // that ignores them would look successful while losing user-supplied assets. const visualLabel = inputImageCount > 0 && inputVideoCount > 0 ? "combined image/video reference inputs" @@ -87,6 +91,8 @@ function mergeVideoGenerationModeCapabilities< } const overlayOptions = overlay.providerOptions; const hasOverlayOptions = Object.hasOwn(overlay, "providerOptions"); + // Explicit empty providerOptions means "clear inherited options"; undefined + // means "inherit base declaration". const mergedProviderOptions = hasOverlayOptions && overlayOptions && Object.keys(overlayOptions).length === 0 ? overlayOptions @@ -155,6 +161,8 @@ export async function resolveProviderWithModelCapabilities(params: { if (!modelCapabilities) { return params.provider; } + // Return a request-local provider copy so dynamic model caps cannot leak + // across later requests or different model candidates. return { ...params.provider, capabilities: mergeVideoGenerationProviderCapabilities( diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 4d1844b68c6f..991fee7587ed 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -21,6 +21,8 @@ import type { VideoGenerationSourceAsset, } from "./types.js"; +// DashScope-compatible video helper for Wan-style async task APIs: submit JSON, +// poll task status, then download generated video URLs with byte limits. export const DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL = "wan2.6-t2v"; export const DASHSCOPE_WAN_VIDEO_MODELS = [ DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL, @@ -100,6 +102,8 @@ export function buildDashscopeVideoGenerationInput(params: { const unsupported = [...(params.req.inputImages ?? []), ...(params.req.inputVideos ?? [])].some( (asset) => !asset.url?.trim() && asset.buffer, ); + // DashScope accepts remote references in this path; buffer uploads require a + // different provider-specific flow, so fail before silently dropping refs. if (unsupported) { throw new Error( `${params.providerLabel} video generation currently requires remote http(s) URLs for reference images/videos.`, @@ -157,6 +161,8 @@ export function buildDashscopeVideoGenerationParameters( return Object.keys(parameters).length > 0 ? parameters : undefined; } +// DashScope may return videos in results[] or a top-level output.video_url. +// De-dupe so downstream downloads produce one asset per unique URL. export function extractDashscopeVideoUrls(payload: DashscopeVideoGenerationResponse): string[] { const urls = [ ...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []), @@ -197,6 +203,8 @@ export async function pollDashscopeVideoTaskUntilComplete(params: { if (status === "SUCCEEDED") { return payload; } + // Terminal failure statuses carry provider messages; nonterminal statuses + // continue until the shared operation deadline or max poll attempts wins. if (status === "FAILED" || status === "CANCELED") { throw new Error( payload.output?.message?.trim() || @@ -299,6 +307,8 @@ export async function runDashscopeVideoGenerationTask(params: { } } +// Downloads task result URLs into generated video assets. The byte limit comes +// from OpenClaw media config so provider URLs cannot overfill memory. export async function downloadDashscopeGeneratedVideos(params: { providerLabel: string; urls: string[]; diff --git a/src/video-generation/duration-support.ts b/src/video-generation/duration-support.ts index 6296df0d47e8..d7024ae19756 100644 --- a/src/video-generation/duration-support.ts +++ b/src/video-generation/duration-support.ts @@ -2,6 +2,8 @@ import { uniqueValues } from "@openclaw/normalization-core/string-normalization" import { resolveVideoGenerationModeCapabilities } from "./capabilities.js"; import type { VideoGenerationProvider } from "./types.js"; +// Duration support is provider/mode/model scoped. Values are normalized to +// positive rounded seconds before runtime snaps requests to the nearest option. function normalizeSupportedDurationValues( values: readonly number[] | undefined, ): number[] | undefined { @@ -36,6 +38,8 @@ export function resolveVideoGenerationSupportedDurations(params: { return normalizeSupportedDurationValues(modelSpecific ?? caps?.supportedDurationSeconds); } +// Normalize requested duration for providers with explicit allowed values. Ties +// choose the longer duration to avoid shortening user intent unexpectedly. export function normalizeVideoGenerationDuration(params: { provider?: VideoGenerationProvider; model?: string; diff --git a/src/video-generation/live-test-helpers.ts b/src/video-generation/live-test-helpers.ts index b967e2159239..2c8ca3b34070 100644 --- a/src/video-generation/live-test-helpers.ts +++ b/src/video-generation/live-test-helpers.ts @@ -10,6 +10,8 @@ import { export { parseProviderModelMap, redactLiveApiKey }; +// Default provider/model matrix for video live tests. Env/config filters can +// override this without editing the live test source. export const DEFAULT_LIVE_VIDEO_MODELS: Record = { alibaba: "alibaba/wan2.6-t2v", byteplus: "byteplus/seedance-1-0-lite-t2v-250428", @@ -31,6 +33,8 @@ const REMOTE_URL_VIDEO_TO_VIDEO_PROVIDERS = new Set(["alibaba", "google", "opena const BUFFER_BACKED_IMAGE_TO_VIDEO_UNSUPPORTED_PROVIDERS = new Set(["vydra"]); const TOGETHER_BUFFER_BACKED_IMAGE_TO_VIDEO_MODEL = "Wan-AI/Wan2.2-I2V-A14B"; +// Keep live-test resolution conservative and provider-specific so broad smoke +// lanes do not spend extra time or hit unsupported defaults. export function resolveLiveVideoResolution(params: { providerId: string; modelRef: string; @@ -61,6 +65,8 @@ export function canRunBufferBackedVideoToVideoLiveLane(params: { modelRef: string; }): boolean { const providerId = normalizeLowercaseStringOrEmpty(params.providerId); + // Some providers only accept remote URL references in live video-to-video + // lanes; skip buffer-backed coverage for those providers. if (REMOTE_URL_VIDEO_TO_VIDEO_PROVIDERS.has(providerId)) { return false; } diff --git a/src/video-generation/model-ref.ts b/src/video-generation/model-ref.ts index f620882084af..4dea5c8fd890 100644 --- a/src/video-generation/model-ref.ts +++ b/src/video-generation/model-ref.ts @@ -1,5 +1,7 @@ import { parseGenerationModelRef } from "../../packages/media-generation-core/src/model-ref.js"; +// Video model refs share the generic media-generation provider/model grammar: +// "provider/model" when explicit, otherwise null for default resolution. export function parseVideoGenerationModelRef( raw: string | undefined, ): { provider: string; model: string } | null { diff --git a/src/video-generation/provider-registry.ts b/src/video-generation/provider-registry.ts index f554d87fb247..83ab49535cfb 100644 --- a/src/video-generation/provider-registry.ts +++ b/src/video-generation/provider-registry.ts @@ -4,6 +4,8 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import * as capabilityProviderRuntime from "../plugins/capability-provider-runtime.js"; import type { VideoGenerationProviderPlugin } from "../plugins/types.js"; +// Video-generation providers come from plugin capability registration. Canonical +// ids drive listing; aliases only affect lookup. const BUILTIN_VIDEO_GENERATION_PROVIDERS: readonly VideoGenerationProviderPlugin[] = []; const UNSAFE_PROVIDER_IDS = new Set(["__proto__", "constructor", "prototype"]); @@ -39,6 +41,8 @@ function buildProviderMaps(cfg?: OpenClawConfig): { if (!isSafeVideoGenerationProviderId(id)) { return; } + // Keep canonical provider listing de-duplicated even when multiple aliases + // point at the same provider. canonical.set(id, provider); aliases.set(id, provider); for (const alias of provider.aliases ?? []) { diff --git a/src/web-fetch/content-extractors.runtime.ts b/src/web-fetch/content-extractors.runtime.ts index 0e9c09ec75fe..2393dded8046 100644 --- a/src/web-fetch/content-extractors.runtime.ts +++ b/src/web-fetch/content-extractors.runtime.ts @@ -6,10 +6,13 @@ import type { } from "../plugins/web-content-extractor-types.js"; import { resolvePluginWebContentExtractors } from "../plugins/web-content-extractors.runtime.js"; +// Runtime loader for plugin-provided readable-content extractors. The loader is +// config-scoped so plugin registry results can be reused within a config view. const webContentExtractorLoader = createConfigScopedPromiseLoader((config?: OpenClawConfig) => resolvePluginWebContentExtractors(config ? { config } : undefined), ); +/** Runs configured content extractors until one returns readable text. */ export async function extractReadableContent(params: { html: string; url: string; @@ -32,6 +35,8 @@ export async function extractReadableContent(params: { extractMode: params.extractMode, }); } catch { + // Extraction is best-effort across plugins; one broken extractor should + // not prevent later extractors from handling the page. continue; } if (result?.text) { diff --git a/src/web-fetch/runtime.ts b/src/web-fetch/runtime.ts index 0ec9ec49ad21..db497de114b2 100644 --- a/src/web-fetch/runtime.ts +++ b/src/web-fetch/runtime.ts @@ -20,6 +20,8 @@ import { resolveWebProviderDefinition, } from "../web/provider-runtime-shared.js"; +// Runtime provider selection for the web_fetch tool. It resolves config, +// credentials, runtime metadata, and sandbox-safe bundled provider scopes. type WebFetchConfig = NonNullable["web"] extends infer Web ? Web extends { fetch?: infer Fetch } ? Fetch @@ -34,6 +36,7 @@ export type ResolveWebFetchDefinitionParams = { preferRuntimeProviders?: boolean; }; +/** Resolves whether web_fetch is enabled for the current config/sandbox. */ export function resolveWebFetchEnabled(params: { fetch?: WebFetchConfig; sandboxed?: boolean; @@ -74,6 +77,7 @@ function hasEntryCredential( }); } +/** Reports whether a web_fetch provider has usable credentials. */ export function isWebFetchProviderConfigured(params: { provider: Pick< PluginWebFetchProviderEntry, @@ -88,6 +92,7 @@ export function isWebFetchProviderConfigured(params: { return hasEntryCredential(params.provider, params.config, resolveFetchConfig(params.config)); } +/** Lists web_fetch providers available to runtime selection. */ export function listWebFetchProviders(params?: { config?: OpenClawConfig; }): PluginWebFetchProviderEntry[] { @@ -96,6 +101,7 @@ export function listWebFetchProviders(params?: { }); } +/** Lists plugin-configured web_fetch providers. */ export function listConfiguredWebFetchProviders(params?: { config?: OpenClawConfig; }): PluginWebFetchProviderEntry[] { @@ -104,6 +110,7 @@ export function listConfiguredWebFetchProviders(params?: { }); } +/** Resolves the configured or auto-detected web_fetch provider id. */ export function resolveWebFetchProviderId(params: { fetch?: WebFetchConfig; config?: OpenClawConfig; @@ -160,6 +167,7 @@ function resolveConfiguredWebFetchProviderId(params: { return params.providers.find((provider) => provider.id === raw)?.id; } +/** Resolves the executable web_fetch provider tool definition. */ export function resolveWebFetchDefinition( options?: ResolveWebFetchDefinitionParams, ): { provider: PluginWebFetchProviderEntry; definition: WebFetchProviderToolDefinition } | null { diff --git a/src/web-search/runtime-types.ts b/src/web-search/runtime-types.ts index 08bd7b078305..03b46eab1c78 100644 --- a/src/web-search/runtime-types.ts +++ b/src/web-search/runtime-types.ts @@ -5,12 +5,15 @@ import type { } from "../plugins/web-provider-types.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; +// Shared web_search runtime contracts. Keep these in a types-only module so +// provider registries and callers can import them without loading runtime code. type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } ? Search : undefined : undefined; +/** Provider/tool resolution inputs for web_search. */ export type ResolveWebSearchDefinitionParams = { config?: OpenClawConfig; agentDir?: string; @@ -21,16 +24,19 @@ export type ResolveWebSearchDefinitionParams = { preferInputConfig?: boolean; }; +/** Inputs for executing a web_search request through the selected provider. */ export type RunWebSearchParams = ResolveWebSearchDefinitionParams & { args: Record; signal?: AbortSignal; }; +/** Normalized execution result that records which provider answered. */ export type RunWebSearchResult = { provider: string; result: Record; }; +/** List-provider query parameters. */ export type ListWebSearchProvidersParams = { config?: OpenClawConfig; }; diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 50403ae0ea62..9f28bdac569d 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -38,6 +38,8 @@ import type { RuntimeWebSearchConfig as WebSearchConfig, } from "./runtime-types.js"; +// Runtime provider selection and execution for web_search. This keeps plugin, +// runtime, and explicit provider selections aligned before a tool executes. export type { ListWebSearchProvidersParams, ResolveWebSearchDefinitionParams, @@ -66,6 +68,7 @@ function resolveWebSearchRuntimeConfig(params?: { }); } +/** Resolves whether web_search is enabled for the current config/sandbox. */ export function resolveWebSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean; @@ -114,6 +117,7 @@ function hasEntryCredential( }); } +/** Reports whether a web_search provider has usable configured credentials. */ export function isWebSearchProviderConfigured(params: { provider: Pick< PluginWebSearchProviderEntry, @@ -132,6 +136,7 @@ export function isWebSearchProviderConfigured(params: { return hasEntryCredential(params.provider, config, resolveSearchConfig(config)); } +/** Lists runtime web_search providers after applying runtime config snapshots. */ export function listWebSearchProviders(params?: { config?: OpenClawConfig; }): PluginWebSearchProviderEntry[] { @@ -141,6 +146,7 @@ export function listWebSearchProviders(params?: { }); } +/** Lists plugin-configured web_search providers without runtime-only providers. */ export function listConfiguredWebSearchProviders(params?: { config?: OpenClawConfig; }): PluginWebSearchProviderEntry[] { @@ -150,6 +156,7 @@ export function listConfiguredWebSearchProviders(params?: { }); } +/** Resolves configured or auto-detected web_search provider id. */ export function resolveWebSearchProviderId(params: { search?: WebSearchConfig; config?: OpenClawConfig; @@ -190,6 +197,8 @@ export function resolveWebSearchProviderId(params: { return provider.id; } if (keylessFallbackProviderId) { + // Keyless providers are only used after credential-backed providers fail + // auto-detection, so configured API keys win when present. logVerbose( `web_search: no provider configured and no credentials found, falling back to keyless provider "${keylessFallbackProviderId}"`, ); @@ -309,6 +318,7 @@ function loadSortedWebSearchProviders( ); } +/** Resolves the executable web_search provider tool definition. */ export function resolveWebSearchDefinition( options?: ResolveWebSearchDefinitionParams, ): { provider: PluginWebSearchProviderEntry; definition: WebSearchProviderToolDefinition } | null { @@ -438,6 +448,7 @@ function isStructuredAvailabilityError(result: unknown): result is { error: stri return typeof error === "string" && /^missing_[a-z0-9_]*api_key$/i.test(error); } +/** Executes web_search with fallback when selection was not explicit. */ export async function runWebSearch(params: RunWebSearchParams): Promise { const config = resolveWebSearchRuntimeConfig({ config: params.config, @@ -480,6 +491,8 @@ export async function runWebSearch(params: RunWebSearchParams): Promise(value: T | symbol): T { if (isCancel(value)) { cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled."); @@ -57,6 +59,8 @@ export function tokenizedOptionFilter(search: string, option: Option): boo return tokens.every((token) => haystack.includes(token)); } +// Public factory used by setup/onboard commands. Keep side effects inside method +// calls so tests can import the module without starting prompts. export function createClackPrompter(): WizardPrompter { return { intro: async (title) => { @@ -156,6 +160,8 @@ export function createClackPrompter(): WizardPrompter { enabled: true, fallback: "none", }); + // Drive both Clack spinner UI and OSC progress output for terminals that + // display command progress outside the prompt line. return { update: (message) => { spin.message(theme.accent(message)); diff --git a/src/wizard/i18n/index.ts b/src/wizard/i18n/index.ts index ee9bd0f595dd..186d2bc0a3d6 100644 --- a/src/wizard/i18n/index.ts +++ b/src/wizard/i18n/index.ts @@ -10,6 +10,8 @@ import type { export type { WizardI18nParams, WizardLocale, WizardTranslationMap }; +// Wizard i18n uses dotted keys with English fallback. Locale selection is +// intentionally small because setup copy is maintained in-tree. export type SetupTranslator = (key: string, params?: WizardI18nParams) => string; const LOCALES: Record = { @@ -25,6 +27,8 @@ function normalizeLocaleToken(raw: string | undefined): string { return (raw ?? "").trim().split(".")[0]?.split("@")[0]?.replaceAll("_", "-") ?? ""; } +// Resolve shell/browser locale strings such as zh_Hant_TW.UTF-8 into supported +// setup locales, falling back to English for unknown languages. export function resolveWizardLocale(value: string | undefined): WizardLocale { const normalized = normalizeLocaleToken(value); if (!normalized) { @@ -82,6 +86,8 @@ export function wizardT( export const t = wizardT; +// Prefix-aware translator for setup subflows. Common and wizard keys remain +// absolute so shared copy can be reused from any subflow. export function createSetupTranslator(options?: { locale?: WizardLocale; keyPrefix?: string; diff --git a/src/wizard/i18n/types.ts b/src/wizard/i18n/types.ts index 9ed088ced7e0..fa9ff18c7617 100644 --- a/src/wizard/i18n/types.ts +++ b/src/wizard/i18n/types.ts @@ -1,3 +1,5 @@ +// Shared wizard translation schema: a tiny dotted-key tree plus primitive +// interpolation params for setup/onboard copy. export type WizardLocale = "en" | "zh-CN" | "zh-TW"; export type WizardI18nParams = Record; diff --git a/src/wizard/session.ts b/src/wizard/session.ts index e0bf638e4875..151d008c8755 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js"; +// WizardSession exposes interactive setup as a step/answer protocol for remote +// clients while reusing the same WizardPrompter contract as the local CLI. export type WizardStepOption = { value: unknown; label: string; @@ -160,6 +162,8 @@ class WizardSessionPrompter implements WizardPrompter { } private async prompt(step: Omit): Promise { + // Each emitted step receives an id so remote clients can answer the exact + // pending prompt and stale answers can be rejected. return await this.session.awaitAnswer({ ...step, id: randomUUID(), @@ -219,6 +223,8 @@ export class WizardSession { this.error = "cancelled"; this.currentStep = null; for (const [, deferred] of this.answerDeferred) { + // Reject all pending prompt promises so the runner can unwind through its + // normal cancellation path. deferred.reject(new WizardCancelledError()); } this.answerDeferred.clear(); @@ -260,6 +266,8 @@ export class WizardSession { private resolveStep(step: WizardStep | null) { if (!this.stepDeferred) { if (step === null) { + // The runner can finish immediately after an answer before next() has + // installed a waiter; remember that terminal state for the next poll. this.pendingTerminalResolution = true; } return; diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index 2639e32b2d2b..f2acc930dddd 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -9,6 +9,8 @@ import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +// Onboarding migration import helpers detect existing setups, select a plugin +// migration provider, preview a plan, back up state, and apply into fresh setup. export type SetupMigrationDetection = { providerId: string; label: string; @@ -98,6 +100,8 @@ function assertFreshSetupMigrationTarget(freshness: { fresh: boolean; reasons: readonly string[]; }): void { + // Migration import is currently fresh-setup only unless an explicit env gate + // opts into existing-target behavior. if (freshness.fresh || process.env.OPENCLAW_MIGRATION_EXISTING_IMPORT === "1") { return; } @@ -148,6 +152,8 @@ export async function detectSetupMigrationSources(params: { }); } } catch (error) { + // Detection is advisory; one failing provider must not prevent onboarding + // from offering other migration sources. logger.debug?.( `Migration provider ${provider.id} detection failed: ${formatErrorMessage(error)}`, ); @@ -280,6 +286,8 @@ export async function runSetupMigrationImport(params: { } const stateDir = resolveStateDir(); + // Freshness is checked after workspace selection because the migration target + // can be different from the process cwd/default workspace. assertFreshSetupMigrationTarget( await inspectSetupMigrationFreshness({ baseConfig: params.baseConfig, @@ -315,6 +323,8 @@ export async function runSetupMigrationImport(params: { const reportDir = buildMigrationReportDir(providerId, stateDir); const backupPath = await createPreMigrationBackup({}); + // Commit base wizard metadata before applying migrations so generated reports + // can reference a concrete OpenClaw config target. targetConfig = onboardHelpers.applyWizardMetadata(targetConfig, { command: "onboard", mode: "local", diff --git a/src/wizard/setup.official-plugins.ts b/src/wizard/setup.official-plugins.ts index a6459ff42bcb..a563a4df7e32 100644 --- a/src/wizard/setup.official-plugins.ts +++ b/src/wizard/setup.official-plugins.ts @@ -14,6 +14,8 @@ import type { WizardPrompter } from "./prompts.js"; const SKIP_VALUE = "__skip__"; +// Official plugin onboarding lists generic official plugins not already +// configured and installs the selected ones through the trusted install flow. export type OfficialPluginOnboardingInstallEntry = { pluginId: string; label: string; @@ -84,6 +86,8 @@ export function resolveOfficialPluginOnboardingInstallEntries(params: { return entries.toSorted((left, right) => left.label.localeCompare(right.label)); } +// Prompt for optional official plugin installs during onboarding. The skip entry +// is explicit so users can leave every plugin unselected without ambiguity. export async function setupOfficialPluginInstalls(params: { config: OpenClawConfig; prompter: WizardPrompter; diff --git a/src/wizard/setup.secret-input.ts b/src/wizard/setup.secret-input.ts index cfa2ae899402..eeec4da2eb82 100644 --- a/src/wizard/setup.secret-input.ts +++ b/src/wizard/setup.secret-input.ts @@ -4,6 +4,8 @@ import { resolveSecretRefString } from "../secrets/resolve.js"; type SecretDefaults = NonNullable["defaults"]; +// Secret input resolver accepts literal setup values or SecretRef-shaped values +// and reports path-specific errors for onboarding forms. function formatSecretResolutionError(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; diff --git a/src/wizard/setup.types.ts b/src/wizard/setup.types.ts index 85fba7c53cb3..44e13e6a4018 100644 --- a/src/wizard/setup.types.ts +++ b/src/wizard/setup.types.ts @@ -1,6 +1,8 @@ import type { GatewayAuthChoice } from "../commands/onboard-types.js"; import type { SecretInput } from "../config/types.secrets.js"; +// Shared setup wizard types for quickstart/advanced gateway flows and their +// persisted defaults. export type WizardFlow = "quickstart" | "advanced"; export type QuickstartGatewayDefaults = {