@clawling/clawchat-plugin-openclaw 2026.7.29-1 → 2026.8.2-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.
@@ -88,28 +88,76 @@ async function fetchText(fetchFn: FetchLike, url: string): Promise<string> {
88
88
  return text;
89
89
  }
90
90
 
91
+ /**
92
+ * True when `appDir` already holds what the manifest pins: every listed file
93
+ * present, program files byte-identical to their manifest sha256, and nothing
94
+ * beyond those files (so a file dropped by a newer manifest still forces a clean
95
+ * reinstall). USER_DATA_FILES are checked for presence only, never for content —
96
+ * `state.json` ships in the manifest *and* is owned by the agent afterwards, so
97
+ * hashing it would make the short-circuit miss on exactly the installs that are
98
+ * actually in use. Cheap (~20 KB of local reads) next to the per-file GETs it
99
+ * saves, and fail-safe: any read error answers "no".
100
+ */
101
+ async function localInstallMatches(
102
+ appDir: string,
103
+ entry: LivewareSampleManifestEntry,
104
+ ): Promise<boolean> {
105
+ try {
106
+ const expected = new Set(entry.files.map((f) => path.basename(f.path)));
107
+ for (const name of await fs.promises.readdir(appDir)) {
108
+ if (!expected.has(name) && !USER_DATA_FILES.includes(name)) return false;
109
+ }
110
+ for (const file of entry.files) {
111
+ const name = path.basename(file.path);
112
+ const target = path.join(appDir, name);
113
+ if (USER_DATA_FILES.includes(name)) {
114
+ await fs.promises.access(target); // agent-owned: presence is the whole check
115
+ continue;
116
+ }
117
+ const buf = await fs.promises.readFile(target);
118
+ if (crypto.createHash("sha256").update(buf).digest("hex") !== file.sha256) return false;
119
+ }
120
+ return true;
121
+ } catch {
122
+ return false; // missing / unreadable — treat as "not installed"
123
+ }
124
+ }
125
+
91
126
  /**
92
127
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
93
128
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
94
129
  * never leaves a partially-written `app/` dir behind.
130
+ *
131
+ * Re-install short-circuit: when the installed bytes already match every hash
132
+ * the manifest pins, nothing is fetched or rewritten (`reused: true`). Every
133
+ * boot used to re-fetch the whole bundle from GitHub raw even when the identical
134
+ * files were already on disk — a handful of serial round-trips on the critical
135
+ * path, and an unreachable raw.githubusercontent then failed a sample that was
136
+ * already fully installed. Only the manifest itself is still fetched every time,
137
+ * because that is what tells us which hashes to compare against.
95
138
  */
96
139
  export async function downloadLivewareSample(opts: {
97
140
  fetchFn: FetchLike;
98
141
  sampleRoot: string;
99
142
  ref?: string;
100
143
  base?: string;
101
- }): Promise<{ version: string; appDir: string }> {
144
+ }): Promise<{ version: string; appDir: string; reused?: boolean }> {
102
145
  const ref = opts.ref ?? DEFAULT_SKILLS_REF;
103
146
  const entry = parseLivewaresManifest(
104
147
  await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)),
105
148
  );
106
149
 
107
150
  const appDir = path.join(opts.sampleRoot, "app");
151
+ if (await localInstallMatches(appDir, entry)) {
152
+ return { version: entry.version, appDir, reused: true };
153
+ }
108
154
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
109
155
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
110
156
  await fs.promises.mkdir(tmpDir, { recursive: true });
111
157
  try {
112
- for (const file of entry.files) {
158
+ // Fetched concurrently: the files are independent and each is a separate
159
+ // round-trip to the same host, so serializing them only added latency.
160
+ await Promise.all(entry.files.map(async (file) => {
113
161
  const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
114
162
  const buf = Buffer.from(text, "utf8");
115
163
  const actual = crypto.createHash("sha256").update(buf).digest("hex");
@@ -117,7 +165,7 @@ export async function downloadLivewareSample(opts: {
117
165
  throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
118
166
  }
119
167
  await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
120
- }
168
+ }));
121
169
  // Preserve agent/user-owned data files from a previous install.
122
170
  for (const name of USER_DATA_FILES) {
123
171
  const prev = path.join(appDir, name);
@@ -351,6 +399,96 @@ function findIdDeep(value: unknown): string | null {
351
399
  return null;
352
400
  }
353
401
 
402
+ /**
403
+ * Find the id of an existing liveware app named `name` in `liveware app list`
404
+ * output. Deliberately conservative: a shape this cannot read must degrade to
405
+ * "not found" — the caller then creates an app, i.e. the previous behaviour —
406
+ * and must never return a WRONG id, so the text branch only accepts a line that
407
+ * contains the exact app name plus one id-looking token outside it, and an
408
+ * id-looking token must carry a digit/`-`/`_` so a status word ("active",
409
+ * "running") cannot pass for an id. JSON output (any wrapper key) is the
410
+ * reliable path; the aligned-table branch is calibrated on `app create`'s table,
411
+ * the only text shape verified against the real CLI.
412
+ */
413
+ export function findAppIdByName(stdout: string, name: string): string | null {
414
+ const text = (stdout ?? "").trim();
415
+ const wanted = (name ?? "").trim();
416
+ if (!text || !wanted) return null;
417
+ try {
418
+ const found = findNamedIdDeep(JSON.parse(text), wanted);
419
+ if (found) return found;
420
+ } catch { /* not JSON */ }
421
+ for (const line of text.split("\n")) {
422
+ const at = line.indexOf(wanted);
423
+ if (at < 0) continue;
424
+ const outsideName = `${line.slice(0, at)} ${line.slice(at + wanted.length)}`;
425
+ for (const token of outsideName.trim().split(/\s+/)) {
426
+ if (isAppIdToken(token)) return token;
427
+ }
428
+ }
429
+ return null;
430
+ }
431
+
432
+ function isAppIdToken(token: string): boolean {
433
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(token) && /[0-9_-]/.test(token);
434
+ }
435
+
436
+ function findNamedIdDeep(value: unknown, name: string): string | null {
437
+ if (Array.isArray(value)) {
438
+ for (const item of value) {
439
+ const found = findNamedIdDeep(item, name);
440
+ if (found) return found;
441
+ }
442
+ return null;
443
+ }
444
+ if (!value || typeof value !== "object") return null;
445
+ const obj = value as Record<string, unknown>;
446
+ const named = ["name", "app_name", "appName", "title"].some(
447
+ (key) => typeof obj[key] === "string" && (obj[key] as string).trim() === name,
448
+ );
449
+ if (named) {
450
+ const id = findIdDeep(obj);
451
+ if (id) return id;
452
+ }
453
+ for (const nested of Object.values(obj)) {
454
+ const found = findNamedIdDeep(nested, name);
455
+ if (found) return found;
456
+ }
457
+ return null;
458
+ }
459
+
460
+ /**
461
+ * `liveware app list` → the id of an existing app with this name, or null.
462
+ * Best-effort by contract: an exec failure or unreadable output resolves to null
463
+ * so the caller falls back to `app create` instead of failing the whole flow.
464
+ */
465
+ export async function livewareAppFindByName(opts: {
466
+ livewarePath: string;
467
+ name: string;
468
+ execFileFn?: ExecFileLike;
469
+ log?: LivewareLogger;
470
+ }): Promise<string | null> {
471
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
472
+ const stdout = await new Promise<string | null>((resolve) => {
473
+ execFileFn(
474
+ opts.livewarePath,
475
+ ["app", "list"],
476
+ { timeout: CLI_TIMEOUT_MS },
477
+ (err, out, errOut) => {
478
+ if (err) {
479
+ opts.log?.debug?.(
480
+ `liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`,
481
+ );
482
+ resolve(null);
483
+ return;
484
+ }
485
+ resolve(String(out ?? ""));
486
+ },
487
+ );
488
+ });
489
+ return stdout == null ? null : findAppIdByName(stdout, opts.name);
490
+ }
491
+
354
492
  export async function livewareAppCreate(opts: {
355
493
  livewarePath: string;
356
494
  name: string;
@@ -379,7 +517,7 @@ export type LivewareSampleStoreLike = {
379
517
  updateLivewareSampleStatus(input: {
380
518
  platform: string;
381
519
  accountId: string;
382
- status: "active" | "failed" | "disabled";
520
+ status: "pending" | "active" | "failed" | "disabled";
383
521
  lastError?: string | null;
384
522
  }): void;
385
523
  markLivewareSampleIntroSent(input: { platform: string; accountId: string }): void;
@@ -428,10 +566,15 @@ const INTRO_MAX_TRIES = 20;
428
566
  const START_RETRY_DELAYS_MS = [30_000, 60_000, 120_000, 300_000];
429
567
 
430
568
  export class LivewareSampleSupervisor {
431
- private readonly deps: LivewareSampleSupervisorDeps;
569
+ /** Not readonly: adoptDeps() re-points a live supervisor at a re-entered
570
+ * gateway closure (fresh token / REST client / owner-notify path). */
571
+ private deps: LivewareSampleSupervisorDeps;
432
572
  private serverChild: ChildProcess | null = null;
433
573
  private tunnelChild: ChildProcess | null = null;
434
574
  private stopped = false;
575
+ /** True while a startAttempt chain (bootstrap/relaunch) is running; read by
576
+ * isIdle() so adoptDeps never starts a second concurrent flow. */
577
+ private launchInFlight = false;
435
578
  private restartTimes: number[] = [];
436
579
  private timers = new Set<ReturnType<typeof setTimeout>>();
437
580
  /** Bumped on every (re)launch and on killChildren: a child's exit handler
@@ -449,8 +592,40 @@ export class LivewareSampleSupervisor {
449
592
  await this.startAttempt(0);
450
593
  }
451
594
 
595
+ /** Re-point a live supervisor at a freshly re-entered gateway closure, and —
596
+ * only when nothing is in flight — kick a new attempt.
597
+ *
598
+ * `startOpenclawClawlingGateway` re-enters itself on token refresh / transport
599
+ * backoff, so runtime.ts keeps ONE supervisor per (abortSignal, account) and
600
+ * hands the new closure here instead of constructing a second supervisor: two
601
+ * supervisors raced the bootstrap "account has zero apps" gate and registered
602
+ * two liveware apps for the same account, one of which no row then tracked.
603
+ * The re-entered closure owns the fresh token, REST client and owner-notify
604
+ * path; children, timers and the crash-restart budget keep running untouched.
605
+ * A flow already mid-await finishes on the deps it captured. */
606
+ adoptDeps(deps: LivewareSampleSupervisorDeps): void {
607
+ this.deps = deps;
608
+ // A re-entry used to re-run the whole flow through the brand-new supervisor,
609
+ // which is what healed an attempt that had skipped (no token yet) or burnt
610
+ // through its retry ladder. Keep that healing, but only when there is
611
+ // genuinely nothing live: no in-flight flow, no pending retry/crash-restart
612
+ // timer, no children — a second concurrent flow is exactly the duplicate
613
+ // registration the single-supervisor registry exists to prevent.
614
+ if (this.isIdle()) void this.startAttempt(0);
615
+ }
616
+
617
+ /** Nothing running and nothing scheduled: safe to (re)start a flow. */
618
+ private isIdle(): boolean {
619
+ return !this.stopped
620
+ && !this.launchInFlight
621
+ && this.timers.size === 0
622
+ && this.serverChild === null
623
+ && this.tunnelChild === null;
624
+ }
625
+
452
626
  private async startAttempt(attempt: number): Promise<void> {
453
627
  const { deps } = this;
628
+ this.launchInFlight = true;
454
629
  try {
455
630
  if (!deps.enabled) return;
456
631
  // ensureLivewareCli downloads the CLI asynchronously on first boot;
@@ -480,6 +655,8 @@ export class LivewareSampleSupervisor {
480
655
  `clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, retrying in ${delayMs / 1000}s): ${String(err)}`,
481
656
  );
482
657
  this.schedule(() => { void this.startAttempt(attempt + 1); }, delayMs);
658
+ } finally {
659
+ this.launchInFlight = false;
483
660
  }
484
661
  }
485
662
 
@@ -518,17 +695,16 @@ export class LivewareSampleSupervisor {
518
695
  private async bootstrap(): Promise<void> {
519
696
  const { deps } = this;
520
697
  if (this.stopped) return;
698
+ // Retryable-vs-opt-out: a missing CLI, an absent token and a flaky listApps
699
+ // are all transient, but they used to `return`, which left the sample dormant
700
+ // for the whole process lifetime (nothing re-enters bootstrap on its own).
701
+ // Throw instead so startAttempt's START_RETRY_DELAYS_MS ladder gets its turn;
702
+ // only a genuine opt-out (disabled row, owner already has apps) returns.
521
703
  const livewarePath = deps.resolveLivewarePath();
522
- if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip"); return; }
704
+ if (!livewarePath) throw new Error("liveware CLI not ready");
523
705
  const token = deps.resolveToken();
524
- if (!token) { deps.log?.debug?.("liveware-sample: no token; skip"); return; }
525
- let apps: { apps: { app_id?: string }[] };
526
- try {
527
- apps = await deps.listApps();
528
- } catch (err) {
529
- deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
530
- return;
531
- }
706
+ if (!token) throw new Error("no ClawChat token available yet");
707
+ const apps = await deps.listApps();
532
708
  if (this.bailIfStopped()) return;
533
709
  if (apps.apps.length > 0) {
534
710
  deps.log?.debug?.("liveware-sample: user already has liveware apps; skip");
@@ -546,9 +722,30 @@ export class LivewareSampleSupervisor {
546
722
  this.serverChild = server.child;
547
723
  if (this.bailIfStopped()) return;
548
724
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
549
- const appId = await livewareAppCreate({
725
+ // Reuse an app we already own before minting another one. `app create` is not
726
+ // idempotent and nothing here ever reconciled against the liveware side, so
727
+ // every bootstrap that died past this point (no row was written until the very
728
+ // end, see below) left one more orphan app behind and ate into the account's
729
+ // app quota. Best-effort: an unreadable list falls through to `app create`.
730
+ const existingAppId = await livewareAppFindByName({
731
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
732
+ });
733
+ if (this.bailIfStopped()) return;
734
+ if (existingAppId) {
735
+ deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
736
+ }
737
+ const appId = existingAppId ?? await livewareAppCreate({
550
738
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
551
739
  });
740
+ // Persist the app id BEFORE the steps that can still fail. Without this a
741
+ // failure anywhere below left no row at all, so the next boot ran bootstrap
742
+ // again and created yet another app; a "pending" row makes the next attempt
743
+ // resume through relaunch() with the same app id instead.
744
+ deps.store.upsertLivewareSample({
745
+ platform: deps.platform, accountId: deps.accountId,
746
+ appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
747
+ publicUrl: null, sampleVersion: version, status: "pending",
748
+ });
552
749
  const publicUrl = await tunnelBind({
553
750
  livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
554
751
  });
@@ -570,20 +767,22 @@ export class LivewareSampleSupervisor {
570
767
  private async relaunch(row: LivewareSampleRow): Promise<void> {
571
768
  const { deps } = this;
572
769
  if (this.stopped) return;
770
+ // Both of these used to `return` and strand the sample until the next process
771
+ // start; they are transient, so throw and let the retry ladder run (see
772
+ // bootstrap). Only a successful list that no longer contains the app is an
773
+ // opt-out.
573
774
  const livewarePath = deps.resolveLivewarePath();
574
- if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip relaunch"); return; }
775
+ if (!livewarePath) throw new Error("liveware CLI not ready");
776
+ const apps = await deps.listApps();
575
777
  // The user deleting the app tile is an explicit opt-out — never reinstall.
576
- try {
577
- const apps = await deps.listApps();
578
- if (!apps.apps.some((a) => a.app_id === row.app_id)) {
579
- deps.store.updateLivewareSampleStatus({
580
- platform: deps.platform, accountId: deps.accountId, status: "disabled",
581
- lastError: "app removed by user",
582
- });
583
- return;
584
- }
585
- } catch (err) {
586
- deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
778
+ // Exception: a "pending" row was persisted mid-bootstrap, before the app was
779
+ // ever registered with ClawChat, so its absence from this list means "not
780
+ // registered yet", not "deleted" — disabling it there would strand the app id.
781
+ if (row.status !== "pending" && !apps.apps.some((a) => a.app_id === row.app_id)) {
782
+ deps.store.updateLivewareSampleStatus({
783
+ platform: deps.platform, accountId: deps.accountId, status: "disabled",
784
+ lastError: "app removed by user",
785
+ });
587
786
  return;
588
787
  }
589
788
  if (this.bailIfStopped()) return;
@@ -607,21 +806,59 @@ export class LivewareSampleSupervisor {
607
806
  });
608
807
  this.serverChild = server.child;
609
808
  if (this.bailIfStopped()) return;
809
+ // Re-login before touching the CLI. Its credentials live in $HOME/.clawling,
810
+ // a different volume from the stateDir holding the row that sent us down this
811
+ // path: a container that persists stateDir but not $HOME comes back with a row
812
+ // AND a logged-out CLI, so every tunnelBind/agent below fails auth until the
813
+ // retry ladder gives up — the sample then never returns for the whole process
814
+ // lifetime. `login` is idempotent; best-effort, so a stale token still falls
815
+ // through to whatever credentials the CLI already has.
816
+ const token = deps.resolveToken();
817
+ if (token) {
818
+ try {
819
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
820
+ } catch (err) {
821
+ deps.log?.warn?.(
822
+ `liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
823
+ );
824
+ }
825
+ if (this.bailIfStopped()) return;
826
+ } else {
827
+ deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
828
+ }
829
+ // Kept after the re-login above so the lookup runs against a CLI that is
830
+ // actually authenticated: a pending row's app id came straight from
831
+ // `app create`'s parser and was never confirmed by the liveware side, so
832
+ // prefer a by-name lookup — a mis-parsed id then self-heals instead of
833
+ // failing every tunnel bind for the rest of this row's life.
834
+ let appId = row.app_id;
835
+ if (row.status === "pending") {
836
+ const found = await livewareAppFindByName({
837
+ livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
838
+ });
839
+ if (found && found !== appId) {
840
+ deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
841
+ appId = found;
842
+ }
843
+ if (this.bailIfStopped()) return;
844
+ }
610
845
  const publicUrl = await tunnelBind({
611
- livewarePath, appId: row.app_id, port: server.port, execFileFn: deps.execFileFn,
846
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
612
847
  });
613
848
  if (this.bailIfStopped()) return;
614
849
  const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
615
850
  this.tunnelChild = agent.child;
616
851
  if (this.bailIfStopped()) return;
617
- if (publicUrl !== row.public_url) {
852
+ // A pending row has never been registered with ClawChat (public_url is null),
853
+ // so this condition also covers "register it for the first time".
854
+ if (publicUrl !== row.public_url || appId !== row.app_id) {
618
855
  await deps.registerApp({
619
- name: row.app_name, appId: row.app_id, url: publicUrl,
856
+ name: row.app_name, appId, url: publicUrl,
620
857
  });
621
858
  }
622
859
  deps.store.upsertLivewareSample({
623
860
  platform: deps.platform, accountId: deps.accountId,
624
- appId: row.app_id, appName: row.app_name, port: server.port,
861
+ appId, appName: row.app_name, port: server.port,
625
862
  publicUrl, sampleVersion: version, status: "active",
626
863
  });
627
864
  this.watchChildren();
@@ -652,16 +889,25 @@ export class LivewareSampleSupervisor {
652
889
  platform: this.deps.platform, accountId: this.deps.accountId,
653
890
  });
654
891
  if (!row || row.status === "disabled") return;
655
- void this.relaunch(row).catch((err) => {
656
- // A partially-completed relaunch may have spawned children before
657
- // failing — never leave them running unwatched.
658
- this.killChildren();
659
- this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
660
- this.deps.store.updateLivewareSampleStatus({
661
- platform: this.deps.platform, accountId: this.deps.accountId,
662
- status: "failed", lastError: String(err),
663
- });
664
- });
892
+ // This relaunch does NOT go through startAttempt, so it must do that
893
+ // method's launchInFlight bookkeeping itself: the timer has already been
894
+ // dropped from `timers` and children are only assigned a few awaits in, so
895
+ // without this isIdle() reports idle for the whole early part of a
896
+ // crash-restart and a concurrent adoptDeps kick would run a SECOND launch
897
+ // flow — the duplicate registration the single-supervisor rule prevents.
898
+ this.launchInFlight = true;
899
+ void this.relaunch(row)
900
+ .catch((err) => {
901
+ // A partially-completed relaunch may have spawned children before
902
+ // failing — never leave them running unwatched.
903
+ this.killChildren();
904
+ this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
905
+ this.deps.store.updateLivewareSampleStatus({
906
+ platform: this.deps.platform, accountId: this.deps.accountId,
907
+ status: "failed", lastError: String(err),
908
+ });
909
+ })
910
+ .finally(() => { this.launchInFlight = false; });
665
911
  }, delay);
666
912
  };
667
913
  for (const child of [this.serverChild, this.tunnelChild]) {
package/src/no-reply.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  * anchored, wrapping decorations (`<>`, `[]`, `{}`, `/`, backticks, `*`) are
9
9
  * tolerated for free and must not be written into the pattern itself.
10
10
  *
11
- * RULE B — the host's bare markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
12
- * `NO REPLY`), matched as a WHOLE STRING ONLY. These are ordinary English
13
- * words; substring-matching them would swallow prose such as "there is no reply
14
- * from the server".
11
+ * RULE B — bare runtime silence markers (`NO_REPLY` / `[SILENT]` / `SILENT` /
12
+ * `NO REPLY`), matched as a WHOLE STRING ONLY. OpenClaw itself has no such
13
+ * convention; these are carried because other agent runtimes define them, so a
14
+ * model may fall back to one instead of the `clawchat:` form. They are ordinary
15
+ * English words — substring-matching them would swallow prose such as "there is
16
+ * no reply from the server".
15
17
  *
16
18
  * This module MUST stay a literal mirror of the Hermes plugin's equivalent
17
19
  * module. When one side changes, change the other in the same breath.
package/src/outbound.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { EVENT, MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload, type MessageReactionPayload } from "./protocol-types.ts";
3
- import type { ClawlingChatClient } from "./ws-client.ts";
2
+ import { EVENT, MessageSendError, TERMINAL_CHAT_CODES, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload, type MessageReactionPayload } from "./protocol-types.ts";
3
+ import { CHAT_ID_PREFIX, isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
4
4
  import {
5
5
  createAttachedChannelResultAdapter,
6
6
  type ChannelOutboundAdapter,
@@ -219,9 +219,25 @@ export function setAlignedOutboundLogContext(
219
219
  alignedOutboundContexts.set(client as object, context);
220
220
  }
221
221
 
222
+ /**
223
+ * The single outbound gate predicate. Narrow by design: only a SERVER-confirmed
224
+ * rejection blocks, never a `conversation.dissolved` inference, and never the
225
+ * owner's direct chat (the client applies that exemption inside isSendBlocked).
226
+ * Optional-call form because outbound tests hand-roll a client mock.
227
+ */
228
+ function isOutboundBlocked(client: ClawlingChatClient, chatId: string | undefined): boolean {
229
+ if (!chatId) return false;
230
+ return (client as { isSendBlocked?: (chatId: string) => boolean }).isSendBlocked?.(chatId) === true;
231
+ }
232
+
222
233
  export function flushAlignedOutboundQueue(client: ClawlingChatClient): void {
223
234
  const queue = alignedOutboundQueues.get(client as object);
224
- queue?.flush((wire) => client.sendWire(wire));
235
+ // Re-check the gate at write time: this replay path (called on every
236
+ // `connected` edge) does not re-enter sendAlignedAckableEnvelope.
237
+ queue?.flush(
238
+ (wire) => client.sendWire(wire),
239
+ { isBlocked: (item) => isOutboundBlocked(client, item.chatId) },
240
+ );
225
241
  }
226
242
 
227
243
  export function getAlignedOutboundQueueSize(client: ClawlingChatClient): number {
@@ -236,6 +252,68 @@ async function sendAlignedAckableEnvelope(params: {
236
252
  payload: object;
237
253
  log?: LogSink;
238
254
  }): Promise<Envelope<MessageAckPayload>> {
255
+ // Static addressing check, before any server state is consulted: this one is
256
+ // decidable locally and is never revocable, so it is not a "gate" that can
257
+ // later clear. msghub refuses such a frame with `invalid conversation id`.
258
+ if (!isValidChatId(params.chatId)) {
259
+ params.log?.info?.(
260
+ formatWsLog({
261
+ event: "send_blocked",
262
+ accountId: params.account.accountId,
263
+ ...(
264
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
265
+ attempt: 1,
266
+ reconnectCount: 0,
267
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
268
+ }
269
+ ),
270
+ action: "drop",
271
+ fields: [
272
+ ["event_name", params.eventName],
273
+ ["chat_id", params.chatId],
274
+ ["reason", "invalid_chat_id"],
275
+ ],
276
+ }),
277
+ );
278
+ throw new MessageSendError(
279
+ "-",
280
+ "invalid_chat_id",
281
+ `"${params.chatId}" is not a ClawChat conversation id (expected ${CHAT_ID_PREFIX}…); refusing to send`,
282
+ params.chatId,
283
+ );
284
+ }
285
+ // The server has already rejected this conversation with a terminal code and
286
+ // that rejection has not expired. Stop here: every further frame would be
287
+ // refused the same way. Gated on the narrow server-confirmed tier ONLY — a
288
+ // `conversation.dissolved` signal must never reach this branch, and the
289
+ // owner's direct chat is exempt (see ClawChatClient.isSendBlocked).
290
+ if (isOutboundBlocked(params.client, params.chatId)) {
291
+ params.log?.info?.(
292
+ formatWsLog({
293
+ event: "send_blocked",
294
+ accountId: params.account.accountId,
295
+ ...(
296
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
297
+ attempt: 1,
298
+ reconnectCount: 0,
299
+ state: params.client.transportState === "open" ? "ready" : "reconnecting",
300
+ }
301
+ ),
302
+ action: "drop",
303
+ fields: [
304
+ ["event_name", params.eventName],
305
+ ["chat_id", params.chatId],
306
+ ["reason", "chat_rejected_by_server"],
307
+ ],
308
+ }),
309
+ );
310
+ throw new MessageSendError(
311
+ "-",
312
+ "chat_rejected_local",
313
+ "conversation was rejected by the server; refusing to send",
314
+ params.chatId,
315
+ );
316
+ }
239
317
  const traceId = params.client.nextTraceId();
240
318
  const env = {
241
319
  version: "2" as const,
@@ -331,6 +409,19 @@ async function sendAlignedAckableEnvelope(params: {
331
409
  : typeof payload.message === "string" && payload.message
332
410
  ? payload.message
333
411
  : "message send failed";
412
+ // A terminal refusal proves the conversation is unreachable for this
413
+ // sender right now. Record it on the SERVER tier so the gates below
414
+ // stop subsequent frames — this is fact, not the inference that
415
+ // `conversation.dissolved` gives us. It expires; see the client.
416
+ if (
417
+ TERMINAL_CHAT_CODES.has(code)
418
+ && typeof ack.chat_id === "string"
419
+ && ack.chat_id
420
+ ) {
421
+ (params.client as {
422
+ markChatDead?: (chatId: string, source: "signal" | "server") => boolean;
423
+ }).markChatDead?.(ack.chat_id, "server");
424
+ }
334
425
  fail(new MessageSendError(traceId, code, hint, ack.chat_id));
335
426
  return;
336
427
  }
@@ -366,6 +457,14 @@ async function sendAlignedAckableEnvelope(params: {
366
457
  onDrop: () => {
367
458
  fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
368
459
  },
460
+ onBlock: () => {
461
+ fail(new MessageSendError(
462
+ "-",
463
+ "chat_rejected_local",
464
+ "conversation was rejected by the server while this frame was queued",
465
+ params.chatId,
466
+ ));
467
+ },
369
468
  };
370
469
 
371
470
  function isTerminalClose(close: AlignedOutboundClose): boolean {
@@ -407,7 +506,10 @@ async function sendAlignedAckableEnvelope(params: {
407
506
 
408
507
  try {
409
508
  queue.enqueue(item);
410
- queue.flush((queuedWire) => params.client.sendWire(queuedWire));
509
+ queue.flush(
510
+ (queuedWire) => params.client.sendWire(queuedWire),
511
+ { isBlocked: (queued) => isOutboundBlocked(params.client, queued.chatId) },
512
+ );
411
513
  } catch {
412
514
  // The queue keeps the failed frame at the head for reconnect retry, so
413
515
  // keep this promise pending until the frame is written+acked, dropped, or timed out.
@@ -663,6 +765,18 @@ export interface SendReactionParams {
663
765
  * path used for message.send/reply does not apply). Matches the mobile client.
664
766
  */
665
767
  export function sendOpenclawClawlingReaction(params: SendReactionParams): void {
768
+ if (!isValidChatId(params.chatId)) {
769
+ params.log?.info?.(
770
+ `clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`,
771
+ );
772
+ return;
773
+ }
774
+ if (isOutboundBlocked(params.client, params.chatId)) {
775
+ params.log?.info?.(
776
+ `clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`,
777
+ );
778
+ return;
779
+ }
666
780
  const payload: MessageReactionPayload = {
667
781
  target_message_id: params.targetMessageId,
668
782
  emoji: params.emoji,