diff --git a/plugin/core/logging.ts b/plugin/core/logging.ts new file mode 100644 index 0000000..44c49f2 --- /dev/null +++ b/plugin/core/logging.ts @@ -0,0 +1,41 @@ +const DEFAULT_VISIBLE_EDGE = 4; + +export type RedactableValue = string | null | undefined; + +export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "secret" }); +} + +export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "pairingCode" }); +} + +export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "key" }); +} + +export function redactValue( + value: RedactableValue, + options: { visibleEdge?: number; label?: string } = {} +): string { + const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE; + const label = options.label ?? "value"; + + if (!value) { + return ``; + } + + if (value.length <= visibleEdge * 2) { + return ``; + } + + return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} `; +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index b5bac3c..e72612d 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -17,12 +17,15 @@ import { buildPairRequest, buildPairSuccess, buildRePairRequired, + CodecError, decodeBuiltin, encodeBuiltin, + encodeRuleMessage, extractAuthRequestSigningInput, isBuiltinMessage, isTimestampFresh, isValidAuthNonce, + parseRuleMessage, type AuthRequestPayload, type HeartbeatPayload } from "../../../Yonexus.Protocol/src/index.js"; @@ -43,6 +46,7 @@ import { createDiscordNotificationService, type DiscordNotificationService } from "../notifications/discord.js"; +import { safeErrorMessage } from "./logging.js"; export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; @@ -143,10 +147,27 @@ export class YonexusServerRuntime { async handleMessage(connection: ClientConnection, raw: string): Promise { if (!isBuiltinMessage(raw)) { + // Handle rule message - rewrite and dispatch + await this.handleRuleMessage(connection, raw); return; } - const envelope = decodeBuiltin(raw); + let envelope: BuiltinEnvelope; + try { + envelope = decodeBuiltin(raw); + } catch (error) { + const message = error instanceof CodecError ? error.message : "Invalid builtin message"; + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message }, + { timestamp: this.now() } + ) + ) + ); + return; + } if (envelope.type === "hello") { await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); @@ -174,7 +195,21 @@ export class YonexusServerRuntime { connection, envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload> ); + return; } + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { + code: "MALFORMED_MESSAGE", + message: `Unsupported builtin type: ${String(envelope.type)}` + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); } private async handleHello( @@ -803,6 +838,123 @@ export class YonexusServerRuntime { private async persist(): Promise { await this.options.store.save(this.registry.clients.values()); } + + /** + * Send a rule message to a specific client. + * + * @param identifier - The target client identifier + * @param message - The complete rule message with identifier and content + * @returns True if message was sent, false if client not connected/authenticated + */ + sendMessageToClient(identifier: string, message: string): boolean { + const session = this.registry.sessions.get(identifier); + if (!session || !session.isAuthenticated) { + return false; + } + + // Validate the message is a properly formatted rule message + try { + // Quick check: must not be a builtin message and must have :: delimiter + if (message.startsWith("builtin::")) { + return false; + } + const delimiterIndex = message.indexOf("::"); + if (delimiterIndex === -1) { + return false; + } + parseRuleMessage(message); + } catch { + return false; + } + + return this.options.transport.send(identifier, message); + } + + /** + * Send a rule message to a specific client using separate rule identifier and content. + * + * @param identifier - The target client identifier + * @param ruleIdentifier - The rule identifier + * @param content - The message content + * @returns True if message was sent, false if client not connected/authenticated or invalid format + */ + sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): boolean { + const session = this.registry.sessions.get(identifier); + if (!session || !session.isAuthenticated) { + return false; + } + + try { + const encoded = encodeRuleMessage(ruleIdentifier, content); + return this.options.transport.send(identifier, encoded); + } catch { + return false; + } + } + + /** + * Handle incoming rule message from a client. + * Rewrites the message to include sender identifier before dispatch. + * + * @param connection - The client connection + * @param raw - The raw rule message + */ + private async handleRuleMessage(connection: ClientConnection, raw: string): Promise { + // Get sender identifier from connection or session + let senderIdentifier = connection.identifier; + if (!senderIdentifier) { + // Try to find identifier from WebSocket + for (const [id, session] of this.registry.sessions.entries()) { + if (session.socket === connection.ws) { + senderIdentifier = id; + break; + } + } + } + + if (!senderIdentifier) { + // Cannot determine sender - close connection + connection.ws.close(1008, "Cannot identify sender"); + return; + } + + const session = this.registry.sessions.get(senderIdentifier); + if (!session || !session.isAuthenticated) { + // Only accept rule messages from authenticated clients + connection.ws.close(1008, "Not authenticated"); + return; + } + + try { + const parsed = parseRuleMessage(raw); + const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`; + + // TODO: Dispatch to registered rules via rule registry + // For now, just log the rewritten message + // this.ruleRegistry.dispatch(rewritten); + + // Update last activity + session.lastActivityAt = this.now(); + + // Future: dispatch to rule registry + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void rewritten; + } catch (error) { + // Malformed rule message + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { + code: "MALFORMED_MESSAGE", + message: safeErrorMessage(error) || "Invalid rule message format" + }, + { timestamp: this.now() } + ) + ) + ); + } + } } export function createYonexusServerRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index 2743e24..a2834eb 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -1,6 +1,6 @@ import { WebSocketServer, WebSocket, RawData } from "ws"; import type { YonexusServerConfig } from "./config.js"; -import type { ClientRecord } from "./persistence.js"; +import { safeErrorMessage } from "./logging.js"; export interface ClientConnection { readonly identifier: string | null; @@ -221,7 +221,7 @@ export class YonexusServerTransport implements ServerTransport { ws.on("error", (error: Error) => { // Log error but let close handler clean up - console.error("WebSocket error:", error.message); + console.error("[Yonexus.Server] WebSocket error:", safeErrorMessage(error)); }); if (this.options.onConnect) { diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index 047d3f2..aad958c 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -5,6 +5,7 @@ */ import type { PairingRequest } from "../services/pairing.js"; +import { redactPairingCode } from "../core/logging.js"; export interface DiscordNotificationService { /** @@ -36,11 +37,16 @@ export function createDiscordNotificationService( ): DiscordNotificationService { return { async sendPairingNotification(request: PairingRequest): Promise { - const message = formatPairingMessage(request); + const redactedCode = redactPairingCode(request.pairingCode); // Log to console (visible in OpenClaw logs) - console.log("[Yonexus.Server] Pairing notification (Discord DM stub):"); - console.log(message); + console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", { + identifier: request.identifier, + pairingCode: redactedCode, + expiresAt: request.expiresAt, + ttlSeconds: request.ttlSeconds, + adminUserId: config.adminUserId + }); // TODO: Replace with actual Discord bot integration // Example with discord.js: @@ -59,7 +65,7 @@ export function createDiscordNotificationService( /** * Format a pairing request as a Discord DM message. */ -function formatPairingMessage(request: PairingRequest): string { +export function formatPairingMessage(request: PairingRequest): string { const expiresDate = new Date(request.expiresAt * 1000); const expiresStr = expiresDate.toISOString(); @@ -89,7 +95,7 @@ export function createMockNotificationService( async sendPairingNotification(request: PairingRequest): Promise { console.log("[Yonexus.Server] Mock pairing notification:"); console.log(` Identifier: ${request.identifier}`); - console.log(` Pairing Code: ${request.pairingCode}`); + console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`); console.log(` Success: ${shouldSucceed}`); return shouldSucceed; }