@alfe.ai/openclaw 0.4.10 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin2.cjs CHANGED
@@ -31,15 +31,17 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  }) : target, mod));
32
32
  //#endregion
33
33
  let node_path = require("node:path");
34
- let node_fs_promises = require("node:fs/promises");
35
34
  let node_os = require("node:os");
36
35
  let node_module = require("node:module");
37
36
  let _alfe_ai_config = require("@alfe.ai/config");
38
37
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
39
- let node_net = require("node:net");
40
38
  let node_crypto = require("node:crypto");
41
39
  let node_events = require("node:events");
40
+ let node_net = require("node:net");
41
+ let node_string_decoder = require("node:string_decoder");
42
42
  let _auriclabs_logger = require("@auriclabs/logger");
43
+ let node_fs = require("node:fs");
44
+ let node_fs_promises = require("node:fs/promises");
43
45
  //#region ../../node_modules/.pnpm/@sinclair+typebox@0.34.48/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
44
46
  /** Returns true if this value is an async iterator */
45
47
  function IsAsyncIterator$2(value) {
@@ -2786,197 +2788,183 @@ const Type = /* @__PURE__ */ __exportAll({
2786
2788
  //#endregion
2787
2789
  //#region src/types.ts
2788
2790
  function isIPCRequest(msg) {
2789
- return typeof msg === "object" && msg !== null && msg.type === "req" && typeof msg.id === "string" && typeof msg.method === "string";
2791
+ if (!isRecord$1(msg)) return false;
2792
+ return msg.type === "req" && isBoundedString(msg.id, 128) && isBoundedString(msg.method, 256) && isRecord$1(msg.params);
2790
2793
  }
2791
2794
  function isIPCResponse(msg) {
2792
- return typeof msg === "object" && msg !== null && typeof msg.id === "string" && typeof msg.ok === "boolean" && !("type" in msg);
2795
+ if (!isRecord$1(msg)) return false;
2796
+ return isBoundedString(msg.id, 128) && typeof msg.ok === "boolean" && !("type" in msg) && (msg.error === void 0 || isIPCError(msg.error));
2793
2797
  }
2794
2798
  function isIPCEvent(msg) {
2795
- return typeof msg === "object" && msg !== null && msg.type === "event" && typeof msg.event === "string";
2799
+ if (!isRecord$1(msg)) return false;
2800
+ return msg.type === "event" && isBoundedString(msg.event, 256);
2801
+ }
2802
+ function isIPCError(value) {
2803
+ return isRecord$1(value) && isBoundedString(value.code, 128) && isBoundedString(value.message, 4096);
2804
+ }
2805
+ function isBoundedString(value, maxLength) {
2806
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength;
2807
+ }
2808
+ function isRecord$1(value) {
2809
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2796
2810
  }
2797
2811
  /** IPC protocol version — must match daemon's PROTOCOL_VERSION */
2798
2812
  const PROTOCOL_VERSION = 1;
2799
2813
  //#endregion
2800
2814
  //#region src/ipc-client.ts
2801
2815
  /**
2802
- * IPC Client connects to the Alfe gateway daemon via Unix socket.
2803
- *
2804
- * Protocol: newline-delimited JSON over Unix socket.
2805
- * Request: { type: 'req', id, method, params }
2806
- * Response: { id, ok, payload?, error? }
2807
- * Event: { type: 'event', event, payload }
2808
- *
2809
- * Features:
2810
- * - Automatic reconnection with exponential backoff (1s → 30s)
2811
- * - Request/response correlation via message ID
2812
- * - Event emission for incoming daemon events + requests
2813
- * - Graceful handling if daemon not running (warns, retries, doesn't crash)
2816
+ * Reconnecting newline-delimited JSON client for the local Alfe gateway.
2817
+ * The socket is local, but it is still a bidirectional runtime boundary: both
2818
+ * peers cap frames, validate envelopes, and bind callbacks to one connection.
2814
2819
  */
2815
2820
  const MIN_BACKOFF_MS = 1e3;
2816
2821
  const MAX_BACKOFF_MS = 3e4;
2817
2822
  const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
2823
+ const MAX_REQUEST_TIMEOUT_MS = 12e4;
2824
+ const MAX_PENDING_REQUESTS = 128;
2825
+ /** Must stay aligned with `packages/gateway/src/protocol.ts`. */
2826
+ const MAX_IPC_MESSAGE_BYTES = 1024 * 1024;
2818
2827
  var IPCClient = class extends node_events.EventEmitter {
2819
2828
  socket = null;
2820
- socketPath;
2821
2829
  buffer = "";
2830
+ decoder = new node_string_decoder.StringDecoder("utf8");
2822
2831
  backoffMs = MIN_BACKOFF_MS;
2823
2832
  closed = false;
2824
- _connected = false;
2833
+ isSocketConnected = false;
2825
2834
  reconnectTimer = null;
2826
2835
  pending = /* @__PURE__ */ new Map();
2827
- log = (0, _auriclabs_logger.createLogger)("IpcClient");
2828
- constructor(socketPath) {
2836
+ constructor(socketPath, log = (0, _auriclabs_logger.createLogger)("IpcClient")) {
2829
2837
  super();
2830
2838
  this.socketPath = socketPath;
2839
+ this.log = log;
2840
+ if (socketPath.length < 1 || socketPath.length > 4096 || socketPath.includes("\0")) throw new TypeError("IPC socket path must contain 1 to 4096 characters without NUL bytes");
2831
2841
  }
2832
2842
  get connected() {
2833
- return this._connected;
2843
+ return this.isSocketConnected;
2834
2844
  }
2835
- /**
2836
- * Start the IPC connection with auto-reconnect.
2837
- */
2838
2845
  start() {
2846
+ if (this.socket || this.reconnectTimer) return;
2839
2847
  this.closed = false;
2840
- this.doConnect();
2848
+ this.openSocket();
2841
2849
  }
2842
- /**
2843
- * Stop the IPC connection and all timers.
2844
- */
2845
2850
  stop() {
2846
2851
  this.closed = true;
2847
2852
  this.clearReconnectTimer();
2848
- for (const [id, pending] of this.pending) {
2849
- clearTimeout(pending.timer);
2850
- pending.resolve({
2851
- id,
2852
- ok: false,
2853
- error: {
2854
- code: "CLIENT_STOPPED",
2855
- message: "IPC client stopped"
2856
- }
2857
- });
2858
- }
2859
- this.pending.clear();
2860
- if (this.socket) {
2861
- try {
2862
- this.socket.end();
2863
- } catch {}
2864
- this.socket = null;
2865
- }
2866
- this._connected = false;
2867
- this.removeAllListeners();
2853
+ this.resolveAllPending("CLIENT_STOPPED", "IPC client stopped");
2854
+ const socket = this.socket;
2855
+ this.socket = null;
2856
+ this.isSocketConnected = false;
2857
+ this.resetDecoder();
2858
+ if (socket) try {
2859
+ socket.destroy();
2860
+ } catch {}
2868
2861
  }
2869
- /**
2870
- * Send a request to the daemon and wait for a response.
2871
- */
2872
2862
  async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
2873
- if (!this._connected || !this.socket) return {
2874
- id: "",
2875
- ok: false,
2876
- error: {
2877
- code: "NOT_CONNECTED",
2878
- message: "Not connected to daemon"
2879
- }
2880
- };
2863
+ if (!this.isSocketConnected || !this.socket) return this.errorResponse("", "NOT_CONNECTED", "Not connected to daemon");
2864
+ if (method.length < 1 || method.length > 256) return this.errorResponse("", "INVALID_REQUEST", "IPC method must contain 1 to 256 characters");
2865
+ const runtimeParams = params;
2866
+ if (typeof runtimeParams !== "object" || runtimeParams === null || Array.isArray(runtimeParams)) return this.errorResponse("", "INVALID_REQUEST", "IPC params must be an object");
2867
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_REQUEST_TIMEOUT_MS) return this.errorResponse("", "INVALID_REQUEST", `IPC timeout must be an integer from 1 to ${String(MAX_REQUEST_TIMEOUT_MS)}ms`);
2881
2868
  const id = (0, node_crypto.randomUUID)();
2869
+ if (this.pending.size >= MAX_PENDING_REQUESTS) return this.errorResponse(id, "TOO_MANY_REQUESTS", "Too many IPC requests are in flight");
2882
2870
  const request = {
2883
2871
  type: "req",
2884
2872
  id,
2885
2873
  method,
2886
2874
  params
2887
2875
  };
2876
+ const frame = this.serializeFrame(request, id);
2877
+ if (typeof frame !== "string") return frame;
2878
+ const socket = this.socket;
2888
2879
  return new Promise((resolve) => {
2889
2880
  const timer = setTimeout(() => {
2890
2881
  this.pending.delete(id);
2891
- resolve({
2892
- id,
2893
- ok: false,
2894
- error: {
2895
- code: "TIMEOUT",
2896
- message: `Request ${method} timed out after ${String(timeoutMs)}ms`
2897
- }
2898
- });
2882
+ resolve(this.errorResponse(id, "TIMEOUT", `Request ${method} timed out after ${String(timeoutMs)}ms`));
2899
2883
  }, timeoutMs);
2884
+ timer.unref();
2900
2885
  this.pending.set(id, {
2901
2886
  resolve,
2902
2887
  timer
2903
2888
  });
2904
2889
  try {
2905
- const socket = this.socket;
2906
- if (socket) socket.write(JSON.stringify(request) + "\n");
2907
- } catch (err) {
2890
+ socket.write(frame);
2891
+ } catch (error) {
2908
2892
  clearTimeout(timer);
2909
2893
  this.pending.delete(id);
2910
- resolve({
2911
- id,
2912
- ok: false,
2913
- error: {
2914
- code: "SEND_FAILED",
2915
- message: `Failed to send: ${err instanceof Error ? err.message : String(err)}`
2916
- }
2917
- });
2894
+ resolve(this.errorResponse(id, "SEND_FAILED", `Failed to send: ${error instanceof Error ? error.message : String(error)}`));
2918
2895
  }
2919
2896
  });
2920
2897
  }
2921
- /**
2922
- * Send a response to a daemon request.
2923
- * Used internally by the request event handler.
2924
- */
2925
2898
  sendResponse(response) {
2926
- if (!this._connected || !this.socket) return;
2899
+ const socket = this.socket;
2900
+ if (!this.isSocketConnected || !socket) return;
2901
+ if (!isIPCResponse(response)) {
2902
+ this.log.warn("Refusing malformed outgoing IPC response");
2903
+ return;
2904
+ }
2905
+ const frame = this.serializeFrame(response, response.id);
2906
+ if (typeof frame !== "string") {
2907
+ this.log.warn(`Refusing oversized or unserializable IPC response for ${response.id}`);
2908
+ return;
2909
+ }
2927
2910
  try {
2928
- this.socket.write(JSON.stringify(response) + "\n");
2911
+ socket.write(frame);
2929
2912
  } catch {}
2930
2913
  }
2931
- doConnect() {
2932
- if (this.closed) return;
2914
+ openSocket() {
2915
+ if (this.closed || this.socket) return;
2916
+ this.resetDecoder();
2933
2917
  this.log.debug(`Connecting to daemon at ${this.socketPath}...`);
2934
- this.socket = (0, node_net.createConnection)(this.socketPath, () => {
2918
+ const socket = (0, node_net.createConnection)(this.socketPath, () => {
2919
+ if (this.closed || this.socket !== socket) {
2920
+ socket.destroy();
2921
+ return;
2922
+ }
2935
2923
  this.log.info(`Connected to daemon (${this.socketPath})`);
2936
- this._connected = true;
2924
+ this.isSocketConnected = true;
2937
2925
  this.backoffMs = MIN_BACKOFF_MS;
2938
- this.buffer = "";
2939
- this.emit("connected");
2926
+ this.safeEmit("connected");
2940
2927
  });
2941
- this.socket.on("data", (data) => {
2942
- this.buffer += data.toString();
2943
- this.processBuffer();
2928
+ this.socket = socket;
2929
+ socket.on("data", (data) => {
2930
+ if (this.socket !== socket || this.closed) return;
2931
+ this.buffer += this.decoder.write(data);
2932
+ this.processBuffer(socket);
2933
+ if (Buffer.byteLength(this.buffer, "utf8") > 1048576) this.closeOversized(socket, "unterminated frame");
2944
2934
  });
2945
- this.socket.on("close", () => {
2946
- const wasConnected = this._connected;
2947
- this._connected = false;
2948
- for (const [id, pending] of this.pending) {
2949
- clearTimeout(pending.timer);
2950
- pending.resolve({
2951
- id,
2952
- ok: false,
2953
- error: {
2954
- code: "DISCONNECTED",
2955
- message: "Connection closed"
2956
- }
2957
- });
2958
- }
2959
- this.pending.clear();
2935
+ socket.on("close", () => {
2936
+ if (this.socket !== socket) return;
2937
+ const wasConnected = this.isSocketConnected;
2938
+ this.socket = null;
2939
+ this.isSocketConnected = false;
2940
+ this.resetDecoder();
2941
+ this.resolveAllPending("DISCONNECTED", "Connection closed");
2960
2942
  if (wasConnected) {
2961
2943
  this.log.warn("Disconnected from daemon");
2962
- this.emit("disconnected", "connection closed");
2944
+ this.safeEmit("disconnected", "connection closed");
2963
2945
  }
2964
- this.scheduleReconnect();
2946
+ if (!this.closed) this.scheduleReconnect();
2965
2947
  });
2966
- this.socket.on("error", (err) => {
2967
- if (err.code === "ECONNREFUSED" || err.code === "ENOENT" || err.code === "ECONNRESET") this.log.debug(`Daemon not available: ${err.code}`);
2968
- else {
2969
- this.log.error(`IPC socket error: ${err.message}`);
2970
- this.emit("error", err);
2948
+ socket.on("error", (error) => {
2949
+ if (this.socket !== socket || this.closed) return;
2950
+ if (error.code === "ECONNREFUSED" || error.code === "ENOENT" || error.code === "ECONNRESET") {
2951
+ this.log.debug(`Daemon not available: ${error.code}`);
2952
+ return;
2971
2953
  }
2954
+ this.log.error(`IPC socket error: ${error.message}`);
2955
+ if (this.listenerCount("error") > 0) this.safeEmit("error", error);
2972
2956
  });
2973
2957
  }
2974
- processBuffer() {
2975
- let newlineIdx;
2976
- while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
2977
- const line = this.buffer.slice(0, newlineIdx).trim();
2978
- this.buffer = this.buffer.slice(newlineIdx + 1);
2958
+ processBuffer(socket) {
2959
+ let newlineIndex;
2960
+ while ((newlineIndex = this.buffer.indexOf("\n")) !== -1) {
2961
+ const line = this.buffer.slice(0, newlineIndex).trim();
2962
+ this.buffer = this.buffer.slice(newlineIndex + 1);
2979
2963
  if (!line) continue;
2964
+ if (Buffer.byteLength(line, "utf8") > 1048576) {
2965
+ this.closeOversized(socket, "frame");
2966
+ return;
2967
+ }
2980
2968
  let parsed;
2981
2969
  try {
2982
2970
  parsed = JSON.parse(line);
@@ -2984,24 +2972,11 @@ var IPCClient = class extends node_events.EventEmitter {
2984
2972
  this.log.warn("Received invalid JSON from daemon");
2985
2973
  continue;
2986
2974
  }
2987
- if (isIPCResponse(parsed)) {
2988
- this.handleResponse(parsed);
2989
- continue;
2990
- }
2991
- if (isIPCEvent(parsed)) {
2992
- if (parsed.event === "plugin.replaced") {
2993
- this.log.warn("Replaced by another process — stopping reconnection");
2994
- this.closed = true;
2995
- this.clearReconnectTimer();
2996
- }
2997
- this.emit("event", parsed.event, parsed.payload);
2998
- continue;
2999
- }
3000
- if (isIPCRequest(parsed)) {
3001
- this.handleIncomingRequest(parsed);
3002
- continue;
3003
- }
3004
- this.log.debug({ msg: parsed }, "Unhandled message from daemon");
2975
+ if (isIPCResponse(parsed)) this.handleResponse(parsed);
2976
+ else if (isIPCEvent(parsed)) this.handleEvent(parsed.event, parsed.payload, socket);
2977
+ else if (isIPCRequest(parsed)) this.handleIncomingRequest(parsed);
2978
+ else this.log.warn("Ignoring malformed IPC envelope");
2979
+ if (this.socket !== socket) return;
3005
2980
  }
3006
2981
  }
3007
2982
  handleResponse(response) {
@@ -3011,37 +2986,117 @@ var IPCClient = class extends node_events.EventEmitter {
3011
2986
  this.pending.delete(response.id);
3012
2987
  pending.resolve(response);
3013
2988
  }
2989
+ handleEvent(event, payload, socket) {
2990
+ if (event === "plugin.replaced") {
2991
+ this.log.warn("Replaced by another process — closing this IPC client");
2992
+ this.closed = true;
2993
+ this.clearReconnectTimer();
2994
+ }
2995
+ this.safeEmit("event", event, payload);
2996
+ if (event === "plugin.replaced" && this.socket === socket) socket.destroy();
2997
+ }
3014
2998
  handleIncomingRequest(request) {
2999
+ let responded = false;
3015
3000
  const respond = (partial) => {
3016
- const response = {
3001
+ if (responded) return;
3002
+ responded = true;
3003
+ this.sendResponse({
3017
3004
  id: request.id,
3018
3005
  ...partial
3019
- };
3020
- this.sendResponse(response);
3006
+ });
3021
3007
  };
3022
- this.emit("request", request.method, request.params, respond);
3008
+ if (this.listenerCount("request") === 0) {
3009
+ respond({
3010
+ ok: false,
3011
+ error: {
3012
+ code: "UNKNOWN_METHOD",
3013
+ message: `No handler for method: ${request.method}`
3014
+ }
3015
+ });
3016
+ return;
3017
+ }
3018
+ try {
3019
+ this.emit("request", request.method, request.params, respond);
3020
+ } catch (error) {
3021
+ this.log.error(`IPC request handler failed: ${error instanceof Error ? error.message : String(error)}`);
3022
+ respond({
3023
+ ok: false,
3024
+ error: {
3025
+ code: "HANDLER_FAILED",
3026
+ message: "IPC request handler failed"
3027
+ }
3028
+ });
3029
+ }
3030
+ }
3031
+ serializeFrame(value, id) {
3032
+ let frame;
3033
+ try {
3034
+ frame = `${JSON.stringify(value)}\n`;
3035
+ } catch (error) {
3036
+ return this.errorResponse(id, "SERIALIZE_FAILED", error instanceof Error ? error.message : String(error));
3037
+ }
3038
+ if (Buffer.byteLength(frame, "utf8") > 1048576) return this.errorResponse(id, "PAYLOAD_TOO_LARGE", `IPC frame exceeds ${String(MAX_IPC_MESSAGE_BYTES)} bytes`);
3039
+ return frame;
3040
+ }
3041
+ closeOversized(socket, kind) {
3042
+ this.log.warn(`Closing daemon connection after oversized IPC ${kind} (limit ${String(MAX_IPC_MESSAGE_BYTES)} bytes)`);
3043
+ this.resetDecoder();
3044
+ if (this.socket === socket) socket.destroy();
3045
+ }
3046
+ resolveAllPending(code, message) {
3047
+ for (const [id, pending] of this.pending) {
3048
+ clearTimeout(pending.timer);
3049
+ pending.resolve(this.errorResponse(id, code, message));
3050
+ }
3051
+ this.pending.clear();
3023
3052
  }
3024
3053
  scheduleReconnect() {
3025
- if (this.closed) return;
3026
- this.clearReconnectTimer();
3054
+ if (this.closed || this.reconnectTimer || this.socket) return;
3027
3055
  const delay = this.backoffMs;
3028
3056
  this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
3029
3057
  this.log.debug(`Reconnecting in ${String(delay)}ms...`);
3030
3058
  this.reconnectTimer = setTimeout(() => {
3031
- this.doConnect();
3059
+ this.reconnectTimer = null;
3060
+ this.openSocket();
3032
3061
  }, delay);
3033
3062
  this.reconnectTimer.unref();
3034
3063
  }
3035
3064
  clearReconnectTimer() {
3036
- if (this.reconnectTimer) {
3037
- clearTimeout(this.reconnectTimer);
3038
- this.reconnectTimer = null;
3065
+ if (!this.reconnectTimer) return;
3066
+ clearTimeout(this.reconnectTimer);
3067
+ this.reconnectTimer = null;
3068
+ }
3069
+ resetDecoder() {
3070
+ this.buffer = "";
3071
+ this.decoder = new node_string_decoder.StringDecoder("utf8");
3072
+ }
3073
+ errorResponse(id, code, message) {
3074
+ return {
3075
+ id,
3076
+ ok: false,
3077
+ error: {
3078
+ code,
3079
+ message
3080
+ }
3081
+ };
3082
+ }
3083
+ safeEmit(event, ...args) {
3084
+ try {
3085
+ this.emit(event, ...args);
3086
+ } catch (error) {
3087
+ this.log.error(`IPC ${event} listener failed: ${error instanceof Error ? error.message : String(error)}`);
3039
3088
  }
3040
3089
  }
3041
3090
  };
3042
3091
  //#endregion
3043
3092
  //#region src/capability-reporter.ts
3044
- const PLUGIN_VERSION = "0.1.0";
3093
+ /**
3094
+ * Capability Reporter — registers OpenClaw's capabilities with the daemon.
3095
+ *
3096
+ * On connect (and reconnect), sends a `register` request to the daemon
3097
+ * with OpenClaw framework info, plugin version, and capabilities list.
3098
+ */
3099
+ const pkg$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
3045
3100
  const OPENCLAW_CAPABILITIES = [
3046
3101
  "sessions",
3047
3102
  "tools",
@@ -3057,7 +3112,7 @@ async function registerWithDaemon(client, log, pluginName = "@alfe.ai/openclaw")
3057
3112
  const response = await client.request("register", {
3058
3113
  framework: "openclaw",
3059
3114
  name: pluginName,
3060
- version: PLUGIN_VERSION,
3115
+ version: pkg$1.version,
3061
3116
  protocolVersion: 1,
3062
3117
  capabilities: [...OPENCLAW_CAPABILITIES],
3063
3118
  pid: process.pid
@@ -3070,10 +3125,252 @@ async function registerWithDaemon(client, log, pluginName = "@alfe.ai/openclaw")
3070
3125
  } else log.error(`Registration failed: ${err?.message ?? "unknown error"}`);
3071
3126
  return null;
3072
3127
  }
3128
+ if (!isRegisterResult(response.payload)) {
3129
+ log.error("Registration failed: daemon returned an invalid response");
3130
+ return null;
3131
+ }
3073
3132
  const result = response.payload;
3074
3133
  log.info(`Registered with daemon v${result.daemonVersion} (protocol v${String(result.protocolVersion)})`);
3075
3134
  return result;
3076
3135
  }
3136
+ function isRegisterResult(value) {
3137
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3138
+ const result = value;
3139
+ return typeof result.status === "string" && result.status.length > 0 && result.status.length <= 128 && typeof result.daemonVersion === "string" && result.daemonVersion.length > 0 && result.daemonVersion.length <= 128 && Number.isSafeInteger(result.protocolVersion) && result.protocolVersion > 0;
3140
+ }
3141
+ //#endregion
3142
+ //#region src/avatar-source.ts
3143
+ /** Safe local/remote avatar loading for the `set_avatar` tool. */
3144
+ const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
3145
+ const MAX_AVATAR_URL_LENGTH = 2048;
3146
+ const MAX_LOCAL_PATH_LENGTH = 4096;
3147
+ const AVATAR_DOWNLOAD_TIMEOUT_MS = 3e4;
3148
+ const AVATAR_MIME_BY_EXT = {
3149
+ ".png": "image/png",
3150
+ ".jpg": "image/jpeg",
3151
+ ".jpeg": "image/jpeg",
3152
+ ".webp": "image/webp"
3153
+ };
3154
+ function deriveAgentAssetsOrigin(apiUrl) {
3155
+ const api = new URL(apiUrl);
3156
+ if (api.protocol !== "https:" || api.username || api.password || !api.hostname.startsWith("api.")) throw new Error("Cannot derive a trusted agent-assets origin from the configured API URL");
3157
+ api.hostname = api.hostname.replace(/^api\./u, "assets.");
3158
+ api.pathname = "/";
3159
+ api.search = "";
3160
+ api.hash = "";
3161
+ return api.origin;
3162
+ }
3163
+ async function loadAvatarSource(input, options) {
3164
+ if (input.url !== void 0 === (input.path !== void 0)) throw new Error("Provide exactly one of url or path");
3165
+ if (input.url !== void 0) return loadRemoteAvatar(input.url, options.agentAssetsOrigin, options.fetchImpl ?? fetch);
3166
+ return loadWorkspaceAvatar(input.path ?? "", options.workspacePath);
3167
+ }
3168
+ function assertPresignedS3UploadUrl(rawUrl) {
3169
+ if (rawUrl.length < 1 || rawUrl.length > MAX_AVATAR_URL_LENGTH) throw new Error("Avatar upload URL is invalid");
3170
+ const url = new URL(rawUrl);
3171
+ if (url.protocol !== "https:" || url.username || url.password || !(url.hostname === "amazonaws.com" || url.hostname.endsWith(".amazonaws.com"))) throw new Error("Avatar upload URL is not a trusted S3 endpoint");
3172
+ return url.href;
3173
+ }
3174
+ async function loadRemoteAvatar(rawUrl, agentAssetsOrigin, fetchImpl) {
3175
+ if (rawUrl.length < 1 || rawUrl.length > MAX_AVATAR_URL_LENGTH) throw new Error("Avatar URL must contain 1 to 2048 characters");
3176
+ const url = new URL(rawUrl);
3177
+ if (url.protocol !== "https:" || url.username || url.password || url.origin !== agentAssetsOrigin) throw new Error(`Avatar URL must use the trusted agent-assets origin ${agentAssetsOrigin}`);
3178
+ if (!url.pathname.startsWith("/generated/") && !url.pathname.startsWith("/avatars/")) throw new Error("Avatar URL must reference a generated or uploaded agent asset");
3179
+ const response = await fetchImpl(url, {
3180
+ redirect: "error",
3181
+ signal: AbortSignal.timeout(AVATAR_DOWNLOAD_TIMEOUT_MS)
3182
+ });
3183
+ if (!response.ok) throw new Error(`Failed to download the avatar image (${String(response.status)})`);
3184
+ const declaredLength = response.headers.get("content-length");
3185
+ if (declaredLength !== null) {
3186
+ const bytes = Number(declaredLength);
3187
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > 5242880) throw new Error(`Avatar response exceeds the ${String(MAX_AVATAR_BYTES)} byte limit`);
3188
+ }
3189
+ const mimeType = avatarMime(url.pathname, response.headers.get("content-type"));
3190
+ const bytes = await readResponseBytes(response, MAX_AVATAR_BYTES);
3191
+ assertImageSignature(bytes, mimeType);
3192
+ return {
3193
+ bytes,
3194
+ mimeType
3195
+ };
3196
+ }
3197
+ async function loadWorkspaceAvatar(rawPath, workspacePath) {
3198
+ if (rawPath.length < 1 || rawPath.length > MAX_LOCAL_PATH_LENGTH || (0, node_path.isAbsolute)(rawPath)) throw new Error("Avatar path must be a workspace-relative path of at most 4096 characters");
3199
+ if (rawPath.includes("\0")) throw new Error("Avatar path contains an invalid character");
3200
+ let workspace;
3201
+ try {
3202
+ workspace = await (0, node_fs_promises.realpath)(workspacePath);
3203
+ } catch {
3204
+ throw new Error("Avatar workspace is unavailable");
3205
+ }
3206
+ const candidate = (0, node_path.resolve)(workspace, rawPath);
3207
+ if (!isWithin(workspace, candidate)) throw new Error("Avatar path resolves outside the workspace");
3208
+ let fileInfo;
3209
+ try {
3210
+ fileInfo = await (0, node_fs_promises.lstat)(candidate);
3211
+ } catch {
3212
+ throw new Error("Avatar file does not exist or cannot be accessed");
3213
+ }
3214
+ if (fileInfo.isSymbolicLink() || !fileInfo.isFile()) throw new Error("Avatar path must reference a regular non-symlink file");
3215
+ let canonical;
3216
+ try {
3217
+ canonical = await (0, node_fs_promises.realpath)(candidate);
3218
+ } catch {
3219
+ throw new Error("Avatar file does not exist or cannot be accessed");
3220
+ }
3221
+ if (!isWithin(workspace, canonical)) throw new Error("Avatar path resolves outside the workspace");
3222
+ const mimeType = avatarMime(canonical, null);
3223
+ const handle = await (0, node_fs_promises.open)(canonical, node_fs.constants.O_RDONLY | node_fs.constants.O_NOFOLLOW);
3224
+ try {
3225
+ const opened = await handle.stat();
3226
+ if (!opened.isFile() || opened.size < 1 || opened.size > 5242880) throw new Error(`Avatar file must contain 1 to ${String(MAX_AVATAR_BYTES)} bytes`);
3227
+ const target = Buffer.allocUnsafe(MAX_AVATAR_BYTES + 1);
3228
+ let offset = 0;
3229
+ while (offset <= MAX_AVATAR_BYTES) {
3230
+ const { bytesRead } = await handle.read(target, offset, target.length - offset, offset);
3231
+ if (bytesRead === 0) break;
3232
+ offset += bytesRead;
3233
+ }
3234
+ if (offset < 1 || offset > 5242880) throw new Error(`Avatar file must contain 1 to ${String(MAX_AVATAR_BYTES)} bytes`);
3235
+ const bytes = Buffer.from(target.subarray(0, offset));
3236
+ assertImageSignature(bytes, mimeType);
3237
+ return {
3238
+ bytes,
3239
+ mimeType
3240
+ };
3241
+ } finally {
3242
+ await handle.close();
3243
+ }
3244
+ }
3245
+ async function readResponseBytes(response, maxBytes) {
3246
+ if (!response.body) throw new Error("Avatar response had no body");
3247
+ const reader = response.body.getReader();
3248
+ const chunks = [];
3249
+ let total = 0;
3250
+ try {
3251
+ for (;;) {
3252
+ const { done, value } = await reader.read();
3253
+ if (done) break;
3254
+ total += value.byteLength;
3255
+ if (total > maxBytes) {
3256
+ await reader.cancel("avatar exceeds size limit");
3257
+ throw new Error(`Avatar response exceeds the ${String(maxBytes)} byte limit`);
3258
+ }
3259
+ chunks.push(value);
3260
+ }
3261
+ } finally {
3262
+ reader.releaseLock();
3263
+ }
3264
+ if (total < 1) throw new Error("Avatar response was empty");
3265
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
3266
+ }
3267
+ function avatarMime(pathname, contentType) {
3268
+ const normalizedType = (contentType ?? "").split(";", 1)[0]?.trim().toLowerCase();
3269
+ if (normalizedType === "image/png" || normalizedType === "image/jpeg" || normalizedType === "image/webp") return normalizedType;
3270
+ const fromExtension = AVATAR_MIME_BY_EXT[(0, node_path.extname)(pathname).toLowerCase()];
3271
+ if (fromExtension) return fromExtension;
3272
+ throw new Error("Avatar image must be PNG, JPEG, or WebP");
3273
+ }
3274
+ function assertImageSignature(bytes, mimeType) {
3275
+ if (!(mimeType === "image/png" && bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([
3276
+ 137,
3277
+ 80,
3278
+ 78,
3279
+ 71,
3280
+ 13,
3281
+ 10,
3282
+ 26,
3283
+ 10
3284
+ ])) || mimeType === "image/jpeg" && bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 || mimeType === "image/webp" && bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP")) throw new Error(`Avatar bytes do not match the declared ${mimeType} image type`);
3285
+ }
3286
+ function isWithin(root, candidate) {
3287
+ const rel = (0, node_path.relative)(root, candidate);
3288
+ return rel === "" || !rel.startsWith("..") && !(0, node_path.isAbsolute)(rel);
3289
+ }
3290
+ //#endregion
3291
+ //#region src/tool-input.ts
3292
+ /** Runtime validation for model-controlled OpenClaw tool parameters. */
3293
+ const MAX_JSON_DEPTH = 8;
3294
+ const MAX_JSON_NODES = 1e4;
3295
+ const MAX_RECORD_ENTRIES = 1e3;
3296
+ const MAX_RECORD_KEY_LENGTH = 256;
3297
+ const MAX_RECORD_STRING_LENGTH = 1e4;
3298
+ const MAX_JSON_BYTES = 1024 * 1024;
3299
+ const UNSAFE_KEYS = new Set([
3300
+ "__proto__",
3301
+ "constructor",
3302
+ "prototype"
3303
+ ]);
3304
+ function requireString(params, field, maxLength, pattern) {
3305
+ const value = params[field];
3306
+ if (typeof value !== "string" || value.length < 1 || value.length > maxLength) throw new Error(`${field} must contain 1 to ${String(maxLength)} characters`);
3307
+ if (pattern && !pattern.test(value)) throw new Error(`${field} has an invalid format`);
3308
+ return value;
3309
+ }
3310
+ function optionalString(params, field, maxLength) {
3311
+ if (params[field] === void 0) return void 0;
3312
+ return requireString(params, field, maxLength);
3313
+ }
3314
+ function requireConfirmation(params, message) {
3315
+ if (params.confirm !== true) throw new Error(message);
3316
+ }
3317
+ function requireMatchingConfirmation(params, field, expected) {
3318
+ if (params[field] !== expected) throw new Error(`${field} must exactly match ${expected}`);
3319
+ }
3320
+ function optionalJsonRecord(params, field) {
3321
+ if (params[field] === void 0) return void 0;
3322
+ return requireJsonRecord(params, field);
3323
+ }
3324
+ function requireJsonRecord(params, field) {
3325
+ const state = {
3326
+ nodes: 0,
3327
+ ancestors: /* @__PURE__ */ new Set()
3328
+ };
3329
+ const cloned = cloneJson(params[field], field, 0, state);
3330
+ if (!isRecord(cloned)) throw new Error(`${field} must be an object`);
3331
+ const serialized = JSON.stringify(cloned);
3332
+ if (Buffer.byteLength(serialized, "utf8") > MAX_JSON_BYTES) throw new Error(`${field} exceeds the ${String(MAX_JSON_BYTES)} byte limit`);
3333
+ return cloned;
3334
+ }
3335
+ function cloneJson(value, label, depth, state) {
3336
+ state.nodes += 1;
3337
+ if (state.nodes > MAX_JSON_NODES) throw new Error(`${label} contains too many values`);
3338
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds ${String(MAX_JSON_DEPTH)} nesting levels`);
3339
+ if (value === null || typeof value === "boolean") return value;
3340
+ if (typeof value === "number") {
3341
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
3342
+ return value;
3343
+ }
3344
+ if (typeof value === "string") {
3345
+ if (value.length > MAX_RECORD_STRING_LENGTH) throw new Error(`${label} contains a string longer than ${String(MAX_RECORD_STRING_LENGTH)} characters`);
3346
+ return value;
3347
+ }
3348
+ if (typeof value !== "object") throw new Error(`${label} must contain only JSON values`);
3349
+ if (state.ancestors.has(value)) throw new Error(`${label} must not contain cycles`);
3350
+ state.ancestors.add(value);
3351
+ try {
3352
+ if (Array.isArray(value)) {
3353
+ if (value.length > MAX_RECORD_ENTRIES) throw new Error(`${label} contains too many entries`);
3354
+ return value.map((entry) => cloneJson(entry, label, depth + 1, state));
3355
+ }
3356
+ if (!isRecord(value)) throw new Error(`${label} must contain only plain objects`);
3357
+ const entries = Object.entries(value);
3358
+ if (entries.length > MAX_RECORD_ENTRIES) throw new Error(`${label} contains too many entries`);
3359
+ const output = Object.create(null);
3360
+ for (const [key, entry] of entries) {
3361
+ if (key.length < 1 || key.length > MAX_RECORD_KEY_LENGTH || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains an invalid key`);
3362
+ output[key] = cloneJson(entry, label, depth + 1, state);
3363
+ }
3364
+ return output;
3365
+ } finally {
3366
+ state.ancestors.delete(value);
3367
+ }
3368
+ }
3369
+ function isRecord(value) {
3370
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3371
+ const prototype = Object.getPrototypeOf(value);
3372
+ return prototype === Object.prototype || prototype === null;
3373
+ }
3077
3374
  //#endregion
3078
3375
  //#region src/plugin.ts
3079
3376
  /**
@@ -3085,44 +3382,11 @@ async function registerWithDaemon(client, log, pluginName = "@alfe.ai/openclaw")
3085
3382
  * 3. Re-registers on reconnect (daemon may restart)
3086
3383
  * 4. Gracefully handles daemon being unavailable
3087
3384
  *
3088
- * Follows the same plugin pattern as @alfe.ai/openclaw-voice.
3089
3385
  */
3090
3386
  const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
3091
3387
  const DEFAULT_SOCKET_PATH = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "gateway.sock");
3092
3388
  let ipcClient = null;
3093
- /** Max avatar upload size — mirrors the server-side finalize check. */
3094
- const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
3095
- /** Image types the avatar presign/finalize flow accepts, keyed by file extension. */
3096
- const AVATAR_MIME_BY_EXT = {
3097
- ".png": "image/png",
3098
- ".jpg": "image/jpeg",
3099
- ".jpeg": "image/jpeg",
3100
- ".webp": "image/webp"
3101
- };
3102
- /** Infer an avatar mime type from a file path, rejecting unsupported types. */
3103
- function avatarMimeFromPath(path) {
3104
- const ext = (0, node_path.extname)(path).toLowerCase();
3105
- const mimeType = AVATAR_MIME_BY_EXT[ext];
3106
- if (!mimeType) throw new Error(`unsupported image type "${ext || path}" — set_avatar accepts .png, .jpg/.jpeg, or .webp`);
3107
- return mimeType;
3108
- }
3109
- /**
3110
- * Infer an avatar mime type for a remote image — prefer the response's
3111
- * Content-Type, else the URL path extension. Lets `set_avatar` accept a URL
3112
- * (e.g. the one `generate_image` returns), which is how an agent naturally
3113
- * wants to set the picture it just generated.
3114
- */
3115
- function avatarMimeFromUrl(url, contentType) {
3116
- const ct = (contentType ?? "").split(";")[0].trim().toLowerCase();
3117
- if (ct === "image/png" || ct === "image/jpeg" || ct === "image/webp") return ct;
3118
- let pathname = url;
3119
- try {
3120
- pathname = new URL(url).pathname;
3121
- } catch {}
3122
- const byExt = AVATAR_MIME_BY_EXT[(0, node_path.extname)(pathname).toLowerCase()];
3123
- if (byExt) return byExt;
3124
- throw new Error(`could not determine the image type for "${url}" — set_avatar accepts png, jpeg, or webp`);
3125
- }
3389
+ let ipcOwner = null;
3126
3390
  function ok(result) {
3127
3391
  return { content: [{
3128
3392
  type: "text",
@@ -3152,7 +3416,7 @@ function defineTool(def) {
3152
3416
  try {
3153
3417
  return ok(await def.handler(params));
3154
3418
  } catch (e) {
3155
- return errResult(e.message);
3419
+ return errResult(e instanceof Error ? e.message : "Unknown error");
3156
3420
  }
3157
3421
  }
3158
3422
  };
@@ -3162,15 +3426,19 @@ function buildIntegrationTools(client) {
3162
3426
  defineTool({
3163
3427
  name: "list_integrations",
3164
3428
  description: "List all integrations installed for this agent, including their status and available config fields.",
3165
- parameters: Type.Object({}),
3429
+ parameters: Type.Object({}, { additionalProperties: false }),
3166
3430
  handler: async () => client.listIntegrations()
3167
3431
  }),
3168
3432
  defineTool({
3169
3433
  name: "get_integration_config",
3170
3434
  description: "Get the current configuration and editable fields for a specific integration.",
3171
- parameters: Type.Object({ integrationId: Type.String({ description: "The integration to inspect (e.g. \"voice\", \"slack\")" }) }),
3435
+ parameters: Type.Object({ integrationId: Type.String({
3436
+ description: "The integration to inspect (e.g. \"voice\", \"slack\")",
3437
+ minLength: 1,
3438
+ maxLength: 256
3439
+ }) }, { additionalProperties: false }),
3172
3440
  handler: async (params) => {
3173
- const integrationId = params.integrationId;
3441
+ const integrationId = requireString(params, "integrationId", 256);
3174
3442
  return client.getIntegrationConfig(integrationId);
3175
3443
  }
3176
3444
  }),
@@ -3178,27 +3446,53 @@ function buildIntegrationTools(client) {
3178
3446
  name: "update_integration_config",
3179
3447
  description: "Update configuration for an installed integration. Only agent-editable fields can be changed.",
3180
3448
  parameters: Type.Object({
3181
- integrationId: Type.String({ description: "The integration to update" }),
3182
- fields: Type.Record(Type.String(), Type.Unknown(), { description: "Key-value pairs of config fields to update" })
3183
- }),
3449
+ integrationId: Type.String({
3450
+ description: "The integration to update",
3451
+ minLength: 1,
3452
+ maxLength: 256
3453
+ }),
3454
+ fields: Type.Record(Type.String({
3455
+ minLength: 1,
3456
+ maxLength: 256
3457
+ }), Type.Unknown(), {
3458
+ description: "Key-value pairs of config fields to update",
3459
+ maxProperties: 1e3
3460
+ })
3461
+ }, { additionalProperties: false }),
3184
3462
  handler: async (params) => {
3185
- const integrationId = params.integrationId;
3186
- const fields = params.fields;
3463
+ const integrationId = requireString(params, "integrationId", 256);
3464
+ const fields = requireJsonRecord(params, "fields");
3187
3465
  return client.updateIntegrationConfig(integrationId, fields);
3188
3466
  }
3189
3467
  }),
3190
3468
  defineTool({
3191
3469
  name: "install_integration",
3192
- description: "Install an integration from the registry. Returns the newly created integration record.",
3470
+ description: "Install an integration from the registry. This changes the agent runtime and may run integration lifecycle hooks. Set confirm=true only after the user approves the install.",
3193
3471
  parameters: Type.Object({
3194
- integrationId: Type.String({ description: "The integration to install (e.g. \"voice\", \"slack\", \"discord\")" }),
3195
- version: Type.Optional(Type.String({ description: "Specific version to install (defaults to latest)" })),
3196
- config: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Initial configuration key-value pairs" }))
3197
- }),
3472
+ integrationId: Type.String({
3473
+ description: "The integration to install (e.g. \"voice\", \"slack\", \"discord\")",
3474
+ minLength: 1,
3475
+ maxLength: 256
3476
+ }),
3477
+ version: Type.Optional(Type.String({
3478
+ description: "Specific version to install (defaults to latest)",
3479
+ minLength: 1,
3480
+ maxLength: 64
3481
+ })),
3482
+ config: Type.Optional(Type.Record(Type.String({
3483
+ minLength: 1,
3484
+ maxLength: 256
3485
+ }), Type.Unknown(), {
3486
+ description: "Initial configuration key-value pairs",
3487
+ maxProperties: 1e3
3488
+ })),
3489
+ confirm: Type.Literal(true, { description: "Must be true after the user explicitly approves the integration install" })
3490
+ }, { additionalProperties: false }),
3198
3491
  handler: async (params) => {
3199
- const integrationId = params.integrationId;
3200
- const version = params.version;
3201
- const config = params.config;
3492
+ const integrationId = requireString(params, "integrationId", 256);
3493
+ const version = optionalString(params, "version", 64);
3494
+ const config = optionalJsonRecord(params, "config");
3495
+ requireConfirmation(params, "confirm must be true after the user explicitly approves the integration install");
3202
3496
  return client.installIntegration(integrationId, {
3203
3497
  version,
3204
3498
  config
@@ -3207,27 +3501,39 @@ function buildIntegrationTools(client) {
3207
3501
  }),
3208
3502
  defineTool({
3209
3503
  name: "remove_integration",
3210
- description: "Remove an installed integration. The daemon will handle teardown during reconciliation.",
3211
- parameters: Type.Object({ integrationId: Type.String({ description: "The integration to remove" }) }),
3504
+ description: "Remove an installed integration. The daemon will tear down its runtime resources during reconciliation. Copy the exact id into confirmIntegrationId after the user approves.",
3505
+ parameters: Type.Object({
3506
+ integrationId: Type.String({
3507
+ description: "The integration to remove",
3508
+ minLength: 1,
3509
+ maxLength: 256
3510
+ }),
3511
+ confirmIntegrationId: Type.String({
3512
+ description: "Exact integration id copied to confirm removal",
3513
+ minLength: 1,
3514
+ maxLength: 256
3515
+ })
3516
+ }, { additionalProperties: false }),
3212
3517
  handler: async (params) => {
3213
- const integrationId = params.integrationId;
3518
+ const integrationId = requireString(params, "integrationId", 256);
3519
+ requireMatchingConfirmation(params, "confirmIntegrationId", integrationId);
3214
3520
  return client.removeIntegration(integrationId);
3215
3521
  }
3216
3522
  }),
3217
3523
  defineTool({
3218
3524
  name: "browse_integration_registry",
3219
3525
  description: "Browse available integrations in the registry. Shows all integrations that can be installed.",
3220
- parameters: Type.Object({}),
3526
+ parameters: Type.Object({}, { additionalProperties: false }),
3221
3527
  handler: async () => client.getRegistry()
3222
3528
  })
3223
3529
  ];
3224
3530
  }
3225
- function buildVoiceTools(client) {
3531
+ function buildVoiceTools(client, options) {
3226
3532
  return [
3227
3533
  defineTool({
3228
3534
  name: "list_voices",
3229
3535
  description: "List the available voices this agent can speak with (the ElevenLabs catalogue). Use this to pick or confirm a voice before calling set_voice with the chosen id.",
3230
- parameters: Type.Object({}),
3536
+ parameters: Type.Object({}, { additionalProperties: false }),
3231
3537
  handler: async () => {
3232
3538
  const { voices } = await client.listVoices();
3233
3539
  return { voices: voices.map((v) => ({
@@ -3243,9 +3549,14 @@ function buildVoiceTools(client) {
3243
3549
  defineTool({
3244
3550
  name: "set_voice",
3245
3551
  description: "Change this agent's OWN voice. Pass a `voiceId` from list_voices. Returns the updated voice config. (Voice is always on — it cannot be turned off per-agent.)",
3246
- parameters: Type.Object({ voiceId: Type.String({ description: "The ElevenLabs voice id to use, from list_voices (e.g. the `id` field)." }) }),
3552
+ parameters: Type.Object({ voiceId: Type.String({
3553
+ description: "The ElevenLabs voice id to use, from list_voices (e.g. the `id` field).",
3554
+ minLength: 8,
3555
+ maxLength: 64,
3556
+ pattern: "^[A-Za-z0-9]{8,64}$"
3557
+ }) }, { additionalProperties: false }),
3247
3558
  handler: async (params) => {
3248
- const voiceId = params.voiceId;
3559
+ const voiceId = requireString(params, "voiceId", 64, /^[A-Za-z0-9]{8,64}$/u);
3249
3560
  return {
3250
3561
  updated: true,
3251
3562
  voiceConfig: (await client.updateSelf({ voiceConfig: { voiceId } })).voiceConfig
@@ -3255,9 +3566,13 @@ function buildVoiceTools(client) {
3255
3566
  defineTool({
3256
3567
  name: "generate_avatar",
3257
3568
  description: "Generate and set THIS agent's own profile picture / avatar from a text prompt (e.g. a character description). Updates the agent's real profile picture shown in the dashboard, chat, and apps. Use this — do NOT use `config set ui.assistant.avatar`, which has no effect on the real profile.",
3258
- parameters: Type.Object({ prompt: Type.String({ description: "Text description of the avatar to generate (e.g. a character or portrait description)." }) }),
3569
+ parameters: Type.Object({ prompt: Type.String({
3570
+ description: "Text description of the avatar to generate (e.g. a character or portrait description).",
3571
+ minLength: 1,
3572
+ maxLength: 2e3
3573
+ }) }, { additionalProperties: false }),
3259
3574
  handler: async (params) => {
3260
- const prompt = params.prompt;
3575
+ const prompt = requireString(params, "prompt", 2e3);
3261
3576
  return {
3262
3577
  updated: true,
3263
3578
  avatarUrl: (await client.generateAvatar({ prompt })).avatarUrl,
@@ -3267,34 +3582,40 @@ function buildVoiceTools(client) {
3267
3582
  }),
3268
3583
  defineTool({
3269
3584
  name: "set_avatar",
3270
- description: "Set THIS agent's OWN profile picture from an image — pass a `url` (e.g. the URL returned by `generate_image`) OR a local file `path` you created/downloaded (png/jpeg/webp, <=5MB). Updates the real profile picture shown in the dashboard, chat, and apps. IMPORTANT: this is how you set YOUR OWN avatar — do NOT use `update_identity` (that edits a CONTACT/person you know, not your own profile) and do NOT use `config set ui.assistant.avatar` (no effect).",
3585
+ description: "Set THIS agent's OWN profile picture from an image — pass an Alfe agent-assets `url` (e.g. the URL returned by `generate_image`) OR a workspace-relative local `path` (png/jpeg/webp, <=5MB). Updates the real profile picture shown in the dashboard, chat, and apps. IMPORTANT: this is how you set YOUR OWN avatar — do NOT use `update_identity` (that edits a CONTACT/person you know, not your own profile) and do NOT use `config set ui.assistant.avatar` (no effect).",
3271
3586
  parameters: Type.Object({
3272
- url: Type.Optional(Type.String({ description: "URL of an image to use as the avatar — e.g. the `url` returned by `generate_image`. png/jpeg/webp, <=5MB." })),
3273
- path: Type.Optional(Type.String({ description: "Absolute path to a LOCAL image file (.png, .jpg/.jpeg, or .webp, <=5MB). Use `url` instead if you have a URL." }))
3274
- }),
3587
+ url: Type.Optional(Type.String({
3588
+ description: "Trusted Alfe agent-assets URL e.g. the `url` returned by `generate_image`. png/jpeg/webp, <=5MB.",
3589
+ minLength: 1,
3590
+ maxLength: 2048,
3591
+ pattern: "^https://"
3592
+ })),
3593
+ path: Type.Optional(Type.String({
3594
+ description: "Workspace-relative path to a local image file (.png, .jpg/.jpeg, or .webp, <=5MB).",
3595
+ minLength: 1,
3596
+ maxLength: 4096
3597
+ }))
3598
+ }, { additionalProperties: false }),
3275
3599
  handler: async (params) => {
3276
- const url = params.url;
3277
- const path = params.path;
3278
- let bytes;
3279
- let mimeType;
3280
- if (url) {
3281
- const dl = await fetch(url);
3282
- if (!dl.ok) throw new Error(`failed to download the image from the url (${String(dl.status)})`);
3283
- bytes = Buffer.from(await dl.arrayBuffer());
3284
- mimeType = avatarMimeFromUrl(url, dl.headers.get("content-type"));
3285
- } else if (path) {
3286
- mimeType = avatarMimeFromPath(path);
3287
- bytes = await (0, node_fs_promises.readFile)(path);
3288
- } else throw new Error("provide either `url` (an image URL, e.g. from generate_image) or `path` (a local image file)");
3289
- if (bytes.length > MAX_AVATAR_BYTES) throw new Error(`image is ${String(bytes.length)} bytes — the avatar limit is ${String(MAX_AVATAR_BYTES)} bytes (5MB)`);
3600
+ const { bytes, mimeType } = await loadAvatarSource({
3601
+ url: optionalString(params, "url", 2048),
3602
+ path: optionalString(params, "path", 4096)
3603
+ }, {
3604
+ workspacePath: options.workspacePath,
3605
+ agentAssetsOrigin: options.agentAssetsOrigin
3606
+ });
3290
3607
  const { uploadUrl, s3Key } = await client.presignAvatar({
3291
3608
  mimeType,
3292
3609
  size: bytes.length
3293
3610
  });
3294
- const putRes = await fetch(uploadUrl, {
3611
+ const trustedUploadUrl = assertPresignedS3UploadUrl(uploadUrl);
3612
+ if (typeof s3Key !== "string" || s3Key.length < 1 || s3Key.length > 1024 || !s3Key.startsWith("avatars/")) throw new Error("Avatar presign returned an invalid object key");
3613
+ const putRes = await fetch(trustedUploadUrl, {
3295
3614
  method: "PUT",
3296
3615
  headers: { "Content-Type": mimeType },
3297
- body: new Uint8Array(bytes)
3616
+ body: new Uint8Array(bytes),
3617
+ redirect: "error",
3618
+ signal: AbortSignal.timeout(3e4)
3298
3619
  });
3299
3620
  if (!putRes.ok) throw new Error(`failed to upload the avatar image (${String(putRes.status)})`);
3300
3621
  return {
@@ -3306,19 +3627,35 @@ function buildVoiceTools(client) {
3306
3627
  }),
3307
3628
  defineTool({
3308
3629
  name: "generate_image",
3309
- description: "Generate an image from a text prompt. Returns a URL to the generated PNG. To show the image to the user, embed it in your reply as markdown: ![description](imageUrl). To set the generated image as YOUR OWN profile picture, pass its url to `set_avatar` (NOT update_identity, which edits other people you know). Or skip this and use `generate_avatar` to generate + set your avatar in one step. Optional `model` picks the image model (default gpt-image-1). Generation is synchronous and may take up to ~30s; if it times out, retry or use a standard size.",
3630
+ description: "Generate an image from a text prompt. Returns a URL to the generated image. To show the image to the user, embed it in your reply as markdown: ![description](imageUrl). To set the generated image as YOUR OWN profile picture, pass its url to `set_avatar` (NOT update_identity, which edits other people you know). Or skip this and use `generate_avatar` to generate + set your avatar in one step. Optional `model` picks the image model (default gpt-image-1). Generation is queued server-side while this tool polls for completion and may take up to three minutes; if it times out, retry or use a standard size.",
3310
3631
  parameters: Type.Object({
3311
- prompt: Type.String({ description: "Text description of the image to generate." }),
3312
- model: Type.Optional(Type.String({ description: "Image model id (default \"gpt-image-1\"). Others may be available, e.g. dall-e-3." })),
3313
- size: Type.Optional(Type.String({ description: "Image size. For the default gpt-image-1 use one of \"1024x1024\" (square), \"1536x1024\" (landscape), \"1024x1536\" (portrait), or \"auto\" — NOT \"512x512\". Other models accept their own sizes. Omit to use the provider default." })),
3314
- quality: Type.Optional(Type.String({ description: "Image quality hint (provider-dependent)." }))
3315
- }),
3632
+ prompt: Type.String({
3633
+ description: "Text description of the image to generate.",
3634
+ minLength: 1,
3635
+ maxLength: 4e3
3636
+ }),
3637
+ model: Type.Optional(Type.String({
3638
+ description: "Image model id (default \"gpt-image-1\"). Others may be available, e.g. dall-e-3.",
3639
+ minLength: 1,
3640
+ maxLength: 100
3641
+ })),
3642
+ size: Type.Optional(Type.String({
3643
+ description: "Image size. For the default gpt-image-1 use one of \"1024x1024\" (square), \"1536x1024\" (landscape), \"1024x1536\" (portrait), or \"auto\" — NOT \"512x512\". Other models accept their own sizes. Omit to use the provider default.",
3644
+ minLength: 1,
3645
+ maxLength: 32
3646
+ })),
3647
+ quality: Type.Optional(Type.String({
3648
+ description: "Image quality hint (provider-dependent).",
3649
+ minLength: 1,
3650
+ maxLength: 32
3651
+ }))
3652
+ }, { additionalProperties: false }),
3316
3653
  handler: async (params) => {
3317
3654
  const { imageUrl, model } = await client.generateImage({
3318
- prompt: params.prompt,
3319
- model: params.model,
3320
- size: params.size,
3321
- quality: params.quality
3655
+ prompt: requireString(params, "prompt", 4e3),
3656
+ model: optionalString(params, "model", 100),
3657
+ size: optionalString(params, "size", 32),
3658
+ quality: optionalString(params, "quality", 32)
3322
3659
  });
3323
3660
  return {
3324
3661
  imageUrl,
@@ -3341,42 +3678,59 @@ const plugin = {
3341
3678
  try {
3342
3679
  cfg = (0, _alfe_ai_config.resolveConfig)();
3343
3680
  } catch (err) {
3344
- log.warn(`Integration tools not registered — config not available: ${err.message}`);
3681
+ log.warn(`Integration tools not registered — config not available: ${err instanceof Error ? err.message : "unknown error"}`);
3345
3682
  }
3346
3683
  if (cfg) {
3347
3684
  const client = new _alfe_ai_agent_api_client.AgentApiClient({
3348
3685
  apiKey: cfg.apiKey,
3349
3686
  apiUrl: cfg.apiUrl
3350
3687
  });
3351
- const tools = [...buildIntegrationTools(client), ...buildVoiceTools(client)];
3688
+ let agentAssetsOrigin = "https://assets.invalid";
3689
+ try {
3690
+ agentAssetsOrigin = deriveAgentAssetsOrigin(cfg.apiUrl);
3691
+ } catch {
3692
+ log.warn("Remote set_avatar URLs are unavailable for this API configuration");
3693
+ }
3694
+ const tools = [...buildIntegrationTools(client), ...buildVoiceTools(client, {
3695
+ workspacePath: cfg.workspacePath,
3696
+ agentAssetsOrigin
3697
+ })];
3352
3698
  for (const tool of tools) api.registerTool(tool);
3353
3699
  log.info(`Registered ${String(tools.length)} agent tools: ${tools.map((t) => t.name).join(", ")}`);
3354
3700
  }
3701
+ const serviceOwner = Symbol("alfe-daemon-ipc-owner");
3355
3702
  const startDaemonIpc = () => {
3356
3703
  if (globalThis.__alfeOpenclawPluginActivated === true) {
3357
3704
  log.debug("Alfe OpenClaw Plugin already activated — skipping duplicate");
3358
3705
  return;
3359
3706
  }
3360
3707
  globalThis.__alfeOpenclawPluginActivated = true;
3708
+ ipcOwner = serviceOwner;
3361
3709
  if (ipcClient) {
3362
3710
  log.warn("Stopping stale IPC client from prior activation");
3363
3711
  ipcClient.stop();
3364
3712
  ipcClient = null;
3365
3713
  }
3366
3714
  log.info("Alfe OpenClaw Plugin activating...");
3367
- const socketPath = ((api.config ?? {}).plugins?.entries?.["@alfe.ai/openclaw"]?.config ?? {}).socketPath ?? process.env.ALFE_GATEWAY_SOCKET ?? DEFAULT_SOCKET_PATH;
3368
- ipcClient = new IPCClient(socketPath);
3715
+ const configuredSocketPath = ((api.config ?? {}).plugins?.entries?.["@alfe.ai/openclaw"]?.config ?? {}).socketPath ?? process.env.ALFE_GATEWAY_SOCKET ?? DEFAULT_SOCKET_PATH;
3716
+ if (typeof configuredSocketPath !== "string" || configuredSocketPath.length < 1 || configuredSocketPath.length > 4096 || configuredSocketPath.includes("\0")) {
3717
+ ipcOwner = null;
3718
+ globalThis.__alfeOpenclawPluginActivated = false;
3719
+ throw new Error("Invalid Alfe gateway socket path");
3720
+ }
3721
+ const socketPath = configuredSocketPath;
3722
+ ipcClient = new IPCClient(socketPath, log);
3369
3723
  ipcClient.on("event", (event, payload) => {
3370
3724
  switch (event) {
3371
3725
  case "cloud.status": {
3372
- const status = payload;
3373
- log.info(`Alfe cloud: ${status.connected ? "connected" : "disconnected"}`);
3726
+ const connected = typeof payload === "object" && payload !== null && !Array.isArray(payload) && payload.connected === true;
3727
+ log.info(`Alfe cloud: ${connected ? "connected" : "disconnected"}`);
3374
3728
  break;
3375
3729
  }
3376
3730
  case "daemon.shutdown":
3377
3731
  log.info("Daemon shutting down — will reconnect when available");
3378
3732
  break;
3379
- default: log.debug(`Daemon event: ${event}`, payload);
3733
+ default: log.debug(`Daemon event: ${event}`);
3380
3734
  }
3381
3735
  });
3382
3736
  ipcClient.on("request", (method, _params, respond) => {
@@ -3393,7 +3747,9 @@ const plugin = {
3393
3747
  ipcClient.on("connected", () => {
3394
3748
  log.info("Connected to Alfe daemon — registering capabilities...");
3395
3749
  registerWithDaemon(ipc, log, plugin.id).then((result) => {
3396
- if (!result) log.error("Failed to register with daemon — commands may not be routed");
3750
+ if (ipcClient === ipc && ipcOwner === serviceOwner && !result) log.error("Failed to register with daemon — commands may not be routed");
3751
+ }).catch((error) => {
3752
+ if (ipcClient === ipc && ipcOwner === serviceOwner) log.error(`Capability registration failed: ${error instanceof Error ? error.message : String(error)}`);
3397
3753
  });
3398
3754
  });
3399
3755
  ipcClient.on("disconnected", (reason) => {
@@ -3402,15 +3758,28 @@ const plugin = {
3402
3758
  ipcClient.on("error", (err) => {
3403
3759
  log.error(`IPC error: ${err.message}`);
3404
3760
  });
3405
- ipcClient.start();
3761
+ try {
3762
+ ipcClient.start();
3763
+ } catch (error) {
3764
+ ipcClient.stop();
3765
+ ipcClient = null;
3766
+ ipcOwner = null;
3767
+ globalThis.__alfeOpenclawPluginActivated = false;
3768
+ throw error;
3769
+ }
3406
3770
  log.info(`Alfe OpenClaw Plugin activated (socket: ${socketPath})`);
3407
3771
  };
3408
3772
  const stopDaemonIpc = () => {
3773
+ if (ipcOwner !== serviceOwner) {
3774
+ log.debug("Ignoring stop from a non-owning Alfe IPC service registration");
3775
+ return;
3776
+ }
3409
3777
  log.info("Alfe OpenClaw Plugin deactivating...");
3410
3778
  if (ipcClient) {
3411
3779
  ipcClient.stop();
3412
3780
  ipcClient = null;
3413
3781
  }
3782
+ ipcOwner = null;
3414
3783
  globalThis.__alfeOpenclawPluginActivated = false;
3415
3784
  log.info("Alfe OpenClaw Plugin deactivated");
3416
3785
  };
@@ -3430,9 +3799,10 @@ const plugin = {
3430
3799
  log.info("Alfe OpenClaw Plugin deactivating (legacy)...");
3431
3800
  ipcClient.stop();
3432
3801
  ipcClient = null;
3433
- globalThis.__alfeOpenclawPluginActivated = false;
3434
3802
  log.info("Alfe OpenClaw Plugin deactivated");
3435
3803
  }
3804
+ ipcOwner = null;
3805
+ globalThis.__alfeOpenclawPluginActivated = false;
3436
3806
  }
3437
3807
  };
3438
3808
  //#endregion