@clawling/clawchat-plugin-openclaw 2026.10.25-1 → 2026.10.31-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.
@@ -117,6 +117,105 @@ export async function authRefresh(opts, params) {
117
117
  clearTimeout(timer);
118
118
  }
119
119
  }
120
+ // A URL starts at an anchored, length-capped scheme. Its authority runs to the
121
+ // first whitespace or `/?#`, and userinfo is everything before the authority's
122
+ // last `@` (fetch accepts quotes, `<`, `>`, backticks and extra `@` there), with
123
+ // no length cap so long credentials fail closed. The host after that `@` ends at
124
+ // quotes and angle brackets as well, and only `scheme://userinfo@host[:port]` is
125
+ // rewritten; scanning continues right after it (or after the scheme when there
126
+ // is no userinfo), so adjacent and embedded URLs are still checked. Redaction is
127
+ // conservative: text glued to a credentialed URL up to a later `@` may be
128
+ // dropped with it. Every search is bounded by one authority, so server-supplied
129
+ // text is processed in linear time.
130
+ const URL_SCHEME = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\//gi;
131
+ const AUTHORITY_END = /[\s/?#]/g;
132
+ const HOST_END = /[\s"'<>`]/;
133
+ function stripUserinfoFromAuthority(scheme, authority) {
134
+ try {
135
+ const url = new URL(`${scheme}${authority}`);
136
+ return url.host ? `${scheme}${url.host}` : "[redacted-url]";
137
+ }
138
+ catch {
139
+ return "[redacted-url]";
140
+ }
141
+ }
142
+ function stripUrlUserinfo(text) {
143
+ let out = "";
144
+ let copied = 0;
145
+ for (const scheme of text.matchAll(URL_SCHEME)) {
146
+ const start = scheme.index ?? 0;
147
+ if (start < copied)
148
+ continue;
149
+ const authorityStart = start + scheme[0].length;
150
+ AUTHORITY_END.lastIndex = authorityStart;
151
+ const authorityEnd = AUTHORITY_END.exec(text)?.index ?? text.length;
152
+ const authority = text.slice(authorityStart, authorityEnd);
153
+ const at = authority.lastIndexOf("@");
154
+ if (at < 0)
155
+ continue;
156
+ const hostLength = authority.slice(at + 1).search(HOST_END);
157
+ const hostEnd = hostLength < 0 ? authorityEnd : authorityStart + at + 1 + hostLength;
158
+ out += text.slice(copied, start) + stripUserinfoFromAuthority(scheme[0], text.slice(authorityStart, hostEnd));
159
+ copied = hostEnd;
160
+ }
161
+ return out + text.slice(copied);
162
+ }
163
+ /** Replaces refresh diagnostic text when the configured base URL carries
164
+ * credentials: their echoes take too many forms (spaces, no scheme, escaping,
165
+ * percent-encoding, truncation) to remove reliably, so the free text is not
166
+ * shown at all. Callers keep the HTTP status and error kind around it. */
167
+ export const REFRESH_DIAGNOSTIC_WITHHELD = "(diagnostic text withheld: base URL contains credentials)";
168
+ /** Replaces refresh diagnostic text when redaction itself fails (fail closed). */
169
+ export const REFRESH_DIAGNOSTIC_FAILED = "(diagnostic text withheld: redaction failed)";
170
+ /** True when a URL carries userinfo: the parser reports a username or
171
+ * password, or its authority (up to the first `/?#\`) contains `@`. An
172
+ * unparseable URL containing `@` anywhere counts too (fail closed). */
173
+ export function urlHasUserinfo(url) {
174
+ if (!url)
175
+ return false;
176
+ try {
177
+ const parsed = new URL(url);
178
+ if (parsed.username || parsed.password)
179
+ return true;
180
+ }
181
+ catch {
182
+ if (url.includes("@"))
183
+ return true;
184
+ }
185
+ const schemeEnd = url.indexOf("://");
186
+ const rest = schemeEnd < 0 ? url : url.slice(schemeEnd + 3);
187
+ const authorityLength = rest.search(/[/?#\\]/);
188
+ return (authorityLength < 0 ? rest : rest.slice(0, authorityLength)).includes("@");
189
+ }
190
+ /** Refresh diagnostics use the media redactor's credential patterns, plus exact
191
+ * in-scope tokens of 8+ characters (shorter values would mangle ordinary words;
192
+ * real tokens are far longer). Keep this at the first conversion to display text.
193
+ * URL userinfo is also removed before diagnostics reach display text. When the
194
+ * configured base URL carries credentials the whole text is replaced with
195
+ * REFRESH_DIAGNOSTIC_WITHHELD; any failure returns REFRESH_DIAGNOSTIC_FAILED.
196
+ * This never throws and never falls back to the original text. */
197
+ export function redactRefreshDiagnostics(text, tokens = [], baseUrl) {
198
+ try {
199
+ if (urlHasUserinfo(baseUrl))
200
+ return REFRESH_DIAGNOSTIC_WITHHELD;
201
+ return redactRefreshText(String(text), tokens);
202
+ }
203
+ catch {
204
+ return REFRESH_DIAGNOSTIC_FAILED;
205
+ }
206
+ }
207
+ function redactRefreshText(text, tokens) {
208
+ for (const token of tokens) {
209
+ const value = token?.trim();
210
+ if (value && value.length >= 8)
211
+ text = text.split(value).join("[redacted]");
212
+ }
213
+ // JSON body snippets may contain an escaped diagnostic string.
214
+ return stripUrlUserinfo(text.replace(/\\"/g, '"'))
215
+ .replace(/\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [redacted]")
216
+ .replace(/("?\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, '$1"[redacted]"')
217
+ .replace(/\beyJ[A-Za-z0-9._~+/=-]{10,}/g, "[redacted]");
218
+ }
120
219
  async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
121
220
  let res;
122
221
  try {
@@ -136,7 +235,7 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
136
235
  // Network error / timeout / DNS — TRANSIENT (no rotation committed).
137
236
  return {
138
237
  kind: "transient",
139
- message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
238
+ message: `refresh fetch failed: ${redactRefreshDiagnostics(err instanceof Error ? err.message : String(err), [params.refreshToken, params.accessToken], baseUrl)}`,
140
239
  };
141
240
  }
142
241
  if (res.status !== 200) {
@@ -150,7 +249,12 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
150
249
  const message = (typeof parsed.msg === "string" && parsed.msg) ||
151
250
  (typeof parsed.message === "string" && parsed.message) ||
152
251
  `refresh http ${res.status} code=10003`;
153
- return { kind: "permanent", code: CODE_INVALID_REFRESH, message };
252
+ const detail = redactRefreshDiagnostics(message, [params.refreshToken, params.accessToken], baseUrl);
253
+ return {
254
+ kind: "permanent",
255
+ code: CODE_INVALID_REFRESH,
256
+ message: detail === REFRESH_DIAGNOSTIC_WITHHELD ? `refresh http ${res.status} code=10003 ${detail}` : detail,
257
+ };
154
258
  }
155
259
  }
156
260
  catch {
@@ -159,7 +263,7 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
159
263
  return {
160
264
  kind: "transient",
161
265
  status: res.status,
162
- message: `refresh http ${res.status} ${text.slice(0, 200)}`,
266
+ message: `refresh http ${res.status} ${redactRefreshDiagnostics(text, [params.refreshToken, params.accessToken], baseUrl).slice(0, 200)}`,
163
267
  };
164
268
  }
165
269
  const text = await res.text().catch(() => "");
@@ -171,9 +275,10 @@ async function authRefreshAttempt(baseUrl, fetchImpl, params, signal) {
171
275
  parsed = undefined;
172
276
  }
173
277
  const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
174
- const message = (typeof parsed?.msg === "string" && parsed.msg) ||
278
+ const detail = redactRefreshDiagnostics((typeof parsed?.msg === "string" && parsed.msg) ||
175
279
  (typeof parsed?.message === "string" && parsed.message) ||
176
- `code=${code}`;
280
+ `code=${code}`, [params.refreshToken, params.accessToken], baseUrl);
281
+ const message = detail === REFRESH_DIAGNOSTIC_WITHHELD ? `code=${code} ${detail}` : detail;
177
282
  if (!Number.isFinite(code)) {
178
283
  // 200 with no usable envelope — treat as transient (do not auto-logout).
179
284
  return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
@@ -365,6 +470,17 @@ export function createOpenclawClawlingApiClient(opts) {
365
470
  }
366
471
  return obj;
367
472
  }
473
+ function parseAvatarUploadResult(data, path) {
474
+ const obj = data;
475
+ if (!obj ||
476
+ typeof obj.url !== "string" ||
477
+ obj.url === "" ||
478
+ typeof obj.mime !== "string" ||
479
+ typeof obj.size !== "number") {
480
+ throw new ClawlingApiError("api", "invalid upload response: missing required avatar fields", { path });
481
+ }
482
+ return { url: obj.url, size: obj.size, mime: obj.mime };
483
+ }
368
484
  function isLivewareView(v) {
369
485
  if (!v || typeof v !== "object" || Array.isArray(v))
370
486
  return false;
@@ -718,7 +834,8 @@ export function createOpenclawClawlingApiClient(opts) {
718
834
  });
719
835
  const fd = new FormData();
720
836
  fd.set("file", file);
721
- return await call("POST", "/v1/files/upload-url", { body: fd });
837
+ const data = await call("POST", "/v1/files/upload-url", { body: fd });
838
+ return parseAvatarUploadResult(data, "/v1/files/upload-url");
722
839
  },
723
840
  async reportPlugin(input, opts) {
724
841
  const path = opts?.authenticated
@@ -15,8 +15,8 @@ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
15
15
  import { resolveOwnerIdentity } from "./owner-identity.js";
16
16
  import { getRegisteredClawChatToolNames } from "./tools.js";
17
17
  import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
18
- import { bindOrchestrationOriginCall, bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
19
- import { GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
18
+ import { bindOrchestrationOriginCall, bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, isNonOwnerBoundRun, hasOwnerDowngradingOverlap, ownerDowngradeTrigger, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
19
+ import { OWNER_DOWNGRADED_MESSAGE, GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
20
20
  import { AUTHORIZATION_TOOLS, OWNER_ONLY_TOOLS, OWNER_TURN_STRICT_TOOLS, WIDGET_OWNER_ONLY_MESSAGE, TOOL_CAPABILITIES, canonicalToolName, hostToolTargetsCurrentSession, isCrossChatCall, resolveCrossChatGate } from "./tool-capabilities.js";
21
21
  import { recordTurnArtifactCall, registerTurnArtifacts } from "./turn-artifacts.js";
22
22
  export const BLOCK_MESSAGE = "This tool is not available here: only your owner's direct chat may use host tools on this machine. Do not try to reach them another way; answer without them, or tell the person to ask your owner.";
@@ -59,8 +59,22 @@ export function hasLocalPathArgument(value, field = "") {
59
59
  return value.some(item => hasLocalPathArgument(item, field));
60
60
  return Boolean(value && typeof value === "object" && Object.entries(value).some(([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key)));
61
61
  }
62
+ const ownerDowngradedDecision = () => ({ block: true, blockReason: OWNER_DOWNGRADED_MESSAGE, reasonCode: "owner-downgraded" });
63
+ function ownerOverlapDowngraded(input, includeNotices = false) {
64
+ const parsed = parseClawChatSessionKey(input.sessionKey);
65
+ return Boolean(parsed && classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner"
66
+ && input.ownerUserId?.trim() && input.senderId === input.ownerUserId
67
+ && (includeNotices ? isNonOwnerSideSession(input.sessionKey)
68
+ : !isNonOwnerBoundRun(input.sessionKey, input.runId) && hasOwnerDowngradingOverlap(input.sessionKey)));
69
+ }
62
70
  /** Pure decision for one tool call. `undefined` lets the call run. */
63
71
  export function decideHostToolCall(input) {
72
+ const decision = decideHostToolCallInner(input);
73
+ return decision && "block" in decision && (ownerOverlapDowngraded(input)
74
+ || (OWNER_ONLY_TOOLS.includes(canonicalToolName(input.toolName)) && ownerOverlapDowngraded(input, true)))
75
+ ? ownerDowngradedDecision() : decision;
76
+ }
77
+ function decideHostToolCallInner(input) {
64
78
  if (input.toolName === "clawchat_tool_request") {
65
79
  const origin = input.sessionKey ? requestOriginForRun(input.sessionKey, input.runId) : undefined;
66
80
  const parsed = parseClawChatSessionKey(input.sessionKey);
@@ -102,7 +116,12 @@ export function decideHostToolCall(input) {
102
116
  // cannot start local turns in other sessions (cross-session). Under that
103
117
  // audited premise, only the owner can initiate a snapshot-free turn here.
104
118
  // A new host tool missing from the audit could break it; keep it current.
105
- if ((ownerTurn && !isNonOwnerSideSession(input.sessionKey))
119
+ // Plugin notices in flight (no third-party text) keep that access only for
120
+ // a run the host attributes to the owner; the notice's own run never gets it.
121
+ const ownerExempt = ownerTurn && !isNonOwnerBoundRun(input.sessionKey, input.runId)
122
+ && (!isNonOwnerSideSession(input.sessionKey) || (!hasOwnerDowngradingOverlap(input.sessionKey)
123
+ && Boolean(input.ownerUserId?.trim()) && input.senderId === input.ownerUserId));
124
+ if (ownerExempt
106
125
  || (parsed.kind === "group" && isOwnerSideRun(input.sessionKey, input.runId)))
107
126
  return undefined;
108
127
  const publicTools = nonOwnerToolAllowlist(input.publicTools);
@@ -322,6 +341,25 @@ export function registerHostToolsGuard(api, options = {}) {
322
341
  return { ready: false, reason: "not-applied" };
323
342
  }
324
343
  };
344
+ const loggedOwnerDowngrades = new Set();
345
+ // A downgraded owner run has no request origin; tell it the truth instead of the
346
+ // generic refusal. Every other origin-less call keeps the plain block.
347
+ const ownerRequestDowngraded = (sessionKey, runId, requester) => {
348
+ try {
349
+ const accountId = clawChatSessionAccountId(sessionKey);
350
+ if (!accountId || !requester?.senderId || (requester.channel && requester.channel !== CHANNEL_ID))
351
+ return false;
352
+ const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
353
+ const ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
354
+ : resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
355
+ return ownerOverlapDowngraded({ toolName: "clawchat_tool_request", params: {}, sessionKey, runId,
356
+ senderId: requester.senderId, ownerUserId: ownerUserId ?? undefined,
357
+ ownerDirectConversationId: lookupOwnerConversation(accountId), registeredTools: new Set() });
358
+ }
359
+ catch {
360
+ return false;
361
+ }
362
+ };
325
363
  api.on("before_tool_call", (event, sdkCtx) => {
326
364
  // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
327
365
  const ctx = sdkCtx;
@@ -339,6 +377,8 @@ export function registerHostToolsGuard(api, options = {}) {
339
377
  }
340
378
  if (event.toolName === "clawchat_tool_request") {
341
379
  const origin = sessionKey ? requestOriginForRun(sessionKey, runId) : undefined;
380
+ if (!origin && sessionKey && ownerRequestDowngraded(sessionKey, runId, ctx.requester))
381
+ return ownerDowngradedDecision();
342
382
  if (!origin || !sessionKey || !ctx.requester?.senderId
343
383
  || (ctx.requester.channel && ctx.requester.channel !== CHANNEL_ID)
344
384
  || (ctx.requester.accountId && normalizeOpenclawClawlingAccountId(ctx.requester.accountId) !== origin.accountId)
@@ -445,14 +485,12 @@ export function registerHostToolsGuard(api, options = {}) {
445
485
  const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
446
486
  nonOwnerToolAllowlist(account.publicTools, api.logger);
447
487
  let ownerUserId = null;
448
- if (AUTHORIZATION_TOOLS.includes(event.toolName) || OWNER_ONLY_TOOLS.includes(canonicalToolName(event.toolName))) {
449
- try {
450
- ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
451
- : resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
452
- }
453
- catch { /* A missing owner identity cannot authorize grant tools. */ }
488
+ try {
489
+ ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
490
+ : resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
454
491
  }
455
- return decideHostToolCall({
492
+ catch { /* Missing identity cannot authorize management or identify an owner downgrade. */ }
493
+ const input = {
456
494
  toolName: event.toolName,
457
495
  params: event.params ?? {},
458
496
  sessionKey,
@@ -467,7 +505,8 @@ export function registerHostToolsGuard(api, options = {}) {
467
505
  ownerUserId: ownerUserId ?? undefined,
468
506
  senderId: requester?.senderId,
469
507
  approvalRoute: approvalRoute(accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
470
- });
508
+ };
509
+ return decideHostToolCall(input);
471
510
  }
472
511
  catch {
473
512
  warn("handler-exception");
@@ -475,8 +514,18 @@ export function registerHostToolsGuard(api, options = {}) {
475
514
  }
476
515
  })();
477
516
  const result = decision && "block" in decision && OWNER_ONLY_TOOLS.includes(canonicalToolName(event.toolName))
478
- && decision.reasonCode !== "owner-turn-only"
517
+ && decision.reasonCode !== "owner-turn-only" && decision.reasonCode !== "owner-downgraded"
479
518
  ? { block: true, reasonCode: "owner-turn-only", blockReason: OWNER_TURN_STRICT_TOOLS.includes(canonicalToolName(event.toolName)) ? WIDGET_OWNER_ONLY_MESSAGE : ownerOnlyMessage(false) } : decision;
519
+ if (result && "block" in result && result.reasonCode === "owner-downgraded" && ctx?.sessionKey) {
520
+ const runId = ctx.runId ?? event.runId;
521
+ const key = JSON.stringify([ctx.sessionKey.trim().toLowerCase(), runId]);
522
+ if (!loggedOwnerDowngrades.has(key)) {
523
+ if (loggedOwnerDowngrades.size >= 256)
524
+ loggedOwnerDowngrades.clear();
525
+ loggedOwnerDowngrades.add(key);
526
+ api.logger.info(`owner_downgrade reason=overlap trigger=${ownerDowngradeTrigger(ctx.sessionKey)}`);
527
+ }
528
+ }
480
529
  recordTurnArtifactCall(event, ctx, result === undefined || "requireApproval" in result);
481
530
  return result;
482
531
  });
@@ -1,9 +1,22 @@
1
1
  import fs from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import path from "node:path";
3
4
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
4
5
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
5
6
  import { normalizeOpenclawClawlingAccountId } from "./config.js";
6
7
  export const LIVEWARE_BASE_URL = "https://media.clawling.chat/liveware/";
8
+ // The trust anchor is this package, not the download host. Updating the CLI
9
+ // requires updating these hashes in a plugin release.
10
+ export const LIVEWARE_CLI_PIN = {
11
+ build: "2026-09-21",
12
+ sha256: {
13
+ "liveware-darwin-amd64": "28c63d371115889cd8ebeed916af0d5ede62e10016ae34a8984de31ec49d00fe",
14
+ "liveware-darwin-arm64": "eebc9f9a881f9a4da3eb81559290581f0054d15b94eb8c0d2dd21e9472169765",
15
+ "liveware-linux-amd64": "bbbf7c0b74e5633cf869ea3af78997d5ec0f57f3253f1fb3c3cdd75ead5cebba",
16
+ "liveware-linux-arm64": "691b79a8f346e10780e8270d9b759bd749d1df5a3d08bf033c66765c470d6a15",
17
+ "liveware-windows-amd64.exe": "b5fe6fa02cf23d0101af20cc3f8c936ac5d6f32a13896bc1c14622089e3504a1",
18
+ },
19
+ };
7
20
  // A non-default User-Agent is required: the CDN (Cloudflare) returns 403 for
8
21
  // known scripting/default UAs, so download requests must identify themselves.
9
22
  const LIVEWARE_USER_AGENT = "clawchat-liveware-installer";
@@ -92,7 +105,7 @@ export function platformAssets(plat = process.platform, arch = process.arch) {
92
105
  const cpu = arch === "x64" ? "amd64" : arch === "arm64" ? "arm64" : null;
93
106
  if (!os || !cpu)
94
107
  return null;
95
- // Windows only ships an amd64 build, published with a .exe suffix.
108
+ // Only the Windows amd64 build is supported here, with a .exe suffix.
96
109
  if (os === "windows") {
97
110
  if (cpu !== "amd64")
98
111
  return null;
@@ -116,59 +129,233 @@ function findOnPath(name) {
116
129
  }
117
130
  return null;
118
131
  }
119
- /**
120
- * Resolve the liveware executable to invoke:
121
- * 1. on PATH → "liveware" (let the OS resolve it)
122
- * 2. downloaded dir → absolute path
123
- * 3. otherwise → null
124
- */
132
+ const managedVerifications = new Map();
133
+ function fileIdentity(stat) {
134
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
135
+ }
136
+ /** PATH is user-managed; downloaded executables must pass the package pin.
137
+ * Successful hashes are reused only while the full stat identity is unchanged. */
125
138
  export function resolveLivewarePath(stateDir) {
126
139
  const name = livewareBinaryName();
127
140
  if (findOnPath(name))
128
141
  return name;
129
- const local = path.join(livewareDir(stateDir), name);
142
+ const assets = platformAssets();
143
+ const digest = assets && LIVEWARE_CLI_PIN.sha256[assets.liveware];
144
+ if (!digest)
145
+ return null;
146
+ const local = path.resolve(livewareDir(stateDir), name);
130
147
  try {
131
148
  fs.accessSync(local, fs.constants.X_OK);
149
+ const stat = fs.statSync(local);
150
+ if (!stat.isFile())
151
+ return null;
152
+ const cached = managedVerifications.get(local);
153
+ if (cached?.digest === digest && cached.identity === fileIdentity(stat))
154
+ return local;
155
+ managedVerifications.delete(local);
156
+ // Hash an open descriptor so the memo identifies the bytes actually read.
157
+ const file = fs.openSync(local, "r");
158
+ let identity;
159
+ let actualDigest;
160
+ try {
161
+ identity = fileIdentity(fs.fstatSync(file));
162
+ actualDigest = sha256(fs.readFileSync(file));
163
+ if (identity !== fileIdentity(fs.fstatSync(file)))
164
+ return null;
165
+ }
166
+ finally {
167
+ fs.closeSync(file);
168
+ }
169
+ if (actualDigest !== digest || identity !== fileIdentity(fs.statSync(local)))
170
+ return null;
171
+ managedVerifications.set(local, { identity, digest });
132
172
  return local;
133
173
  }
134
174
  catch {
175
+ managedVerifications.delete(local);
135
176
  return null;
136
177
  }
137
178
  }
138
- async function downloadTo(url, dest) {
139
- const res = await fetch(url, { headers: { "user-agent": LIVEWARE_USER_AGENT } });
140
- if (!res.ok)
141
- throw new Error(`download ${url} failed: HTTP ${res.status}`);
142
- const buf = Buffer.from(await res.arrayBuffer());
143
- if (buf.length === 0)
144
- throw new Error(`download ${url} returned empty body`);
145
- const tmp = `${dest}.tmp`;
146
- await fs.promises.writeFile(tmp, buf);
147
- await fs.promises.chmod(tmp, 0o755);
148
- await fs.promises.rename(tmp, dest);
179
+ class DownloadError extends Error {
180
+ retryable;
181
+ constructor(message, retryable) {
182
+ super(message);
183
+ this.retryable = retryable;
184
+ }
185
+ }
186
+ function sha256(bytes) {
187
+ return createHash("sha256").update(bytes).digest("hex");
188
+ }
189
+ // Processes can race safely: rename only publishes verified bytes, and failure
190
+ // cleanup re-verifies the file it actually quarantined rather than an old read.
191
+ const livewareInstalls = new Map();
192
+ async function syncDirectory(dir) {
193
+ try {
194
+ const file = await fs.promises.open(dir, "r");
195
+ try {
196
+ await file.sync();
197
+ }
198
+ finally {
199
+ await file.close();
200
+ }
201
+ }
202
+ catch { /* Best effort directory durability across platforms/filesystems. */ }
203
+ }
204
+ async function readManagedFile(target) {
205
+ let file;
206
+ try {
207
+ file = await fs.promises.open(target, "r");
208
+ }
209
+ catch (err) {
210
+ if (err.code === "ENOENT")
211
+ return null;
212
+ throw err;
213
+ }
214
+ try {
215
+ const before = fileIdentity(await file.stat());
216
+ const bytes = await file.readFile();
217
+ const after = fileIdentity(await file.stat());
218
+ return { identity: after, digest: before === after ? sha256(bytes) : null };
219
+ }
220
+ finally {
221
+ await file.close();
222
+ }
223
+ }
224
+ async function quarantineRejectedCache(target, digest) {
225
+ // Each attempt takes a fresh hash/identity, including after a failed download.
226
+ // A changed target is re-verified rather than moved using a stale decision.
227
+ for (let attempt = 0; attempt < 3; attempt++) {
228
+ const snapshot = await readManagedFile(target);
229
+ if (!snapshot || snapshot.digest === digest)
230
+ return;
231
+ if (snapshot.digest === null)
232
+ continue;
233
+ const rejected = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.rejected`);
234
+ try {
235
+ if (fileIdentity(await fs.promises.lstat(target)) !== snapshot.identity)
236
+ continue;
237
+ await fs.promises.rename(target, rejected);
238
+ }
239
+ catch (err) {
240
+ if (err.code === "ENOENT")
241
+ return;
242
+ throw err;
243
+ }
244
+ try {
245
+ // Recheck what moved: publication can still race the final lstat/rename.
246
+ if ((await readManagedFile(rejected))?.digest === digest) {
247
+ try {
248
+ await fs.promises.link(rejected, target);
249
+ }
250
+ catch (err) {
251
+ if (err.code !== "EEXIST")
252
+ throw err;
253
+ }
254
+ }
255
+ }
256
+ finally {
257
+ await fs.promises.unlink(rejected);
258
+ await syncDirectory(path.dirname(target));
259
+ }
260
+ return;
261
+ }
262
+ throw new Error("liveware CLI cache kept changing during quarantine; skipping removal");
263
+ }
264
+ async function downloadTo(url, dest, digest, fetchImpl, timeoutMs) {
265
+ const tmp = `${dest}.${randomUUID()}.tmp`;
266
+ try {
267
+ // The signal remains active during arrayBuffer(), covering the entire body.
268
+ let buf;
269
+ try {
270
+ const res = await fetchImpl(url, {
271
+ headers: { "user-agent": LIVEWARE_USER_AGENT }, signal: AbortSignal.timeout(timeoutMs),
272
+ });
273
+ if (!res.ok) {
274
+ await res.body?.cancel().catch(() => { });
275
+ throw new DownloadError(`liveware download HTTP ${res.status}`, res.status === 429 || (res.status >= 500 && res.status < 600));
276
+ }
277
+ buf = Buffer.from(await res.arrayBuffer());
278
+ }
279
+ catch (err) {
280
+ if (err instanceof DownloadError)
281
+ throw err;
282
+ throw new DownloadError(`liveware download request failed: ${String(err)}`, true);
283
+ }
284
+ if (sha256(buf) !== digest)
285
+ throw new DownloadError("liveware download SHA-256 mismatch", false);
286
+ const file = await fs.promises.open(tmp, "wx", 0o600);
287
+ try {
288
+ await file.writeFile(buf);
289
+ await file.chmod(0o755);
290
+ await file.sync();
291
+ }
292
+ finally {
293
+ await file.close();
294
+ }
295
+ await fs.promises.rename(tmp, dest);
296
+ await syncDirectory(path.dirname(dest));
297
+ }
298
+ finally {
299
+ await fs.promises.rm(tmp, { force: true });
300
+ }
301
+ }
302
+ async function installLivewareCli(target, asset, digest, opts) {
303
+ const dir = path.dirname(target);
304
+ const isVerified = async () => (await readManagedFile(target))?.digest === digest;
305
+ if (await isVerified())
306
+ return;
307
+ // Quarantine only the unchanged file we just hashed as invalid. Execution
308
+ // independently verifies the managed copy, even if cleanup is unavailable.
309
+ await quarantineRejectedCache(target, digest);
310
+ if (await isVerified())
311
+ return;
312
+ await fs.promises.mkdir(dir, { recursive: true });
313
+ const delay = opts.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
314
+ try {
315
+ for (let attempt = 0; attempt < 2; attempt++) {
316
+ try {
317
+ await downloadTo(LIVEWARE_BASE_URL + asset, target, digest, opts.fetchImpl ?? fetch, opts.timeoutMs ?? 120_000);
318
+ break;
319
+ }
320
+ catch (err) {
321
+ if (attempt === 1 || !(err instanceof DownloadError) || !err.retryable)
322
+ throw err;
323
+ await delay(2_000);
324
+ }
325
+ }
326
+ }
327
+ catch (err) {
328
+ await quarantineRejectedCache(target, digest);
329
+ throw err;
330
+ }
331
+ opts.log?.debug?.(`clawchat-plugin-openclaw: liveware CLI downloaded to ${dir}`);
149
332
  }
150
333
  /**
151
- * Startup hook: ensure the liveware CLI is available, downloading it when not.
152
- * Fire-and-forget — never throws; logs and returns on any failure.
334
+ * Startup verifies the managed cache; per-tool resolution memoizes verified file identities.
335
+ * User-managed PATH executables take precedence and are not hashed.
336
+ * Never throws: a failed install is logged and skipped.
153
337
  */
154
338
  export async function ensureLivewareCli(opts = {}) {
155
339
  const { stateDir, log } = opts;
156
340
  try {
157
- const name = livewareBinaryName();
341
+ const platform = opts.platform ?? process.platform;
342
+ const name = livewareBinaryName(platform);
158
343
  if (findOnPath(name))
159
- return; // PATH wins
160
- const dir = livewareDir(stateDir);
161
- const target = path.join(dir, name);
162
- if (fs.existsSync(target))
163
344
  return;
164
- const assets = platformAssets();
165
- if (!assets) {
166
- log?.warn?.(`clawchat-plugin-openclaw: unsupported platform ${process.platform}/${process.arch}; skipping liveware download`);
345
+ const target = path.resolve(livewareDir(stateDir), name);
346
+ const assets = platformAssets(platform, opts.arch ?? process.arch);
347
+ const digest = assets && LIVEWARE_CLI_PIN.sha256[assets.liveware];
348
+ if (!assets || !digest) {
349
+ log?.warn?.(`clawchat-plugin-openclaw: unsupported or unpinned platform ${platform}/${opts.arch ?? process.arch}; skipping liveware download`);
167
350
  return;
168
351
  }
169
- await fs.promises.mkdir(dir, { recursive: true });
170
- await downloadTo(LIVEWARE_BASE_URL + assets.liveware, path.join(dir, name));
171
- log?.debug?.(`clawchat-plugin-openclaw: liveware CLI downloaded to ${dir}`);
352
+ let install = livewareInstalls.get(target);
353
+ if (!install) {
354
+ install = installLivewareCli(target, assets.liveware, digest, opts)
355
+ .finally(() => { livewareInstalls.delete(target); });
356
+ livewareInstalls.set(target, install);
357
+ }
358
+ await install;
172
359
  }
173
360
  catch (err) {
174
361
  log?.warn?.(`clawchat-plugin-openclaw: liveware CLI download skipped: ${String(err)}`);
@@ -690,7 +690,7 @@ export function loadIntroText(appDir, language) {
690
690
  const DEFAULT_SAMPLE_PORT = 43110;
691
691
  const RESTART_WINDOW_MS = 30 * 60 * 1000;
692
692
  const MAX_RESTARTS_PER_WINDOW = 5;
693
- const INTRO_RETRY_DELAY_MS = 30_000;
693
+ const INTRO_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000];
694
694
  const INTRO_MAX_TRIES = 20;
695
695
  // Backoff for transient start() failures (template CDN 429s, network blips):
696
696
  // a one-shot bootstrap would otherwise stay dormant until the next restart.
@@ -1130,8 +1130,8 @@ export class LivewareSampleSupervisor {
1130
1130
  }
1131
1131
  }
1132
1132
  /** Deliver the intro chat message; retries while the WS client is not ready. */
1133
- async deliverIntro(tryIndex = 0) {
1134
- if (this.stopped)
1133
+ async deliverIntro(tryIndex = 0, generation = this.generation) {
1134
+ if (this.stopped || generation !== this.generation)
1135
1135
  return;
1136
1136
  const { deps } = this;
1137
1137
  let delivered = false;
@@ -1142,6 +1142,8 @@ export class LivewareSampleSupervisor {
1142
1142
  catch (err) {
1143
1143
  deps.log?.debug?.(`liveware-sample: intro send error: ${String(err)}`);
1144
1144
  }
1145
+ if (this.stopped || generation !== this.generation)
1146
+ return;
1145
1147
  if (delivered) {
1146
1148
  deps.store.markLivewareSampleIntroSent({ platform: deps.platform, accountId: deps.accountId });
1147
1149
  return;
@@ -1150,6 +1152,6 @@ export class LivewareSampleSupervisor {
1150
1152
  deps.log?.debug?.("liveware-sample: intro not delivered; will retry next boot");
1151
1153
  return;
1152
1154
  }
1153
- this.schedule(() => { void this.deliverIntro(tryIndex + 1); }, INTRO_RETRY_DELAY_MS);
1155
+ this.schedule(() => { void this.deliverIntro(tryIndex + 1, generation); }, INTRO_RETRY_DELAYS_MS[Math.min(tryIndex, INTRO_RETRY_DELAYS_MS.length - 1)]);
1154
1156
  }
1155
1157
  }