@clawling/clawchat-plugin-openclaw 2026.8.14-1 → 2026.8.20-1

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.
@@ -153,8 +153,13 @@ export function createOpenclawClawlingApiClient(opts) {
153
153
  // `X-Device-Id` is sent on every request so the server can correlate
154
154
  // activity back to this plugin instance. Callers may override via
155
155
  // `extra` (e.g. tests) but the default is the channel id.
156
+ //
157
+ // A blank token gets NO Authorization header at all: `Bearer ` with an
158
+ // empty credential is a zero-segment JWT that the backend logs as
159
+ // malformed-token 401 noise (the unauthenticated plugin-report call sent
160
+ // one on every gateway entry).
156
161
  return {
157
- authorization: `Bearer ${opts.token}`,
162
+ ...(opts.token.trim() ? { authorization: `Bearer ${opts.token}` } : {}),
158
163
  "x-device-id": CHANNEL_ID,
159
164
  ...extra,
160
165
  };
@@ -221,6 +221,28 @@ function decodeJwtStringClaim(token, claim) {
221
221
  * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
222
222
  * configured, mirroring the `oid` fallback above.
223
223
  */
224
+ /**
225
+ * Reject an ownerUserId that is an unrendered provisioning-template
226
+ * placeholder — treat it as absent (fall through to the next source) instead
227
+ * of using it verbatim.
228
+ *
229
+ * 2026-08 production incident: a provisioner shipped the literal `usr_owner`
230
+ * (this repo's own test-fixture value) un-rendered; every affected agent
231
+ * passed the connect gate and hammered prod with `GET /v1/users/usr_owner`
232
+ * plus malformed-JWT retries for days. Dev/mock ids stay loose on purpose —
233
+ * only the known fixture literal and unmistakable template syntax
234
+ * ({{…}}, ${…}, <…>, %…%) are rejected.
235
+ */
236
+ function rejectPlaceholderOwnerUserId(value, source) {
237
+ if (!value)
238
+ return "";
239
+ const looksUnrendered = value === "usr_owner" || /[{}<>%]|\$\{/.test(value);
240
+ if (looksUnrendered) {
241
+ console.warn(`clawchat ownerUserId from ${source} looks like an unrendered template placeholder ("${value}"); ignoring it. Fix the provisioning template — without a real owner id (or an oid claim in the token) the agent stays in activation-wait instead of hammering the backend.`);
242
+ return "";
243
+ }
244
+ return value;
245
+ }
224
246
  function resolveUserId(token, configured) {
225
247
  const tokenSub = decodeJwtStringClaim(token, "sub");
226
248
  if (!tokenSub)
@@ -336,8 +358,8 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
336
358
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
337
359
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
338
360
  const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV));
339
- const ownerUserId = readOptionalString(channel.ownerUserId) ||
340
- readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
361
+ const ownerUserId = rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
362
+ rejectPlaceholderOwnerUserId(readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV), CLAWCHAT_OWNER_USER_ID_ENV) ||
341
363
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
342
364
  // inject token + userId but omit ownerUserId; without it the connect gate
343
365
  // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
@@ -1,4 +1,5 @@
1
1
  import { buildOutboundMediaLoadOptions, } from "openclaw/plugin-sdk/media-runtime";
2
+ import { ClawlingApiError } from "./api-types.js";
2
3
  export function inferMediaKindFromMime(mime) {
3
4
  if (!mime)
4
5
  return "file";
@@ -10,7 +11,178 @@ export function inferMediaKindFromMime(mime) {
10
11
  return "video";
11
12
  return "file";
12
13
  }
13
- const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
14
+ /**
15
+ * Single-file ceiling of the upstream msghub media service — its
16
+ * `media.max_size_bytes` is `104857600` in every shipped upstream config, and a
17
+ * larger body is rejected with HTTP 413 / business code `41301`. The plugin
18
+ * mirrors the server capability rather than under-reporting it: a lower local
19
+ * cap silently fails attachments the service would accept.
20
+ */
21
+ export const CLAWCHAT_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
22
+ const DEFAULT_MEDIA_MAX_BYTES = CLAWCHAT_MEDIA_MAX_BYTES;
23
+ /** Three attempts per attachment = the initial upload plus two backoff retries. */
24
+ export const DEFAULT_UPLOAD_RETRY_POLICY = {
25
+ maxAttempts: 3,
26
+ baseDelayMs: 300,
27
+ maxDelayMs: 2_000,
28
+ };
29
+ /** Bounded exponential backoff for the wait AFTER 1-based `attempt`. */
30
+ export function uploadRetryDelayMs(attempt, policy = DEFAULT_UPLOAD_RETRY_POLICY) {
31
+ return Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
32
+ }
33
+ /**
34
+ * Media-service business codes that reject THIS payload on its merits:
35
+ * missing file part, empty upload, bad token, too large, disallowed MIME.
36
+ * Re-sending identical bytes produces an identical rejection, so these never
37
+ * retry regardless of status.
38
+ */
39
+ const PERMANENT_BUSINESS_CODES = new Set([40001, 40002, 40101, 41301, 41501]);
40
+ /**
41
+ * Socket/DNS/timeout signatures for throws that never made it through the API
42
+ * client's own wrapping (e.g. an `AbortError` surfaced by the host fetch).
43
+ */
44
+ const TRANSIENT_ERROR_PATTERN = /(econnreset|econnrefused|econnaborted|etimedout|epipe|ehostunreach|enetunreach|enetdown|eai_again|enotfound|socket hang up|network error|fetch failed|timed out|timeout|aborted)/i;
45
+ function isRetryableHttpStatus(status) {
46
+ // 408 request timeout, 425 too early, 429 rate limited, plus every 5xx.
47
+ return status === 408 || status === 425 || status === 429 || status >= 500;
48
+ }
49
+ const API_ERROR_KINDS = ["auth", "api", "transport", "validation"];
50
+ function asClawlingApiError(err) {
51
+ if (err instanceof ClawlingApiError)
52
+ return err;
53
+ // Structural fallback: a duck-typed error from a mocked client still carries
54
+ // the diagnostics we must log, and misreading it as `unknown` would lose them.
55
+ if (err instanceof Error &&
56
+ API_ERROR_KINDS.includes(err.kind)) {
57
+ return err;
58
+ }
59
+ return undefined;
60
+ }
61
+ function isRetryableFailure(detail) {
62
+ // Local pre-flight failures and credential rejections do not get better by
63
+ // trying again with the same bytes and the same token.
64
+ if (detail.kind === "validation" || detail.kind === "auth")
65
+ return false;
66
+ if (typeof detail.code === "number" && PERMANENT_BUSINESS_CODES.has(detail.code))
67
+ return false;
68
+ if (typeof detail.status === "number")
69
+ return isRetryableHttpStatus(detail.status);
70
+ // `transport` without a status is a failure before any response existed —
71
+ // DNS, connection reset, timeout: the transient case this retry loop is for.
72
+ if (detail.kind === "transport")
73
+ return true;
74
+ // `api` without a status is a server-declared business rejection.
75
+ return false;
76
+ }
77
+ /** Classify a thrown upload error into structured diagnostics + a retry verdict. */
78
+ export function classifyUploadError(err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ const api = asClawlingApiError(err);
81
+ if (!api) {
82
+ return { kind: "unknown", message, retryable: TRANSIENT_ERROR_PATTERN.test(message) };
83
+ }
84
+ const detail = {
85
+ kind: api.kind,
86
+ message,
87
+ retryable: false,
88
+ ...(typeof api.meta?.status === "number" ? { status: api.meta.status } : {}),
89
+ ...(typeof api.meta?.code === "number" ? { code: api.meta.code } : {}),
90
+ ...(api.meta?.path ? { path: api.meta.path } : {}),
91
+ ...(api.meta?.data !== undefined ? { data: api.meta.data } : {}),
92
+ };
93
+ detail.retryable = isRetryableFailure(detail);
94
+ return detail;
95
+ }
96
+ const BEARER_PATTERN = /\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi;
97
+ const JWT_PATTERN = /\beyJ[A-Za-z0-9._~+/=-]{10,}/g;
98
+ const SECRET_FIELD_PATTERN = /("?\b(?:authorization|access[_-]?token|refresh[_-]?token|token|api[_-]?key|apikey|password|passwd|secret|signature|x-amz-signature|x-amz-credential)\b"?\s*[:=]\s*)"?[^"',\s&}]+"?/gi;
99
+ /**
100
+ * Strip anything credential-shaped out of a diagnostic string. Upload failures
101
+ * are logged with the server's `msg`/`data` verbatim, and neither is under this
102
+ * plugin's control — a backend that echoes a header or a presigned URL must not
103
+ * turn a log line into a token leak.
104
+ */
105
+ export function redactMediaDiagnostics(text) {
106
+ return text
107
+ .replace(BEARER_PATTERN, "$1 [redacted]")
108
+ .replace(SECRET_FIELD_PATTERN, '$1"[redacted]"')
109
+ .replace(JWT_PATTERN, "[redacted]");
110
+ }
111
+ /**
112
+ * Reduce an outbound media source to something safe to log: presigned HTTP(S)
113
+ * URLs carry their credential in the query string, so only origin + path
114
+ * survive. Local paths are kept (they are the only way to identify the
115
+ * attachment) but never their contents.
116
+ */
117
+ export function sanitizeMediaSource(source) {
118
+ try {
119
+ const parsed = new URL(source);
120
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
121
+ const trimmed = `${parsed.origin}${parsed.pathname}`;
122
+ return parsed.search || parsed.hash ? `${trimmed} (query redacted)` : trimmed;
123
+ }
124
+ }
125
+ catch {
126
+ // Not a URL — a local path. Fall through to plain redaction.
127
+ }
128
+ return redactMediaDiagnostics(source);
129
+ }
130
+ function serializeDiagnosticValue(value, limit = 300) {
131
+ let text;
132
+ try {
133
+ text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
134
+ }
135
+ catch {
136
+ text = "[unserializable]";
137
+ }
138
+ const redacted = redactMediaDiagnostics(text);
139
+ return redacted.length > limit ? `${redacted.slice(0, limit)}…` : redacted;
140
+ }
141
+ /**
142
+ * Render a failure as `key=value` diagnostics: error kind, HTTP status,
143
+ * business code, request path, response data, plus file name / size / MIME.
144
+ * Everything passes through {@link redactMediaDiagnostics} first — no token,
145
+ * `Authorization` header, or local file content ever reaches a log line.
146
+ */
147
+ export function formatOutboundMediaFailure(failure) {
148
+ const { detail } = failure;
149
+ return [
150
+ `source=${failure.source}`,
151
+ ...(failure.fileName ? [`file=${serializeDiagnosticValue(failure.fileName, 120)}`] : []),
152
+ ...(typeof failure.size === "number" ? [`size=${failure.size}`] : []),
153
+ ...(failure.mime ? [`mime=${failure.mime}`] : []),
154
+ `stage=${failure.stage}`,
155
+ `attempts=${failure.attempts}`,
156
+ `retryable=${detail.retryable}`,
157
+ `kind=${detail.kind}`,
158
+ ...(typeof detail.status === "number" ? [`status=${detail.status}`] : []),
159
+ ...(typeof detail.code === "number" ? [`code=${detail.code}`] : []),
160
+ ...(detail.path ? [`path=${detail.path}`] : []),
161
+ ...(detail.data !== undefined ? [`data=${serializeDiagnosticValue(detail.data)}`] : []),
162
+ `error=${serializeDiagnosticValue(detail.message)}`,
163
+ ].join(" ");
164
+ }
165
+ function buildFailure(input) {
166
+ const failure = { ...input, summary: "" };
167
+ failure.summary = formatOutboundMediaFailure(failure);
168
+ return failure;
169
+ }
170
+ /**
171
+ * Build the send-blocking error message for a batch that lost attachments.
172
+ *
173
+ * Both call sites (the `sendMedia` adapter and the reply dispatcher) reject the
174
+ * whole send on a shortfall, so this is the only text the agent sees: it must
175
+ * name every failed attachment with its reason instead of a generic
176
+ * "upload failed" that leaves the operator with nothing to act on.
177
+ */
178
+ export function describeOutboundMediaShortfall(result, requested) {
179
+ const missing = Math.max(requested - result.fragments.length, result.failures.length);
180
+ const outcome = result.fragments.length > 0 ? "partial success" : "all failed";
181
+ const reasons = result.failures.map((f, i) => `[${i + 1}] ${f.summary}`).join(" | ");
182
+ return (`clawchat-plugin-openclaw failed to upload ${missing}/${requested} outbound media ` +
183
+ `attachment(s) (${outcome}, ${result.fragments.length}/${requested} uploaded); ` +
184
+ `message not sent${reasons ? `: ${reasons}` : ""}`);
185
+ }
14
186
  /**
15
187
  * Fetch each remote URL via the shared media runtime, persist to a local
16
188
  * cache, and return the list of local paths.
@@ -33,53 +205,113 @@ export async function fetchInboundMedia(items, ctx) {
33
205
  paths.push(saved.path);
34
206
  }
35
207
  catch (err) {
36
- ctx.log?.info?.(`clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`);
208
+ ctx.log?.info?.(`clawchat-plugin-openclaw inbound media skipped: ${sanitizeMediaSource(item.url)} (${redactMediaDiagnostics(err instanceof Error ? err.message : String(err))})`);
37
209
  }
38
210
  }
39
211
  return paths;
40
212
  }
41
213
  /**
42
214
  * Upload each URL (remote or local path) to /media/upload via the api
43
- * client and return a fragment ready to splice into `body.fragments`.
215
+ * client and return fragments ready to splice into `body.fragments`, plus a
216
+ * structured failure record for every attachment that did not make it.
44
217
  *
45
218
  * Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
46
219
  * enforcement and media-loading policy stay aligned with the current
47
220
  * OpenClaw runtime instead of a directly imported helper.
48
221
  *
49
- * Single-upload failures log at error and are dropped; the remaining
50
- * fragments still come back so a partially-failing batch still sends the
51
- * working media.
222
+ * Transient upload faults (network/timeout, HTTP 5xx, 429/408) are retried
223
+ * with bounded exponential backoff per {@link DEFAULT_UPLOAD_RETRY_POLICY};
224
+ * local validation, auth, and definitive business rejections fail on the first
225
+ * attempt. One attachment giving up never aborts the batch — every remaining
226
+ * URL is still loaded and uploaded, and its fragment comes back — so the
227
+ * caller can distinguish partial success from a total failure and report why.
52
228
  */
53
229
  export async function uploadOutboundMedia(urls, ctx) {
230
+ const result = { fragments: [], failures: [] };
54
231
  if (urls.length === 0)
55
- return [];
232
+ return result;
56
233
  const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
57
- const out = [];
234
+ const policy = { ...DEFAULT_UPLOAD_RETRY_POLICY, ...(ctx.retry ?? {}) };
235
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
58
236
  for (const url of urls) {
237
+ const source = sanitizeMediaSource(url);
238
+ let loaded;
59
239
  try {
60
- const loaded = await ctx.runtime.media.loadWebMedia(url, buildOutboundMediaLoadOptions({
240
+ loaded = await ctx.runtime.media.loadWebMedia(url, buildOutboundMediaLoadOptions({
61
241
  maxBytes,
62
242
  ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
63
243
  ...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
64
244
  ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
65
245
  }));
66
- const uploaded = await ctx.apiClient.uploadMedia({
67
- buffer: loaded.buffer,
68
- filename: loaded.fileName ?? "upload.bin",
69
- mime: loaded.contentType,
70
- });
71
- const fragment = {
72
- kind: uploaded.kind,
73
- url: uploaded.url,
74
- name: uploaded.name,
75
- mime: uploaded.mime,
76
- size: uploaded.size,
77
- };
78
- out.push(fragment);
79
246
  }
80
247
  catch (err) {
81
- ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`);
248
+ // Loading is host policy plus a one-shot read (missing path, outside the
249
+ // allowed roots, over `maxBytes`). Nothing here is transient, so it is
250
+ // never retried — and the bytes never reached the media service.
251
+ const failure = buildFailure({
252
+ source,
253
+ stage: "load",
254
+ attempts: 1,
255
+ detail: { ...classifyUploadError(err), retryable: false },
256
+ });
257
+ result.failures.push(failure);
258
+ ctx.log?.error?.(`clawchat-plugin-openclaw outbound media load failed: ${failure.summary}`);
259
+ continue;
260
+ }
261
+ const fileName = loaded.fileName ?? "upload.bin";
262
+ const size = loaded.buffer.byteLength;
263
+ const mime = loaded.contentType;
264
+ let pending;
265
+ let attempts = 0;
266
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
267
+ attempts = attempt;
268
+ try {
269
+ const uploaded = await ctx.apiClient.uploadMedia({
270
+ buffer: loaded.buffer,
271
+ filename: fileName,
272
+ ...(mime ? { mime } : {}),
273
+ });
274
+ result.fragments.push({
275
+ kind: uploaded.kind,
276
+ url: uploaded.url,
277
+ name: uploaded.name,
278
+ mime: uploaded.mime,
279
+ size: uploaded.size,
280
+ });
281
+ pending = undefined;
282
+ break;
283
+ }
284
+ catch (err) {
285
+ pending = classifyUploadError(err);
286
+ if (!pending.retryable || attempt >= policy.maxAttempts)
287
+ break;
288
+ const delayMs = uploadRetryDelayMs(attempt, policy);
289
+ const attemptFailure = buildFailure({
290
+ source,
291
+ stage: "upload",
292
+ attempts: attempt,
293
+ fileName,
294
+ size,
295
+ ...(mime ? { mime } : {}),
296
+ detail: pending,
297
+ });
298
+ ctx.log?.info?.(`clawchat-plugin-openclaw outbound media upload retry ${attempt}/${policy.maxAttempts - 1} in ${delayMs}ms: ${attemptFailure.summary}`);
299
+ await sleep(delayMs);
300
+ }
301
+ }
302
+ if (pending) {
303
+ const failure = buildFailure({
304
+ source,
305
+ stage: "upload",
306
+ attempts,
307
+ fileName,
308
+ size,
309
+ ...(mime ? { mime } : {}),
310
+ detail: pending,
311
+ });
312
+ result.failures.push(failure);
313
+ ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${failure.summary}`);
82
314
  }
83
315
  }
84
- return out;
316
+ return result;
85
317
  }
@@ -6,7 +6,7 @@ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
8
8
  import { applyTextMentionLabels, buildMentionMessageFragments, normalizeMentionTargets, textToFragments, } from "./message-mapper.js";
9
- import { uploadOutboundMedia } from "./media-runtime.js";
9
+ import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
10
10
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
11
11
  import { stripNoReplyTokens } from "./no-reply.js";
12
12
  import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
@@ -723,16 +723,20 @@ export const openclawClawlingOutbound = {
723
723
  token: account.token,
724
724
  userId: account.userId,
725
725
  });
726
- const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
726
+ const uploadResult = await uploadOutboundMedia([mediaUrl.trim()], {
727
727
  apiClient,
728
728
  runtime,
729
729
  ...(mediaAccess ? { mediaAccess } : {}),
730
730
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
731
731
  ...(mediaReadFile ? { mediaReadFile } : {}),
732
732
  });
733
- if (mediaFragments.length === 0) {
734
- throw new Error(`clawchat-plugin-openclaw failed to upload media: ${mediaUrl}`);
733
+ // Single-attachment path, so "shortfall" is always a total failure here;
734
+ // the message still carries the structured, redacted reason instead of a
735
+ // bare "failed to upload" the operator cannot act on.
736
+ if (uploadResult.fragments.length === 0) {
737
+ throw new Error(describeOutboundMediaShortfall(uploadResult, 1));
735
738
  }
739
+ const mediaFragments = uploadResult.fragments;
736
740
  const target = parseOpenclawRecipient(to);
737
741
  const messageId = mintOutboundMessageId(account);
738
742
  const claimText = (text ?? "").trim();
@@ -2,7 +2,7 @@ import { interactiveReplyToPresentation, renderMessagePresentationFallbackText,
2
2
  import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
3
3
  import { createOpenclawClawlingApiClient } from "./api-client.js";
4
4
  import { effectiveOutputVisibility, } from "./config.js";
5
- import { uploadOutboundMedia } from "./media-runtime.js";
5
+ import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
6
6
  import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
7
7
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
8
8
  import { consumeTerminalClawChatSend } from "./terminal-send.js";
@@ -293,15 +293,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
293
293
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound media skipped: baseUrl not configured`);
294
294
  return [];
295
295
  }
296
- const fragments = await uploadOutboundMedia(urls, { apiClient, runtime, log });
297
- if (fragments.length < urls.length) {
298
- // uploadOutboundMedia drops a failed item with only a log line. A shortfall
299
- // means an attachment never reached the media service; surfacing it (parity
300
- // with the adapter path in outbound.ts and with the Hermes plugin) stops a
301
- // text-only frame from going out and reading as a successful send.
302
- throw new Error(`clawchat-plugin-openclaw failed to upload ${urls.length - fragments.length}/${urls.length} outbound media attachment(s); message not sent`);
296
+ const result = await uploadOutboundMedia(urls, { apiClient, runtime, log });
297
+ if (result.fragments.length < urls.length) {
298
+ // uploadOutboundMedia never aborts the batch: every URL is attempted and
299
+ // the successful fragments still come back. A shortfall — partial success
300
+ // or total failure alike — means at least one attachment never reached
301
+ // the media service, so the whole send is rejected (parity with the
302
+ // adapter path in outbound.ts and with the Hermes plugin) rather than
303
+ // letting a text-only frame go out reading as a successful send. The
304
+ // thrown message carries each failure's structured, redacted diagnostics
305
+ // so the agent is not left with a reasonless generic error.
306
+ throw new Error(describeOutboundMediaShortfall(result, urls.length));
303
307
  }
304
- return fragments;
308
+ return result.fragments;
305
309
  }
306
310
  // ----- Reply state ------------------------------------------------------
307
311
  let reasoningText = "";
@@ -16,7 +16,7 @@ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effec
16
16
  import { isValidChatId } from "./ws-client.js";
17
17
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
18
18
  import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
19
- import { fetchInboundMedia } from "./media-runtime.js";
19
+ import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.js";
20
20
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
21
21
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
22
22
  import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawClawlingText, setAlignedOutboundLogContext, } from "./outbound.js";
@@ -2440,7 +2440,7 @@ export async function startOpenclawClawlingGateway(params) {
2440
2440
  ? await fetchInboundMedia(turn.mediaItems, {
2441
2441
  runtime,
2442
2442
  log,
2443
- maxBytes: 20 * 1024 * 1024,
2443
+ maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
2444
2444
  })
2445
2445
  : [];
2446
2446
  if (inboundPaths.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.14-1",
3
+ "version": "2026.8.20-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/api-client.ts CHANGED
@@ -337,8 +337,13 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
337
337
  // `X-Device-Id` is sent on every request so the server can correlate
338
338
  // activity back to this plugin instance. Callers may override via
339
339
  // `extra` (e.g. tests) but the default is the channel id.
340
+ //
341
+ // A blank token gets NO Authorization header at all: `Bearer ` with an
342
+ // empty credential is a zero-segment JWT that the backend logs as
343
+ // malformed-token 401 noise (the unauthenticated plugin-report call sent
344
+ // one on every gateway entry).
340
345
  return {
341
- authorization: `Bearer ${opts.token}`,
346
+ ...(opts.token.trim() ? { authorization: `Bearer ${opts.token}` } : {}),
342
347
  "x-device-id": CHANNEL_ID,
343
348
  ...extra,
344
349
  };
package/src/config.ts CHANGED
@@ -349,6 +349,30 @@ function decodeJwtStringClaim(token: string, claim: string): string {
349
349
  * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
350
350
  * configured, mirroring the `oid` fallback above.
351
351
  */
352
+ /**
353
+ * Reject an ownerUserId that is an unrendered provisioning-template
354
+ * placeholder — treat it as absent (fall through to the next source) instead
355
+ * of using it verbatim.
356
+ *
357
+ * 2026-08 production incident: a provisioner shipped the literal `usr_owner`
358
+ * (this repo's own test-fixture value) un-rendered; every affected agent
359
+ * passed the connect gate and hammered prod with `GET /v1/users/usr_owner`
360
+ * plus malformed-JWT retries for days. Dev/mock ids stay loose on purpose —
361
+ * only the known fixture literal and unmistakable template syntax
362
+ * ({{…}}, ${…}, <…>, %…%) are rejected.
363
+ */
364
+ function rejectPlaceholderOwnerUserId(value: string, source: string): string {
365
+ if (!value) return "";
366
+ const looksUnrendered = value === "usr_owner" || /[{}<>%]|\$\{/.test(value);
367
+ if (looksUnrendered) {
368
+ console.warn(
369
+ `clawchat ownerUserId from ${source} looks like an unrendered template placeholder ("${value}"); ignoring it. Fix the provisioning template — without a real owner id (or an oid claim in the token) the agent stays in activation-wait instead of hammering the backend.`,
370
+ );
371
+ return "";
372
+ }
373
+ return value;
374
+ }
375
+
352
376
  function resolveUserId(token: string, configured: string): string {
353
377
  const tokenSub = decodeJwtStringClaim(token, "sub");
354
378
  if (!tokenSub) return configured;
@@ -498,8 +522,11 @@ export function resolveOpenclawClawlingAccount(
498
522
  readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV),
499
523
  );
500
524
  const ownerUserId =
501
- readOptionalString(channel.ownerUserId) ||
502
- readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
525
+ rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
526
+ rejectPlaceholderOwnerUserId(
527
+ readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV),
528
+ CLAWCHAT_OWNER_USER_ID_ENV,
529
+ ) ||
503
530
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
504
531
  // inject token + userId but omit ownerUserId; without it the connect gate
505
532
  // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
@@ -5,6 +5,7 @@ import {
5
5
  type OutboundMediaReadFile,
6
6
  } from "openclaw/plugin-sdk/media-runtime";
7
7
  import type { OpenclawClawlingApiClient } from "./api-client.ts";
8
+ import { ClawlingApiError, type ClawlingApiErrorKind } from "./api-types.ts";
8
9
 
9
10
  /**
10
11
  * Local structural superset of the protocol media fragment narrow types.
@@ -50,6 +51,10 @@ export interface UploadOutboundCtx {
50
51
  mediaLocalRoots?: readonly string[] | "any";
51
52
  /** Host-provided read bridge for sandbox/allowed local media paths. */
52
53
  mediaReadFile?: OutboundMediaReadFile;
54
+ /** Overrides for the transient-failure retry budget. Merged over the default policy. */
55
+ retry?: Partial<UploadRetryPolicy>;
56
+ /** Test seam for the backoff wait. Defaults to `setTimeout`. */
57
+ sleep?: (ms: number) => Promise<void>;
53
58
  }
54
59
 
55
60
  export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
@@ -60,7 +65,250 @@ export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kin
60
65
  return "file";
61
66
  }
62
67
 
63
- const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
68
+ /**
69
+ * Single-file ceiling of the upstream msghub media service — its
70
+ * `media.max_size_bytes` is `104857600` in every shipped upstream config, and a
71
+ * larger body is rejected with HTTP 413 / business code `41301`. The plugin
72
+ * mirrors the server capability rather than under-reporting it: a lower local
73
+ * cap silently fails attachments the service would accept.
74
+ */
75
+ export const CLAWCHAT_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
76
+
77
+ const DEFAULT_MEDIA_MAX_BYTES = CLAWCHAT_MEDIA_MAX_BYTES;
78
+
79
+ export interface UploadRetryPolicy {
80
+ /** Total attempts per attachment, including the first one. */
81
+ maxAttempts: number;
82
+ /** Wait after the first failed attempt; doubled per further attempt. */
83
+ baseDelayMs: number;
84
+ /** Upper bound on any single wait, so the backoff stays bounded. */
85
+ maxDelayMs: number;
86
+ }
87
+
88
+ /** Three attempts per attachment = the initial upload plus two backoff retries. */
89
+ export const DEFAULT_UPLOAD_RETRY_POLICY: UploadRetryPolicy = {
90
+ maxAttempts: 3,
91
+ baseDelayMs: 300,
92
+ maxDelayMs: 2_000,
93
+ };
94
+
95
+ /** Bounded exponential backoff for the wait AFTER 1-based `attempt`. */
96
+ export function uploadRetryDelayMs(
97
+ attempt: number,
98
+ policy: UploadRetryPolicy = DEFAULT_UPLOAD_RETRY_POLICY,
99
+ ): number {
100
+ return Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
101
+ }
102
+
103
+ /** Structured, credential-free diagnostics for one failed upload attempt. */
104
+ export interface UploadErrorDetail {
105
+ /** `ClawlingApiError.kind`, or `"unknown"` for a non-API throw. */
106
+ kind: ClawlingApiErrorKind | "unknown";
107
+ /** HTTP status, when the failure carried a response. */
108
+ status?: number;
109
+ /** Business `code` from the `{ code, msg, data }` envelope. */
110
+ code?: number;
111
+ /** Request path the failure came from (e.g. `/media/upload`). */
112
+ path?: string;
113
+ /** Response `data` payload, when the envelope carried one. */
114
+ data?: unknown;
115
+ message: string;
116
+ retryable: boolean;
117
+ }
118
+
119
+ /**
120
+ * Media-service business codes that reject THIS payload on its merits:
121
+ * missing file part, empty upload, bad token, too large, disallowed MIME.
122
+ * Re-sending identical bytes produces an identical rejection, so these never
123
+ * retry regardless of status.
124
+ */
125
+ const PERMANENT_BUSINESS_CODES = new Set([40001, 40002, 40101, 41301, 41501]);
126
+
127
+ /**
128
+ * Socket/DNS/timeout signatures for throws that never made it through the API
129
+ * client's own wrapping (e.g. an `AbortError` surfaced by the host fetch).
130
+ */
131
+ const TRANSIENT_ERROR_PATTERN =
132
+ /(econnreset|econnrefused|econnaborted|etimedout|epipe|ehostunreach|enetunreach|enetdown|eai_again|enotfound|socket hang up|network error|fetch failed|timed out|timeout|aborted)/i;
133
+
134
+ function isRetryableHttpStatus(status: number): boolean {
135
+ // 408 request timeout, 425 too early, 429 rate limited, plus every 5xx.
136
+ return status === 408 || status === 425 || status === 429 || status >= 500;
137
+ }
138
+
139
+ const API_ERROR_KINDS: readonly string[] = ["auth", "api", "transport", "validation"];
140
+
141
+ function asClawlingApiError(err: unknown): ClawlingApiError | undefined {
142
+ if (err instanceof ClawlingApiError) return err;
143
+ // Structural fallback: a duck-typed error from a mocked client still carries
144
+ // the diagnostics we must log, and misreading it as `unknown` would lose them.
145
+ if (
146
+ err instanceof Error &&
147
+ API_ERROR_KINDS.includes((err as { kind?: unknown }).kind as string)
148
+ ) {
149
+ return err as ClawlingApiError;
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ function isRetryableFailure(detail: UploadErrorDetail): boolean {
155
+ // Local pre-flight failures and credential rejections do not get better by
156
+ // trying again with the same bytes and the same token.
157
+ if (detail.kind === "validation" || detail.kind === "auth") return false;
158
+ if (typeof detail.code === "number" && PERMANENT_BUSINESS_CODES.has(detail.code)) return false;
159
+ if (typeof detail.status === "number") return isRetryableHttpStatus(detail.status);
160
+ // `transport` without a status is a failure before any response existed —
161
+ // DNS, connection reset, timeout: the transient case this retry loop is for.
162
+ if (detail.kind === "transport") return true;
163
+ // `api` without a status is a server-declared business rejection.
164
+ return false;
165
+ }
166
+
167
+ /** Classify a thrown upload error into structured diagnostics + a retry verdict. */
168
+ export function classifyUploadError(err: unknown): UploadErrorDetail {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ const api = asClawlingApiError(err);
171
+ if (!api) {
172
+ return { kind: "unknown", message, retryable: TRANSIENT_ERROR_PATTERN.test(message) };
173
+ }
174
+ const detail: UploadErrorDetail = {
175
+ kind: api.kind,
176
+ message,
177
+ retryable: false,
178
+ ...(typeof api.meta?.status === "number" ? { status: api.meta.status } : {}),
179
+ ...(typeof api.meta?.code === "number" ? { code: api.meta.code } : {}),
180
+ ...(api.meta?.path ? { path: api.meta.path } : {}),
181
+ ...(api.meta?.data !== undefined ? { data: api.meta.data } : {}),
182
+ };
183
+ detail.retryable = isRetryableFailure(detail);
184
+ return detail;
185
+ }
186
+
187
+ const BEARER_PATTERN = /\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi;
188
+ const JWT_PATTERN = /\beyJ[A-Za-z0-9._~+/=-]{10,}/g;
189
+ const SECRET_FIELD_PATTERN =
190
+ /("?\b(?:authorization|access[_-]?token|refresh[_-]?token|token|api[_-]?key|apikey|password|passwd|secret|signature|x-amz-signature|x-amz-credential)\b"?\s*[:=]\s*)"?[^"',\s&}]+"?/gi;
191
+
192
+ /**
193
+ * Strip anything credential-shaped out of a diagnostic string. Upload failures
194
+ * are logged with the server's `msg`/`data` verbatim, and neither is under this
195
+ * plugin's control — a backend that echoes a header or a presigned URL must not
196
+ * turn a log line into a token leak.
197
+ */
198
+ export function redactMediaDiagnostics(text: string): string {
199
+ return text
200
+ .replace(BEARER_PATTERN, "$1 [redacted]")
201
+ .replace(SECRET_FIELD_PATTERN, '$1"[redacted]"')
202
+ .replace(JWT_PATTERN, "[redacted]");
203
+ }
204
+
205
+ /**
206
+ * Reduce an outbound media source to something safe to log: presigned HTTP(S)
207
+ * URLs carry their credential in the query string, so only origin + path
208
+ * survive. Local paths are kept (they are the only way to identify the
209
+ * attachment) but never their contents.
210
+ */
211
+ export function sanitizeMediaSource(source: string): string {
212
+ try {
213
+ const parsed = new URL(source);
214
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
215
+ const trimmed = `${parsed.origin}${parsed.pathname}`;
216
+ return parsed.search || parsed.hash ? `${trimmed} (query redacted)` : trimmed;
217
+ }
218
+ } catch {
219
+ // Not a URL — a local path. Fall through to plain redaction.
220
+ }
221
+ return redactMediaDiagnostics(source);
222
+ }
223
+
224
+ function serializeDiagnosticValue(value: unknown, limit = 300): string {
225
+ let text: string;
226
+ try {
227
+ text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
228
+ } catch {
229
+ text = "[unserializable]";
230
+ }
231
+ const redacted = redactMediaDiagnostics(text);
232
+ return redacted.length > limit ? `${redacted.slice(0, limit)}…` : redacted;
233
+ }
234
+
235
+ /** Which step of the outbound pipeline gave up on this attachment. */
236
+ export type OutboundMediaFailureStage = "load" | "upload";
237
+
238
+ export interface OutboundMediaFailure {
239
+ /** Redacted source URL / local path the attachment came from. */
240
+ source: string;
241
+ stage: OutboundMediaFailureStage;
242
+ /** Attempts actually made, including the first. */
243
+ attempts: number;
244
+ fileName?: string;
245
+ /** Byte length of the loaded payload. */
246
+ size?: number;
247
+ mime?: string;
248
+ detail: UploadErrorDetail;
249
+ /** One-line redacted diagnostic — the same text written to the error log. */
250
+ summary: string;
251
+ }
252
+
253
+ export interface OutboundMediaUploadResult {
254
+ /** Fragments for every attachment that uploaded successfully. */
255
+ fragments: ClawlingMediaFragment[];
256
+ /** One entry per attachment that did not, in request order. */
257
+ failures: OutboundMediaFailure[];
258
+ }
259
+
260
+ /**
261
+ * Render a failure as `key=value` diagnostics: error kind, HTTP status,
262
+ * business code, request path, response data, plus file name / size / MIME.
263
+ * Everything passes through {@link redactMediaDiagnostics} first — no token,
264
+ * `Authorization` header, or local file content ever reaches a log line.
265
+ */
266
+ export function formatOutboundMediaFailure(failure: OutboundMediaFailure): string {
267
+ const { detail } = failure;
268
+ return [
269
+ `source=${failure.source}`,
270
+ ...(failure.fileName ? [`file=${serializeDiagnosticValue(failure.fileName, 120)}`] : []),
271
+ ...(typeof failure.size === "number" ? [`size=${failure.size}`] : []),
272
+ ...(failure.mime ? [`mime=${failure.mime}`] : []),
273
+ `stage=${failure.stage}`,
274
+ `attempts=${failure.attempts}`,
275
+ `retryable=${detail.retryable}`,
276
+ `kind=${detail.kind}`,
277
+ ...(typeof detail.status === "number" ? [`status=${detail.status}`] : []),
278
+ ...(typeof detail.code === "number" ? [`code=${detail.code}`] : []),
279
+ ...(detail.path ? [`path=${detail.path}`] : []),
280
+ ...(detail.data !== undefined ? [`data=${serializeDiagnosticValue(detail.data)}`] : []),
281
+ `error=${serializeDiagnosticValue(detail.message)}`,
282
+ ].join(" ");
283
+ }
284
+
285
+ function buildFailure(input: Omit<OutboundMediaFailure, "summary">): OutboundMediaFailure {
286
+ const failure: OutboundMediaFailure = { ...input, summary: "" };
287
+ failure.summary = formatOutboundMediaFailure(failure);
288
+ return failure;
289
+ }
290
+
291
+ /**
292
+ * Build the send-blocking error message for a batch that lost attachments.
293
+ *
294
+ * Both call sites (the `sendMedia` adapter and the reply dispatcher) reject the
295
+ * whole send on a shortfall, so this is the only text the agent sees: it must
296
+ * name every failed attachment with its reason instead of a generic
297
+ * "upload failed" that leaves the operator with nothing to act on.
298
+ */
299
+ export function describeOutboundMediaShortfall(
300
+ result: OutboundMediaUploadResult,
301
+ requested: number,
302
+ ): string {
303
+ const missing = Math.max(requested - result.fragments.length, result.failures.length);
304
+ const outcome = result.fragments.length > 0 ? "partial success" : "all failed";
305
+ const reasons = result.failures.map((f, i) => `[${i + 1}] ${f.summary}`).join(" | ");
306
+ return (
307
+ `clawchat-plugin-openclaw failed to upload ${missing}/${requested} outbound media ` +
308
+ `attachment(s) (${outcome}, ${result.fragments.length}/${requested} uploaded); ` +
309
+ `message not sent${reasons ? `: ${reasons}` : ""}`
310
+ );
311
+ }
64
312
 
65
313
  /**
66
314
  * Fetch each remote URL via the shared media runtime, persist to a local
@@ -92,7 +340,7 @@ export async function fetchInboundMedia(
92
340
  paths.push(saved.path);
93
341
  } catch (err) {
94
342
  ctx.log?.info?.(
95
- `clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`,
343
+ `clawchat-plugin-openclaw inbound media skipped: ${sanitizeMediaSource(item.url)} (${redactMediaDiagnostics(err instanceof Error ? err.message : String(err))})`,
96
344
  );
97
345
  }
98
346
  }
@@ -101,26 +349,34 @@ export async function fetchInboundMedia(
101
349
 
102
350
  /**
103
351
  * Upload each URL (remote or local path) to /media/upload via the api
104
- * client and return a fragment ready to splice into `body.fragments`.
352
+ * client and return fragments ready to splice into `body.fragments`, plus a
353
+ * structured failure record for every attachment that did not make it.
105
354
  *
106
355
  * Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
107
356
  * enforcement and media-loading policy stay aligned with the current
108
357
  * OpenClaw runtime instead of a directly imported helper.
109
358
  *
110
- * Single-upload failures log at error and are dropped; the remaining
111
- * fragments still come back so a partially-failing batch still sends the
112
- * working media.
359
+ * Transient upload faults (network/timeout, HTTP 5xx, 429/408) are retried
360
+ * with bounded exponential backoff per {@link DEFAULT_UPLOAD_RETRY_POLICY};
361
+ * local validation, auth, and definitive business rejections fail on the first
362
+ * attempt. One attachment giving up never aborts the batch — every remaining
363
+ * URL is still loaded and uploaded, and its fragment comes back — so the
364
+ * caller can distinguish partial success from a total failure and report why.
113
365
  */
114
366
  export async function uploadOutboundMedia(
115
367
  urls: string[],
116
368
  ctx: UploadOutboundCtx,
117
- ): Promise<ClawlingMediaFragment[]> {
118
- if (urls.length === 0) return [];
369
+ ): Promise<OutboundMediaUploadResult> {
370
+ const result: OutboundMediaUploadResult = { fragments: [], failures: [] };
371
+ if (urls.length === 0) return result;
119
372
  const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
120
- const out: ClawlingMediaFragment[] = [];
373
+ const policy: UploadRetryPolicy = { ...DEFAULT_UPLOAD_RETRY_POLICY, ...(ctx.retry ?? {}) };
374
+ const sleep = ctx.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
121
375
  for (const url of urls) {
376
+ const source = sanitizeMediaSource(url);
377
+ let loaded: Awaited<ReturnType<PluginRuntime["media"]["loadWebMedia"]>>;
122
378
  try {
123
- const loaded = await ctx.runtime.media.loadWebMedia(
379
+ loaded = await ctx.runtime.media.loadWebMedia(
124
380
  url,
125
381
  buildOutboundMediaLoadOptions({
126
382
  maxBytes,
@@ -129,24 +385,74 @@ export async function uploadOutboundMedia(
129
385
  ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
130
386
  }),
131
387
  );
132
- const uploaded = await ctx.apiClient.uploadMedia({
133
- buffer: loaded.buffer,
134
- filename: loaded.fileName ?? "upload.bin",
135
- mime: loaded.contentType,
136
- });
137
- const fragment: ClawlingMediaFragment = {
138
- kind: uploaded.kind,
139
- url: uploaded.url,
140
- name: uploaded.name,
141
- mime: uploaded.mime,
142
- size: uploaded.size,
143
- };
144
- out.push(fragment);
145
388
  } catch (err) {
146
- ctx.log?.error?.(
147
- `clawchat-plugin-openclaw outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`,
148
- );
389
+ // Loading is host policy plus a one-shot read (missing path, outside the
390
+ // allowed roots, over `maxBytes`). Nothing here is transient, so it is
391
+ // never retried — and the bytes never reached the media service.
392
+ const failure = buildFailure({
393
+ source,
394
+ stage: "load",
395
+ attempts: 1,
396
+ detail: { ...classifyUploadError(err), retryable: false },
397
+ });
398
+ result.failures.push(failure);
399
+ ctx.log?.error?.(`clawchat-plugin-openclaw outbound media load failed: ${failure.summary}`);
400
+ continue;
401
+ }
402
+ const fileName = loaded.fileName ?? "upload.bin";
403
+ const size = loaded.buffer.byteLength;
404
+ const mime = loaded.contentType;
405
+ let pending: UploadErrorDetail | undefined;
406
+ let attempts = 0;
407
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
408
+ attempts = attempt;
409
+ try {
410
+ const uploaded = await ctx.apiClient.uploadMedia({
411
+ buffer: loaded.buffer,
412
+ filename: fileName,
413
+ ...(mime ? { mime } : {}),
414
+ });
415
+ result.fragments.push({
416
+ kind: uploaded.kind,
417
+ url: uploaded.url,
418
+ name: uploaded.name,
419
+ mime: uploaded.mime,
420
+ size: uploaded.size,
421
+ });
422
+ pending = undefined;
423
+ break;
424
+ } catch (err) {
425
+ pending = classifyUploadError(err);
426
+ if (!pending.retryable || attempt >= policy.maxAttempts) break;
427
+ const delayMs = uploadRetryDelayMs(attempt, policy);
428
+ const attemptFailure = buildFailure({
429
+ source,
430
+ stage: "upload",
431
+ attempts: attempt,
432
+ fileName,
433
+ size,
434
+ ...(mime ? { mime } : {}),
435
+ detail: pending,
436
+ });
437
+ ctx.log?.info?.(
438
+ `clawchat-plugin-openclaw outbound media upload retry ${attempt}/${policy.maxAttempts - 1} in ${delayMs}ms: ${attemptFailure.summary}`,
439
+ );
440
+ await sleep(delayMs);
441
+ }
442
+ }
443
+ if (pending) {
444
+ const failure = buildFailure({
445
+ source,
446
+ stage: "upload",
447
+ attempts,
448
+ fileName,
449
+ size,
450
+ ...(mime ? { mime } : {}),
451
+ detail: pending,
452
+ });
453
+ result.failures.push(failure);
454
+ ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${failure.summary}`);
149
455
  }
150
456
  }
151
- return out;
457
+ return result;
152
458
  }
package/src/outbound.ts CHANGED
@@ -16,7 +16,11 @@ import {
16
16
  textToFragments,
17
17
  type MentionTarget,
18
18
  } from "./message-mapper.ts";
19
- import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
19
+ import {
20
+ describeOutboundMediaShortfall,
21
+ uploadOutboundMedia,
22
+ type ClawlingMediaFragment,
23
+ } from "./media-runtime.ts";
20
24
  import { isClawChatNoopResponseText } from "./profile-prompt.ts";
21
25
  import { stripNoReplyTokens } from "./no-reply.ts";
22
26
  import {
@@ -947,16 +951,20 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
947
951
  token: account.token,
948
952
  userId: account.userId,
949
953
  });
950
- const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
954
+ const uploadResult = await uploadOutboundMedia([mediaUrl.trim()], {
951
955
  apiClient,
952
956
  runtime,
953
957
  ...(mediaAccess ? { mediaAccess } : {}),
954
958
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
955
959
  ...(mediaReadFile ? { mediaReadFile } : {}),
956
960
  });
957
- if (mediaFragments.length === 0) {
958
- throw new Error(`clawchat-plugin-openclaw failed to upload media: ${mediaUrl}`);
961
+ // Single-attachment path, so "shortfall" is always a total failure here;
962
+ // the message still carries the structured, redacted reason instead of a
963
+ // bare "failed to upload" the operator cannot act on.
964
+ if (uploadResult.fragments.length === 0) {
965
+ throw new Error(describeOutboundMediaShortfall(uploadResult, 1));
959
966
  }
967
+ const mediaFragments = uploadResult.fragments;
960
968
  const target = parseOpenclawRecipient(to);
961
969
  const messageId = mintOutboundMessageId(account);
962
970
  const claimText = (text ?? "").trim();
@@ -17,7 +17,11 @@ import {
17
17
  effectiveOutputVisibility,
18
18
  type ResolvedOpenclawClawlingAccount,
19
19
  } from "./config.ts";
20
- import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
20
+ import {
21
+ describeOutboundMediaShortfall,
22
+ uploadOutboundMedia,
23
+ type ClawlingMediaFragment,
24
+ } from "./media-runtime.ts";
21
25
  import {
22
26
  mintMessageId,
23
27
  sendOpenclawClawlingText,
@@ -439,17 +443,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
439
443
  );
440
444
  return [];
441
445
  }
442
- const fragments = await uploadOutboundMedia(urls, { apiClient, runtime, log });
443
- if (fragments.length < urls.length) {
444
- // uploadOutboundMedia drops a failed item with only a log line. A shortfall
445
- // means an attachment never reached the media service; surfacing it (parity
446
- // with the adapter path in outbound.ts and with the Hermes plugin) stops a
447
- // text-only frame from going out and reading as a successful send.
448
- throw new Error(
449
- `clawchat-plugin-openclaw failed to upload ${urls.length - fragments.length}/${urls.length} outbound media attachment(s); message not sent`,
450
- );
446
+ const result = await uploadOutboundMedia(urls, { apiClient, runtime, log });
447
+ if (result.fragments.length < urls.length) {
448
+ // uploadOutboundMedia never aborts the batch: every URL is attempted and
449
+ // the successful fragments still come back. A shortfall — partial success
450
+ // or total failure alike — means at least one attachment never reached
451
+ // the media service, so the whole send is rejected (parity with the
452
+ // adapter path in outbound.ts and with the Hermes plugin) rather than
453
+ // letting a text-only frame go out reading as a successful send. The
454
+ // thrown message carries each failure's structured, redacted diagnostics
455
+ // so the agent is not left with a reasonless generic error.
456
+ throw new Error(describeOutboundMediaShortfall(result, urls.length));
451
457
  }
452
- return fragments;
458
+ return result.fragments;
453
459
  }
454
460
 
455
461
  // ----- Reply state ------------------------------------------------------
package/src/runtime.ts CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  resolveManagedSkillsDir,
53
53
  runSkillUpdateCheck,
54
54
  } from "./skill-update.ts";
55
- import { fetchInboundMedia } from "./media-runtime.ts";
55
+ import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.ts";
56
56
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
57
57
  import { runWithTerminalClawChatSendScope } from "./terminal-send.ts";
58
58
  import {
@@ -2907,7 +2907,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2907
2907
  ? await fetchInboundMedia(turn.mediaItems, {
2908
2908
  runtime,
2909
2909
  log,
2910
- maxBytes: 20 * 1024 * 1024,
2910
+ maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
2911
2911
  })
2912
2912
  : [];
2913
2913
  if (inboundPaths.length > 0) {
@@ -1,83 +0,0 @@
1
- /**
2
- * Synthetic reasoning-turn builder for `friend.request` notify.signal events.
3
- *
4
- * When the server fires a `friend.request` signal the runtime dispatches one
5
- * deduped synthetic `message.send` envelope so the agent can reason about the
6
- * pending request. The prompt text varies by the current "friend.add" permission
7
- * policy state: a deny policy yields a decline/inform prompt; ask or allow
8
- * yields a prompt that instructs the agent to review the request via the
9
- * `clawchat_list_friend_requests` tool.
10
- */
11
- import { EVENT } from "./protocol-types.js";
12
- /**
13
- * Returns canned prompt text for a friend-request reasoning turn based on the
14
- * current "friend.add" permission policy state.
15
- *
16
- * - `deny` → inform the agent it should decline; no accept instruction.
17
- * - `ask` / `allow` → instruct the agent to review the pending request.
18
- *
19
- * The requester's user id is embedded so the agent does not go looking up the
20
- * synthetic sender ("clawchat-friend-request" is not a real user).
21
- */
22
- export function friendRequestPromptFor(state, requesterUserId) {
23
- const arrived = requesterUserId
24
- ? `A new friend request has arrived from user ${requesterUserId}.`
25
- : "A new friend request has arrived.";
26
- if (state === "deny") {
27
- return [
28
- arrived,
29
- "Your current friend-add policy is set to deny.",
30
- "Do not add this contact.",
31
- "You may inform the requester that you cannot add them at this time.",
32
- ].join(" ");
33
- }
34
- return [
35
- arrived,
36
- "Please review the pending request by calling `clawchat_list_friend_requests`",
37
- "and decide whether to accept it.",
38
- ].join(" ");
39
- }
40
- /**
41
- * Builds a synthetic `message.send` envelope that triggers one agent reasoning
42
- * turn for a pending friend request. The envelope shape mirrors the
43
- * activation-bootstrap envelope; it targets the owner's direct conversation so
44
- * the agent has context about who it is reasoning for.
45
- */
46
- export function buildFriendRequestEnvelope(params) {
47
- const { account, state, entityId, ownerConversationId } = params;
48
- const text = friendRequestPromptFor(state, entityId);
49
- const now = Date.now();
50
- // Fall back to ownerUserId only when no activation conversation is recorded:
51
- // the turn still runs (the agent can act via tools, e.g. accept the request),
52
- // but its in-chat replies will not be deliverable until activation records
53
- // the owner conversation.
54
- return {
55
- version: "2",
56
- event: EVENT.MESSAGE_SEND,
57
- trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
58
- emitted_at: now,
59
- chat_id: ownerConversationId ?? account.ownerUserId,
60
- chat_type: "direct",
61
- to: { id: account.userId, type: "direct" },
62
- sender: {
63
- id: "clawchat-friend-request",
64
- type: "direct",
65
- nick_name: "ClawChat",
66
- },
67
- payload: {
68
- message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
69
- message_mode: "normal",
70
- message: {
71
- body: { fragments: [{ kind: "text", text }] },
72
- context: { mentions: [], reply: null },
73
- streaming: {
74
- status: "static",
75
- sequence: 0,
76
- mutation_policy: "sealed",
77
- started_at: null,
78
- completed_at: null,
79
- },
80
- },
81
- },
82
- };
83
- }