@alfe.ai/openclaw 0.4.9 → 0.4.11

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