@clawling/clawchat-plugin-openclaw 2026.9.8-2 → 2026.9.10-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.
@@ -2,8 +2,28 @@ import { createHash } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import { createClawChatClient } from "./ws-client.js";
4
4
  import { CHANNEL_ID } from "./config.js";
5
- export function resolveOpenclawClawlingDeviceId(account) {
6
- const material = [CHANNEL_ID, account.accountId, account.userId, os.hostname()].join("\0");
5
+ import { resolveStateDirFromEnv } from "./state-paths.js";
6
+ /**
7
+ * Derive this instance's `device_id` from stable, per-instance material.
8
+ *
9
+ * The state dir is part of the material because it is the ONLY input that
10
+ * distinguishes two OpenClaw instances on one host: `openclaw --profile <name>`
11
+ * isolates an instance by pointing it at `~/.openclaw-<name>` (host
12
+ * `src/cli/profile.ts:133-146`), but `accountId` is a constant and `hostname()`
13
+ * is shared. Two profiles that end up on the SAME ClawChat identity — trivially
14
+ * easy, since an exported `CLAWCHAT_TOKEN` is inherited by a second profile
15
+ * whose own channel config is still empty (`config.ts` env fallbacks) — would
16
+ * otherwise present a byte-identical device id and evict each other forever
17
+ * (`ws-client.ts` close codes 4001/4002 and the escalating takeover floor).
18
+ *
19
+ * Existing installs are unaffected: once the server has resolved a device id it
20
+ * is persisted and replayed as `deviceIdOverride` (`runtime.ts`
21
+ * `getLastResolvedDeviceId` → `markConnectionReady`), so this derivation only
22
+ * governs a first-ever connect.
23
+ */
24
+ export function resolveOpenclawClawlingDeviceId(account, opts = {}) {
25
+ const stateDir = opts.stateDir?.trim() || resolveStateDirFromEnv();
26
+ const material = [CHANNEL_ID, account.accountId, account.userId, os.hostname(), stateDir].join("\0");
7
27
  const digest = createHash("sha256").update(material).digest("hex").slice(0, 24);
8
28
  return `${CHANNEL_ID}-${digest}`;
9
29
  }
@@ -10,6 +10,33 @@ export function livewareDir(stateDir) {
10
10
  const root = stateDir ?? resolveStateDir();
11
11
  return path.join(root, "clawchat", "liveware");
12
12
  }
13
+ /**
14
+ * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home`.
15
+ *
16
+ * The CLI keeps its login credentials in `$HOME/.clawling`, which is
17
+ * host-global. Two OpenClaw instances on one box (`openclaw --profile <name>`,
18
+ * each with its own state dir but the same OS account) each run
19
+ * `liveware login --access-token <their own>`, so the last writer wins and the
20
+ * earlier instance's long-lived `liveware agent` daemon ends up authenticated
21
+ * as the OTHER account's user. Pointing HOME at the state dir makes the
22
+ * credential store per-instance, which is the same isolation the rest of the
23
+ * plugin already gets for free.
24
+ *
25
+ * Migration is automatic: `livewareLogin` runs on every bootstrap AND (best
26
+ * effort) on every relaunch, so an existing install simply re-logs into its new
27
+ * private home on the next start.
28
+ */
29
+ export function livewareCliHomeDir(stateDir) {
30
+ const root = stateDir ?? resolveStateDir();
31
+ return path.join(root, "clawchat", "liveware-home");
32
+ }
33
+ /**
34
+ * Environment for every liveware CLI invocation: the ambient env with HOME
35
+ * (and Windows' USERPROFILE) redirected to {@link livewareCliHomeDir}.
36
+ */
37
+ export function livewareCliEnv(cliHome, baseEnv = process.env) {
38
+ return { ...baseEnv, HOME: cliHome, USERPROFILE: cliHome };
39
+ }
13
40
  /** Local filename for the downloaded binary; Windows needs a .exe suffix. */
14
41
  export function livewareBinaryName(plat = process.platform) {
15
42
  return plat === "win32" ? "liveware.exe" : "liveware";
@@ -7,8 +7,10 @@
7
7
  import crypto from "node:crypto";
8
8
  import { execFile as nodeExecFile, spawn as nodeSpawn } from "node:child_process";
9
9
  import fs from "node:fs";
10
+ import net from "node:net";
10
11
  import path from "node:path";
11
12
  import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE } from "./skill-update.js";
13
+ import { livewareCliEnv } from "./liveware-cli.js";
12
14
  export const LIVEWARES_TARGET = "openclaw";
13
15
  export const LIVEWARE_SAMPLE_ID = "liveware-sample";
14
16
  export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
@@ -260,6 +262,45 @@ function waitForOutput(child, match, timeoutMs, label) {
260
262
  child.on("exit", onExit);
261
263
  });
262
264
  }
265
+ const probeLoopbackPort = (port) => new Promise((resolve) => {
266
+ const srv = net.createServer();
267
+ srv.once("error", () => resolve(null));
268
+ srv.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
269
+ const addr = srv.address();
270
+ const bound = addr && typeof addr === "object" ? addr.port : null;
271
+ srv.close(() => resolve(bound));
272
+ });
273
+ });
274
+ /**
275
+ * Choose the port the sample server should bind.
276
+ *
277
+ * `DEFAULT_SAMPLE_PORT` used to be passed to the child unconditionally, so a
278
+ * second OpenClaw instance on the same host (`openclaw --profile <name>`) lost
279
+ * the bind race, never printed its `{"port"…}` line, and burnt the whole
280
+ * `START_RETRY_DELAYS_MS` ladder before going dormant — permanently, because
281
+ * the port persisted in BOTH state dirs was the same constant.
282
+ *
283
+ * `preferred` is still tried first so a single-instance install keeps its
284
+ * familiar port and its existing tunnel binding; anything else falls back to a
285
+ * kernel-assigned ephemeral port. A changed port costs nothing: bootstrap and
286
+ * relaunch both re-run `tunnel bind` with the actual port and only re-register
287
+ * with ClawChat when the resulting public URL changed.
288
+ *
289
+ * The probe closes its socket before the child binds, so there is a small race
290
+ * with an unrelated process. That is inherent to pre-allocating a port for a
291
+ * child, and a lost race is a transient start failure the retry ladder handles.
292
+ */
293
+ export async function resolveSamplePort(preferred, probeFn = probeLoopbackPort) {
294
+ if (typeof preferred === "number" && preferred > 0) {
295
+ const kept = await probeFn(preferred);
296
+ if (kept)
297
+ return kept;
298
+ }
299
+ const ephemeral = await probeFn(0);
300
+ if (!ephemeral)
301
+ throw new Error("liveware-sample: cannot allocate a local port");
302
+ return ephemeral;
303
+ }
263
304
  export async function startSampleServer(opts) {
264
305
  const spawnFn = opts.spawnFn ?? nodeSpawn;
265
306
  const args = [path.join(opts.appDir, "server.mjs"), "--dir", opts.appDir, "--port", String(opts.port)];
@@ -299,7 +340,7 @@ export function parseTunnelPublicUrl(output) {
299
340
  export async function tunnelBind(opts) {
300
341
  const execFileFn = opts.execFileFn ?? nodeExecFile;
301
342
  const output = await new Promise((resolve, reject) => {
302
- execFileFn(opts.livewarePath, ["tunnel", "bind", opts.appId, `http://127.0.0.1:${opts.port}`], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
343
+ execFileFn(opts.livewarePath, ["tunnel", "bind", opts.appId, `http://127.0.0.1:${opts.port}`], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
303
344
  if (err) {
304
345
  reject(new Error(`liveware tunnel bind failed: ${errOut || out || String(err)}`.trim()));
305
346
  return;
@@ -333,14 +374,36 @@ export function parseAgentReady(output) {
333
374
  */
334
375
  export async function startTunnelAgent(opts) {
335
376
  const spawnFn = opts.spawnFn ?? nodeSpawn;
336
- const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"] });
377
+ const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"], ...(opts.env ? { env: opts.env } : {}) });
337
378
  await waitForOutput(child, (acc) => parseAgentReady(acc), opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS, "liveware agent start");
338
379
  return { child };
339
380
  }
381
+ /** Where the liveware CLI keeps its login state, relative to its `HOME`. */
382
+ const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
383
+ /**
384
+ * True when the liveware CLI has usable cached credentials under `cliHome`.
385
+ *
386
+ * Tolerates either shape the CLI might use: a credential file (non-empty) or a
387
+ * directory (non-empty). Anything unreadable answers "no" — the caller's only
388
+ * use is deciding whether it is safe to skip a login, and guessing "yes" there
389
+ * is exactly the failure this check exists to prevent.
390
+ */
391
+ export async function hasCachedLivewareCredentials(cliHome) {
392
+ const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
393
+ try {
394
+ const stat = await fs.promises.stat(target);
395
+ if (stat.isDirectory())
396
+ return (await fs.promises.readdir(target)).length > 0;
397
+ return stat.size > 0;
398
+ }
399
+ catch {
400
+ return false;
401
+ }
402
+ }
340
403
  export async function livewareLogin(opts) {
341
404
  const execFileFn = opts.execFileFn ?? nodeExecFile;
342
405
  await new Promise((resolve, reject) => {
343
- execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS }, (err, stdout, stderr) => {
406
+ execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, stdout, stderr) => {
344
407
  if (!err) {
345
408
  resolve();
346
409
  return;
@@ -466,7 +529,7 @@ function findNamedIdDeep(value, name) {
466
529
  export async function livewareAppFindByName(opts) {
467
530
  const execFileFn = opts.execFileFn ?? nodeExecFile;
468
531
  const stdout = await new Promise((resolve) => {
469
- execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
532
+ execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
470
533
  if (err) {
471
534
  opts.log?.debug?.(`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`);
472
535
  resolve(null);
@@ -480,7 +543,7 @@ export async function livewareAppFindByName(opts) {
480
543
  export async function livewareAppCreate(opts) {
481
544
  const execFileFn = opts.execFileFn ?? nodeExecFile;
482
545
  const stdout = await new Promise((resolve, reject) => {
483
- execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
546
+ execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
484
547
  if (err) {
485
548
  reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim()));
486
549
  return;
@@ -629,6 +692,27 @@ export class LivewareSampleSupervisor {
629
692
  this.killChildren();
630
693
  return true;
631
694
  }
695
+ /**
696
+ * Environment for every liveware CLI invocation in this flow. Creates the
697
+ * private HOME first: the CLI writes `<HOME>/.clawling` on login and a
698
+ * missing parent would fail the very step that isolates the credentials.
699
+ * Falls back to the ambient env when the dir cannot be created, so an
700
+ * unwritable state dir degrades to the old shared-HOME behavior instead of
701
+ * breaking the sample outright.
702
+ */
703
+ async ensureCliEnv() {
704
+ const cliHome = this.deps.cliHome?.trim();
705
+ if (!cliHome)
706
+ return undefined;
707
+ try {
708
+ await fs.promises.mkdir(cliHome, { recursive: true });
709
+ }
710
+ catch (err) {
711
+ this.deps.log?.warn?.(`liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`);
712
+ return undefined;
713
+ }
714
+ return livewareCliEnv(cliHome);
715
+ }
632
716
  async bootstrap() {
633
717
  const { deps } = this;
634
718
  if (this.stopped)
@@ -656,14 +740,17 @@ export class LivewareSampleSupervisor {
656
740
  });
657
741
  if (this.bailIfStopped())
658
742
  return;
743
+ const cliEnv = await this.ensureCliEnv();
659
744
  const server = await startSampleServer({
660
- appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
745
+ appDir,
746
+ port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
747
+ spawnFn: deps.spawnFn,
661
748
  agentUserId: deps.resolveAgentUserId?.() ?? null,
662
749
  });
663
750
  this.serverChild = server.child;
664
751
  if (this.bailIfStopped())
665
752
  return;
666
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
753
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
667
754
  // Reuse an app we already own before minting another one. `app create` is not
668
755
  // idempotent and nothing here ever reconciled against the liveware side, so
669
756
  // every bootstrap that died past this point (no row was written until the very
@@ -671,6 +758,7 @@ export class LivewareSampleSupervisor {
671
758
  // app quota. Best-effort: an unreadable list falls through to `app create`.
672
759
  const existingAppId = await livewareAppFindByName({
673
760
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
761
+ env: cliEnv,
674
762
  });
675
763
  if (this.bailIfStopped())
676
764
  return;
@@ -678,7 +766,7 @@ export class LivewareSampleSupervisor {
678
766
  deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
679
767
  }
680
768
  const appId = existingAppId ?? await livewareAppCreate({
681
- livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
769
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
682
770
  });
683
771
  // Persist the app id BEFORE the steps that can still fail. Without this a
684
772
  // failure anywhere below left no row at all, so the next boot ran bootstrap
@@ -690,11 +778,11 @@ export class LivewareSampleSupervisor {
690
778
  publicUrl: null, sampleVersion: version, status: "pending",
691
779
  });
692
780
  const publicUrl = await tunnelBind({
693
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
781
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
694
782
  });
695
783
  if (this.bailIfStopped())
696
784
  return;
697
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
785
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
698
786
  this.tunnelChild = agent.child;
699
787
  if (this.bailIfStopped())
700
788
  return;
@@ -749,8 +837,11 @@ export class LivewareSampleSupervisor {
749
837
  }
750
838
  if (this.bailIfStopped())
751
839
  return;
840
+ const cliEnv = await this.ensureCliEnv();
752
841
  const server = await startSampleServer({
753
- appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
842
+ appDir,
843
+ port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
844
+ spawnFn: deps.spawnFn,
754
845
  agentUserId: deps.resolveAgentUserId?.() ?? null,
755
846
  });
756
847
  this.serverChild = server.child;
@@ -764,12 +855,14 @@ export class LivewareSampleSupervisor {
764
855
  // lifetime. `login` is idempotent; best-effort, so a stale token still falls
765
856
  // through to whatever credentials the CLI already has.
766
857
  const token = deps.resolveToken();
858
+ let loggedIn = false;
767
859
  if (token) {
768
860
  try {
769
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
861
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
862
+ loggedIn = true;
770
863
  }
771
864
  catch (err) {
772
- deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`);
865
+ deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`);
773
866
  }
774
867
  if (this.bailIfStopped())
775
868
  return;
@@ -777,6 +870,20 @@ export class LivewareSampleSupervisor {
777
870
  else {
778
871
  deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
779
872
  }
873
+ // Falling through without a fresh login is only safe when the CLI actually
874
+ // has credentials cached. Since the private-HOME redirect those live in
875
+ // `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
876
+ // home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
877
+ // there makes every tunnelBind/agent call below fail auth, which surfaces as
878
+ // a confusing tunnel error rather than the credential problem it is, and
879
+ // burns the whole retry ladder on a state that cannot recover on its own.
880
+ // Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
881
+ // a delayed sample rather than a silent auth failure.
882
+ if (!loggedIn && cliEnv && deps.cliHome) {
883
+ if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
884
+ throw new Error(`liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`);
885
+ }
886
+ }
780
887
  // Kept after the re-login above so the lookup runs against a CLI that is
781
888
  // actually authenticated: a pending row's app id came straight from
782
889
  // `app create`'s parser and was never confirmed by the liveware side, so
@@ -786,6 +893,7 @@ export class LivewareSampleSupervisor {
786
893
  if (row.status === "pending") {
787
894
  const found = await livewareAppFindByName({
788
895
  livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
896
+ env: cliEnv,
789
897
  });
790
898
  if (found && found !== appId) {
791
899
  deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
@@ -795,11 +903,11 @@ export class LivewareSampleSupervisor {
795
903
  return;
796
904
  }
797
905
  const publicUrl = await tunnelBind({
798
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
906
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
799
907
  });
800
908
  if (this.bailIfStopped())
801
909
  return;
802
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
910
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
803
911
  this.tunnelChild = agent.child;
804
912
  if (this.bailIfStopped())
805
913
  return;
@@ -7,7 +7,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { buildActivationBootstrapText } from "./activation-greeting.js";
8
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
9
9
  import path from "node:path";
10
- import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.js";
10
+ import { ensureLivewareCli, livewareCliHomeDir, resolveLivewarePath } from "./liveware-cli.js";
11
11
  import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
12
  import { ClawlingApiError } from "./api-types.js";
13
13
  import { RefreshManager } from "./refresh-manager.js";
@@ -835,6 +835,9 @@ export async function startOpenclawClawlingGateway(params) {
835
835
  resolveToken: () => account.token ?? "",
836
836
  resolveAgentUserId: () => account.userId ?? null,
837
837
  resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
838
+ // Per-instance HOME for the liveware CLI: its credentials otherwise live
839
+ // in the host-global `$HOME/.clawling`, which a second profile clobbers.
840
+ cliHome: livewareCliHomeDir(stateDirForSample),
838
841
  cliReady: livewareCliReady,
839
842
  listApps: async () => {
840
843
  const client = getConversationApiClient();
@@ -1,9 +1,9 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveStateDirFromEnv } from "./state-paths.js";
7
7
  /**
8
8
  * Conversational, adapter-driven skill hot-update for the OpenClaw ClawChat
9
9
  * plugin.
@@ -329,18 +329,14 @@ export async function managedSkillExists(managedDir, skillId) {
329
329
  }
330
330
  }
331
331
  /**
332
- * Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
333
- * `OPENCLAW_STATE_DIR` if set; else the dirname of `OPENCLAW_CONFIG_PATH` if
334
- * set; else `~/.openclaw`.
332
+ * Resolve OpenClaw's state directory: `OPENCLAW_STATE_DIR` if set; else the
333
+ * dirname of `OPENCLAW_CONFIG_PATH` if set; else `<effective home>/.openclaw`,
334
+ * where the effective home honors `OPENCLAW_HOME`. Delegates to the shared
335
+ * mirror in `./state-paths.ts` — see there for why each rule matters under
336
+ * `openclaw --profile <name>`.
335
337
  */
336
338
  export function resolveStateDir(env = process.env) {
337
- const stateDir = env.OPENCLAW_STATE_DIR?.trim();
338
- if (stateDir)
339
- return stateDir;
340
- const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
341
- if (configPath)
342
- return path.dirname(configPath);
343
- return path.join(os.homedir(), ".openclaw");
339
+ return resolveStateDirFromEnv(env);
344
340
  }
345
341
  /**
346
342
  * Resolve the OpenClaw-managed skills directory — THE write target for an
@@ -0,0 +1,65 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ /**
4
+ * Local mirror of the OpenClaw host's state-directory resolution.
5
+ *
6
+ * WHY THIS EXISTS: most of the plugin resolves its state dir through the host
7
+ * (`openclaw/plugin-sdk/state-paths` or `runtime.state.resolveStateDir()`), and
8
+ * that is always the right answer. A few call sites cannot: the sqlite fallback
9
+ * in `storage.ts` runs when the SDK import/resolution throws, and
10
+ * `skill-update.ts` resolves the managed-skills dir without a `PluginRuntime`.
11
+ * Those sites used to guess `~/.openclaw` directly.
12
+ *
13
+ * That guess is wrong under `openclaw --profile <name>`, which isolates an
14
+ * instance by exporting `OPENCLAW_STATE_DIR=~/.openclaw-<name>` plus a matching
15
+ * `OPENCLAW_CONFIG_PATH` (host `src/cli/profile.ts:133-146`). A named profile
16
+ * that ignores those env vars silently lands on the DEFAULT profile's state dir
17
+ * — i.e. reads and writes the default profile's ClawChat database.
18
+ *
19
+ * It is also wrong about `OPENCLAW_HOME`: that variable replaces `$HOME` for
20
+ * every OpenClaw path default (host `src/infra/home-dir.ts:42-52`), it is NOT
21
+ * the state dir itself, so the correct default is `$OPENCLAW_HOME/.openclaw`.
22
+ *
23
+ * Mirrors host `src/config/state-dir.ts:28-38` + `src/infra/home-dir.ts:42-52`.
24
+ * Deliberately NOT mirrored: the legacy `~/.clawdbot` fallback (host
25
+ * `src/config/state-dir.ts:41-67`). This is a last-resort path; probing for a
26
+ * legacy directory here would be more surprising than landing on `~/.openclaw`.
27
+ */
28
+ const STATE_DIRNAME = ".openclaw";
29
+ function trimmed(value) {
30
+ const text = value?.trim();
31
+ return text ? text : undefined;
32
+ }
33
+ /** Expand a leading `~` against the OS home, matching the host's behavior. */
34
+ function expandTilde(value, osHome) {
35
+ if (value !== "~" && !value.startsWith("~/") && !value.startsWith("~\\"))
36
+ return value;
37
+ if (!osHome)
38
+ return value;
39
+ return path.join(osHome, value.slice(1));
40
+ }
41
+ /**
42
+ * The home OpenClaw derives its defaults from: `OPENCLAW_HOME` wins, else the
43
+ * ordinary OS home (`HOME`, then `USERPROFILE`, then `os.homedir()`).
44
+ */
45
+ export function resolveEffectiveHomeDir(env = process.env, homedir = os.homedir) {
46
+ const osHome = trimmed(env.HOME) ?? trimmed(env.USERPROFILE) ?? trimmed(homedir());
47
+ const explicit = trimmed(env.OPENCLAW_HOME);
48
+ if (explicit)
49
+ return expandTilde(explicit, osHome);
50
+ return osHome ?? homedir();
51
+ }
52
+ /**
53
+ * Resolve the state dir the way the host would, from env alone:
54
+ * `OPENCLAW_STATE_DIR`, else the dirname of `OPENCLAW_CONFIG_PATH`, else
55
+ * `<effective home>/.openclaw`.
56
+ */
57
+ export function resolveStateDirFromEnv(env = process.env, homedir = os.homedir) {
58
+ const stateDir = trimmed(env.OPENCLAW_STATE_DIR);
59
+ if (stateDir)
60
+ return expandTilde(stateDir, resolveEffectiveHomeDir(env, homedir));
61
+ const configPath = trimmed(env.OPENCLAW_CONFIG_PATH);
62
+ if (configPath)
63
+ return path.dirname(expandTilde(configPath, resolveEffectiveHomeDir(env, homedir)));
64
+ return path.join(resolveEffectiveHomeDir(env, homedir), STATE_DIRNAME);
65
+ }
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { DatabaseSync } from "node:sqlite";
5
4
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
+ import { resolveStateDirFromEnv } from "./state-paths.js";
6
6
  const DB_FILENAME = "clawchat.sqlite";
7
7
  const DEFAULT_BOOTSTRAP_CLAIM_STALE_MS = 5 * 60 * 1000;
8
8
  const MIGRATIONS = [
@@ -187,9 +187,14 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
187
187
  `,
188
188
  },
189
189
  ];
190
+ /**
191
+ * Only reached when the host SDK's `resolveStateDir()` throws. It must still
192
+ * honor `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH`: guessing `~/.openclaw`
193
+ * here would point a named `--profile` instance at the DEFAULT profile's
194
+ * database. See `./state-paths.ts` for the full mirror + rationale.
195
+ */
190
196
  function fallbackDbPath() {
191
- const home = process.env.OPENCLAW_HOME || path.join(os.homedir(), ".openclaw");
192
- return path.join(home, DB_FILENAME);
197
+ return clawChatDbPathForStateDir(resolveStateDirFromEnv());
193
198
  }
194
199
  export function clawChatDbPathForStateDir(stateDir) {
195
200
  return path.join(stateDir, DB_FILENAME);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.9.8-2",
3
+ "version": "2026.9.10-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/client.ts CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import type { Envelope, Transport } from "./protocol-types.ts";
4
4
  import { createClawChatClient, type ClawlingChatClient } from "./ws-client.ts";
5
5
  import { CHANNEL_ID, type ResolvedOpenclawClawlingAccount } from "./config.ts";
6
+ import { resolveStateDirFromEnv } from "./state-paths.ts";
6
7
 
7
8
  export type { ChatType } from "./protocol-types.ts";
8
9
 
@@ -26,8 +27,32 @@ export interface CreateClientOverrides {
26
27
  };
27
28
  }
28
29
 
29
- export function resolveOpenclawClawlingDeviceId(account: ResolvedOpenclawClawlingAccount): string {
30
- const material = [CHANNEL_ID, account.accountId, account.userId, os.hostname()].join("\0");
30
+ /**
31
+ * Derive this instance's `device_id` from stable, per-instance material.
32
+ *
33
+ * The state dir is part of the material because it is the ONLY input that
34
+ * distinguishes two OpenClaw instances on one host: `openclaw --profile <name>`
35
+ * isolates an instance by pointing it at `~/.openclaw-<name>` (host
36
+ * `src/cli/profile.ts:133-146`), but `accountId` is a constant and `hostname()`
37
+ * is shared. Two profiles that end up on the SAME ClawChat identity — trivially
38
+ * easy, since an exported `CLAWCHAT_TOKEN` is inherited by a second profile
39
+ * whose own channel config is still empty (`config.ts` env fallbacks) — would
40
+ * otherwise present a byte-identical device id and evict each other forever
41
+ * (`ws-client.ts` close codes 4001/4002 and the escalating takeover floor).
42
+ *
43
+ * Existing installs are unaffected: once the server has resolved a device id it
44
+ * is persisted and replayed as `deviceIdOverride` (`runtime.ts`
45
+ * `getLastResolvedDeviceId` → `markConnectionReady`), so this derivation only
46
+ * governs a first-ever connect.
47
+ */
48
+ export function resolveOpenclawClawlingDeviceId(
49
+ account: ResolvedOpenclawClawlingAccount,
50
+ opts: { stateDir?: string | null } = {},
51
+ ): string {
52
+ const stateDir = opts.stateDir?.trim() || resolveStateDirFromEnv();
53
+ const material = [CHANNEL_ID, account.accountId, account.userId, os.hostname(), stateDir].join(
54
+ "\0",
55
+ );
31
56
  const digest = createHash("sha256").update(material).digest("hex").slice(0, 24);
32
57
  return `${CHANNEL_ID}-${digest}`;
33
58
  }
@@ -20,6 +20,38 @@ export function livewareDir(stateDir?: string): string {
20
20
  return path.join(root, "clawchat", "liveware");
21
21
  }
22
22
 
23
+ /**
24
+ * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home`.
25
+ *
26
+ * The CLI keeps its login credentials in `$HOME/.clawling`, which is
27
+ * host-global. Two OpenClaw instances on one box (`openclaw --profile <name>`,
28
+ * each with its own state dir but the same OS account) each run
29
+ * `liveware login --access-token <their own>`, so the last writer wins and the
30
+ * earlier instance's long-lived `liveware agent` daemon ends up authenticated
31
+ * as the OTHER account's user. Pointing HOME at the state dir makes the
32
+ * credential store per-instance, which is the same isolation the rest of the
33
+ * plugin already gets for free.
34
+ *
35
+ * Migration is automatic: `livewareLogin` runs on every bootstrap AND (best
36
+ * effort) on every relaunch, so an existing install simply re-logs into its new
37
+ * private home on the next start.
38
+ */
39
+ export function livewareCliHomeDir(stateDir?: string): string {
40
+ const root = stateDir ?? resolveStateDir();
41
+ return path.join(root, "clawchat", "liveware-home");
42
+ }
43
+
44
+ /**
45
+ * Environment for every liveware CLI invocation: the ambient env with HOME
46
+ * (and Windows' USERPROFILE) redirected to {@link livewareCliHomeDir}.
47
+ */
48
+ export function livewareCliEnv(
49
+ cliHome: string,
50
+ baseEnv: NodeJS.ProcessEnv = process.env,
51
+ ): NodeJS.ProcessEnv {
52
+ return { ...baseEnv, HOME: cliHome, USERPROFILE: cliHome };
53
+ }
54
+
23
55
  /** Local filename for the downloaded binary; Windows needs a .exe suffix. */
24
56
  export function livewareBinaryName(plat: string = process.platform): string {
25
57
  return plat === "win32" ? "liveware.exe" : "liveware";
@@ -7,10 +7,11 @@
7
7
  import crypto from "node:crypto";
8
8
  import { execFile as nodeExecFile, spawn as nodeSpawn, type ChildProcess } from "node:child_process";
9
9
  import fs from "node:fs";
10
+ import net from "node:net";
10
11
  import path from "node:path";
11
12
  import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE, type FetchLike } from "./skill-update.ts";
12
13
  import type { LivewareSampleRow, LivewareSampleUpsert } from "./storage.ts";
13
- import type { LivewareLogger } from "./liveware-cli.ts";
14
+ import { livewareCliEnv, type LivewareLogger } from "./liveware-cli.ts";
14
15
 
15
16
  export const LIVEWARES_TARGET = "openclaw";
16
17
  export const LIVEWARE_SAMPLE_ID = "liveware-sample";
@@ -303,6 +304,55 @@ function waitForOutput(
303
304
  });
304
305
  }
305
306
 
307
+ /**
308
+ * Probe one loopback port: resolves to the bound port when free, else null.
309
+ * Port `0` asks the kernel for any free ephemeral port and reports which.
310
+ */
311
+ export type ProbePortLike = (port: number) => Promise<number | null>;
312
+
313
+ const probeLoopbackPort: ProbePortLike = (port) =>
314
+ new Promise((resolve) => {
315
+ const srv = net.createServer();
316
+ srv.once("error", () => resolve(null));
317
+ srv.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
318
+ const addr = srv.address();
319
+ const bound = addr && typeof addr === "object" ? addr.port : null;
320
+ srv.close(() => resolve(bound));
321
+ });
322
+ });
323
+
324
+ /**
325
+ * Choose the port the sample server should bind.
326
+ *
327
+ * `DEFAULT_SAMPLE_PORT` used to be passed to the child unconditionally, so a
328
+ * second OpenClaw instance on the same host (`openclaw --profile <name>`) lost
329
+ * the bind race, never printed its `{"port"…}` line, and burnt the whole
330
+ * `START_RETRY_DELAYS_MS` ladder before going dormant — permanently, because
331
+ * the port persisted in BOTH state dirs was the same constant.
332
+ *
333
+ * `preferred` is still tried first so a single-instance install keeps its
334
+ * familiar port and its existing tunnel binding; anything else falls back to a
335
+ * kernel-assigned ephemeral port. A changed port costs nothing: bootstrap and
336
+ * relaunch both re-run `tunnel bind` with the actual port and only re-register
337
+ * with ClawChat when the resulting public URL changed.
338
+ *
339
+ * The probe closes its socket before the child binds, so there is a small race
340
+ * with an unrelated process. That is inherent to pre-allocating a port for a
341
+ * child, and a lost race is a transient start failure the retry ladder handles.
342
+ */
343
+ export async function resolveSamplePort(
344
+ preferred: number | null | undefined,
345
+ probeFn: ProbePortLike = probeLoopbackPort,
346
+ ): Promise<number> {
347
+ if (typeof preferred === "number" && preferred > 0) {
348
+ const kept = await probeFn(preferred);
349
+ if (kept) return kept;
350
+ }
351
+ const ephemeral = await probeFn(0);
352
+ if (!ephemeral) throw new Error("liveware-sample: cannot allocate a local port");
353
+ return ephemeral;
354
+ }
355
+
306
356
  export async function startSampleServer(opts: {
307
357
  appDir: string;
308
358
  port: number;
@@ -357,13 +407,14 @@ export async function tunnelBind(opts: {
357
407
  appId: string;
358
408
  port: number;
359
409
  execFileFn?: ExecFileLike;
410
+ env?: NodeJS.ProcessEnv;
360
411
  }): Promise<string> {
361
412
  const execFileFn = opts.execFileFn ?? nodeExecFile;
362
413
  const output = await new Promise<string>((resolve, reject) => {
363
414
  execFileFn(
364
415
  opts.livewarePath,
365
416
  ["tunnel", "bind", opts.appId, `http://127.0.0.1:${opts.port}`],
366
- { timeout: CLI_TIMEOUT_MS },
417
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
367
418
  (err, out, errOut) => {
368
419
  if (err) { reject(new Error(`liveware tunnel bind failed: ${errOut || out || String(err)}`.trim())); return; }
369
420
  resolve(`${String(out ?? "")}\n${String(errOut ?? "")}`);
@@ -399,12 +450,13 @@ export async function startTunnelAgent(opts: {
399
450
  livewarePath: string;
400
451
  spawnFn?: SpawnLike;
401
452
  timeoutMs?: number;
453
+ env?: NodeJS.ProcessEnv;
402
454
  }): Promise<{ child: ChildProcess }> {
403
455
  const spawnFn = opts.spawnFn ?? nodeSpawn;
404
456
  const child = spawnFn(
405
457
  opts.livewarePath,
406
458
  ["agent"],
407
- { stdio: ["ignore", "pipe", "pipe"] },
459
+ { stdio: ["ignore", "pipe", "pipe"], ...(opts.env ? { env: opts.env } : {}) },
408
460
  );
409
461
  await waitForOutput(
410
462
  child,
@@ -415,17 +467,40 @@ export async function startTunnelAgent(opts: {
415
467
  return { child };
416
468
  }
417
469
 
470
+ /** Where the liveware CLI keeps its login state, relative to its `HOME`. */
471
+ const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
472
+
473
+ /**
474
+ * True when the liveware CLI has usable cached credentials under `cliHome`.
475
+ *
476
+ * Tolerates either shape the CLI might use: a credential file (non-empty) or a
477
+ * directory (non-empty). Anything unreadable answers "no" — the caller's only
478
+ * use is deciding whether it is safe to skip a login, and guessing "yes" there
479
+ * is exactly the failure this check exists to prevent.
480
+ */
481
+ export async function hasCachedLivewareCredentials(cliHome: string): Promise<boolean> {
482
+ const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
483
+ try {
484
+ const stat = await fs.promises.stat(target);
485
+ if (stat.isDirectory()) return (await fs.promises.readdir(target)).length > 0;
486
+ return stat.size > 0;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
418
492
  export async function livewareLogin(opts: {
419
493
  livewarePath: string;
420
494
  token: string;
421
495
  execFileFn?: ExecFileLike;
496
+ env?: NodeJS.ProcessEnv;
422
497
  }): Promise<void> {
423
498
  const execFileFn = opts.execFileFn ?? nodeExecFile;
424
499
  await new Promise<void>((resolve, reject) => {
425
500
  execFileFn(
426
501
  opts.livewarePath,
427
502
  ["login", "--access-token", opts.token],
428
- { timeout: CLI_TIMEOUT_MS },
503
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
429
504
  (err, stdout, stderr) => {
430
505
  if (!err) { resolve(); return; }
431
506
  const scrub = (s: string) => (opts.token ? String(s).split(opts.token).join("***") : String(s));
@@ -544,13 +619,14 @@ export async function livewareAppFindByName(opts: {
544
619
  name: string;
545
620
  execFileFn?: ExecFileLike;
546
621
  log?: LivewareLogger;
622
+ env?: NodeJS.ProcessEnv;
547
623
  }): Promise<string | null> {
548
624
  const execFileFn = opts.execFileFn ?? nodeExecFile;
549
625
  const stdout = await new Promise<string | null>((resolve) => {
550
626
  execFileFn(
551
627
  opts.livewarePath,
552
628
  ["app", "list"],
553
- { timeout: CLI_TIMEOUT_MS },
629
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
554
630
  (err, out, errOut) => {
555
631
  if (err) {
556
632
  opts.log?.debug?.(
@@ -570,13 +646,14 @@ export async function livewareAppCreate(opts: {
570
646
  livewarePath: string;
571
647
  name: string;
572
648
  execFileFn?: ExecFileLike;
649
+ env?: NodeJS.ProcessEnv;
573
650
  }): Promise<string> {
574
651
  const execFileFn = opts.execFileFn ?? nodeExecFile;
575
652
  const stdout = await new Promise<string>((resolve, reject) => {
576
653
  execFileFn(
577
654
  opts.livewarePath,
578
655
  ["app", "create", opts.name],
579
- { timeout: CLI_TIMEOUT_MS },
656
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
580
657
  (err, out, errOut) => {
581
658
  if (err) { reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim())); return; }
582
659
  resolve(String(out ?? ""));
@@ -623,6 +700,11 @@ export type LivewareSampleSupervisorDeps = {
623
700
  ref?: string;
624
701
  spawnFn?: SpawnLike;
625
702
  execFileFn?: ExecFileLike;
703
+ /** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir)`), so two
704
+ * instances on one host do not share `$HOME/.clawling`. Absent → the CLI
705
+ * inherits the ambient HOME, which is the pre-isolation behavior. */
706
+ cliHome?: string;
707
+ probePortFn?: ProbePortLike;
626
708
  log?: LivewareLogger;
627
709
  setTimeoutFn?: typeof setTimeout;
628
710
  };
@@ -769,6 +851,28 @@ export class LivewareSampleSupervisor {
769
851
  return true;
770
852
  }
771
853
 
854
+ /**
855
+ * Environment for every liveware CLI invocation in this flow. Creates the
856
+ * private HOME first: the CLI writes `<HOME>/.clawling` on login and a
857
+ * missing parent would fail the very step that isolates the credentials.
858
+ * Falls back to the ambient env when the dir cannot be created, so an
859
+ * unwritable state dir degrades to the old shared-HOME behavior instead of
860
+ * breaking the sample outright.
861
+ */
862
+ private async ensureCliEnv(): Promise<NodeJS.ProcessEnv | undefined> {
863
+ const cliHome = this.deps.cliHome?.trim();
864
+ if (!cliHome) return undefined;
865
+ try {
866
+ await fs.promises.mkdir(cliHome, { recursive: true });
867
+ } catch (err) {
868
+ this.deps.log?.warn?.(
869
+ `liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`,
870
+ );
871
+ return undefined;
872
+ }
873
+ return livewareCliEnv(cliHome);
874
+ }
875
+
772
876
  private async bootstrap(): Promise<void> {
773
877
  const { deps } = this;
774
878
  if (this.stopped) return;
@@ -792,13 +896,16 @@ export class LivewareSampleSupervisor {
792
896
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
793
897
  });
794
898
  if (this.bailIfStopped()) return;
899
+ const cliEnv = await this.ensureCliEnv();
795
900
  const server = await startSampleServer({
796
- appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
901
+ appDir,
902
+ port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
903
+ spawnFn: deps.spawnFn,
797
904
  agentUserId: deps.resolveAgentUserId?.() ?? null,
798
905
  });
799
906
  this.serverChild = server.child;
800
907
  if (this.bailIfStopped()) return;
801
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
908
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
802
909
  // Reuse an app we already own before minting another one. `app create` is not
803
910
  // idempotent and nothing here ever reconciled against the liveware side, so
804
911
  // every bootstrap that died past this point (no row was written until the very
@@ -806,13 +913,14 @@ export class LivewareSampleSupervisor {
806
913
  // app quota. Best-effort: an unreadable list falls through to `app create`.
807
914
  const existingAppId = await livewareAppFindByName({
808
915
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
916
+ env: cliEnv,
809
917
  });
810
918
  if (this.bailIfStopped()) return;
811
919
  if (existingAppId) {
812
920
  deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
813
921
  }
814
922
  const appId = existingAppId ?? await livewareAppCreate({
815
- livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
923
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
816
924
  });
817
925
  // Persist the app id BEFORE the steps that can still fail. Without this a
818
926
  // failure anywhere below left no row at all, so the next boot ran bootstrap
@@ -824,10 +932,10 @@ export class LivewareSampleSupervisor {
824
932
  publicUrl: null, sampleVersion: version, status: "pending",
825
933
  });
826
934
  const publicUrl = await tunnelBind({
827
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
935
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
828
936
  });
829
937
  if (this.bailIfStopped()) return;
830
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
938
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
831
939
  this.tunnelChild = agent.child;
832
940
  if (this.bailIfStopped()) return;
833
941
  await deps.registerApp({ name: LIVEWARE_SAMPLE_APP_NAME, appId, url: publicUrl });
@@ -877,8 +985,11 @@ export class LivewareSampleSupervisor {
877
985
  deps.log?.debug?.(`liveware-sample: download failed, reusing local copy: ${String(err)}`);
878
986
  }
879
987
  if (this.bailIfStopped()) return;
988
+ const cliEnv = await this.ensureCliEnv();
880
989
  const server = await startSampleServer({
881
- appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
990
+ appDir,
991
+ port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
992
+ spawnFn: deps.spawnFn,
882
993
  agentUserId: deps.resolveAgentUserId?.() ?? null,
883
994
  });
884
995
  this.serverChild = server.child;
@@ -891,18 +1002,36 @@ export class LivewareSampleSupervisor {
891
1002
  // lifetime. `login` is idempotent; best-effort, so a stale token still falls
892
1003
  // through to whatever credentials the CLI already has.
893
1004
  const token = deps.resolveToken();
1005
+ let loggedIn = false;
894
1006
  if (token) {
895
1007
  try {
896
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
1008
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
1009
+ loggedIn = true;
897
1010
  } catch (err) {
898
1011
  deps.log?.warn?.(
899
- `liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
1012
+ `liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`,
900
1013
  );
901
1014
  }
902
1015
  if (this.bailIfStopped()) return;
903
1016
  } else {
904
1017
  deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
905
1018
  }
1019
+ // Falling through without a fresh login is only safe when the CLI actually
1020
+ // has credentials cached. Since the private-HOME redirect those live in
1021
+ // `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
1022
+ // home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
1023
+ // there makes every tunnelBind/agent call below fail auth, which surfaces as
1024
+ // a confusing tunnel error rather than the credential problem it is, and
1025
+ // burns the whole retry ladder on a state that cannot recover on its own.
1026
+ // Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
1027
+ // a delayed sample rather than a silent auth failure.
1028
+ if (!loggedIn && cliEnv && deps.cliHome) {
1029
+ if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
1030
+ throw new Error(
1031
+ `liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`,
1032
+ );
1033
+ }
1034
+ }
906
1035
  // Kept after the re-login above so the lookup runs against a CLI that is
907
1036
  // actually authenticated: a pending row's app id came straight from
908
1037
  // `app create`'s parser and was never confirmed by the liveware side, so
@@ -912,6 +1041,7 @@ export class LivewareSampleSupervisor {
912
1041
  if (row.status === "pending") {
913
1042
  const found = await livewareAppFindByName({
914
1043
  livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
1044
+ env: cliEnv,
915
1045
  });
916
1046
  if (found && found !== appId) {
917
1047
  deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
@@ -920,10 +1050,10 @@ export class LivewareSampleSupervisor {
920
1050
  if (this.bailIfStopped()) return;
921
1051
  }
922
1052
  const publicUrl = await tunnelBind({
923
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
1053
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
924
1054
  });
925
1055
  if (this.bailIfStopped()) return;
926
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
1056
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
927
1057
  this.tunnelChild = agent.child;
928
1058
  if (this.bailIfStopped()) return;
929
1059
  // A pending row has never been registered with ClawChat (public_url is null),
package/src/runtime.ts CHANGED
@@ -19,7 +19,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
19
19
  import { buildActivationBootstrapText } from "./activation-greeting.ts";
20
20
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
21
21
  import path from "node:path";
22
- import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.ts";
22
+ import { ensureLivewareCli, livewareCliHomeDir, resolveLivewarePath } from "./liveware-cli.ts";
23
23
  import {
24
24
  LivewareSampleSupervisor,
25
25
  type LivewareSampleStoreLike,
@@ -1162,6 +1162,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1162
1162
  resolveToken: () => account.token ?? "",
1163
1163
  resolveAgentUserId: () => account.userId ?? null,
1164
1164
  resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
1165
+ // Per-instance HOME for the liveware CLI: its credentials otherwise live
1166
+ // in the host-global `$HOME/.clawling`, which a second profile clobbers.
1167
+ cliHome: livewareCliHomeDir(stateDirForSample),
1165
1168
  cliReady: livewareCliReady,
1166
1169
  listApps: async () => {
1167
1170
  const client = getConversationApiClient();
@@ -1,9 +1,9 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveStateDirFromEnv } from "./state-paths.ts";
7
7
 
8
8
  /**
9
9
  * Conversational, adapter-driven skill hot-update for the OpenClaw ClawChat
@@ -433,16 +433,14 @@ export async function managedSkillExists(managedDir: string, skillId: string): P
433
433
  }
434
434
 
435
435
  /**
436
- * Resolve OpenClaw's state directory, mirroring the host `src/utils.ts:132-154`:
437
- * `OPENCLAW_STATE_DIR` if set; else the dirname of `OPENCLAW_CONFIG_PATH` if
438
- * set; else `~/.openclaw`.
436
+ * Resolve OpenClaw's state directory: `OPENCLAW_STATE_DIR` if set; else the
437
+ * dirname of `OPENCLAW_CONFIG_PATH` if set; else `<effective home>/.openclaw`,
438
+ * where the effective home honors `OPENCLAW_HOME`. Delegates to the shared
439
+ * mirror in `./state-paths.ts` — see there for why each rule matters under
440
+ * `openclaw --profile <name>`.
439
441
  */
440
442
  export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
441
- const stateDir = env.OPENCLAW_STATE_DIR?.trim();
442
- if (stateDir) return stateDir;
443
- const configPath = env.OPENCLAW_CONFIG_PATH?.trim();
444
- if (configPath) return path.dirname(configPath);
445
- return path.join(os.homedir(), ".openclaw");
443
+ return resolveStateDirFromEnv(env);
446
444
  }
447
445
 
448
446
  /**
@@ -0,0 +1,72 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Local mirror of the OpenClaw host's state-directory resolution.
6
+ *
7
+ * WHY THIS EXISTS: most of the plugin resolves its state dir through the host
8
+ * (`openclaw/plugin-sdk/state-paths` or `runtime.state.resolveStateDir()`), and
9
+ * that is always the right answer. A few call sites cannot: the sqlite fallback
10
+ * in `storage.ts` runs when the SDK import/resolution throws, and
11
+ * `skill-update.ts` resolves the managed-skills dir without a `PluginRuntime`.
12
+ * Those sites used to guess `~/.openclaw` directly.
13
+ *
14
+ * That guess is wrong under `openclaw --profile <name>`, which isolates an
15
+ * instance by exporting `OPENCLAW_STATE_DIR=~/.openclaw-<name>` plus a matching
16
+ * `OPENCLAW_CONFIG_PATH` (host `src/cli/profile.ts:133-146`). A named profile
17
+ * that ignores those env vars silently lands on the DEFAULT profile's state dir
18
+ * — i.e. reads and writes the default profile's ClawChat database.
19
+ *
20
+ * It is also wrong about `OPENCLAW_HOME`: that variable replaces `$HOME` for
21
+ * every OpenClaw path default (host `src/infra/home-dir.ts:42-52`), it is NOT
22
+ * the state dir itself, so the correct default is `$OPENCLAW_HOME/.openclaw`.
23
+ *
24
+ * Mirrors host `src/config/state-dir.ts:28-38` + `src/infra/home-dir.ts:42-52`.
25
+ * Deliberately NOT mirrored: the legacy `~/.clawdbot` fallback (host
26
+ * `src/config/state-dir.ts:41-67`). This is a last-resort path; probing for a
27
+ * legacy directory here would be more surprising than landing on `~/.openclaw`.
28
+ */
29
+
30
+ const STATE_DIRNAME = ".openclaw";
31
+
32
+ function trimmed(value: string | undefined): string | undefined {
33
+ const text = value?.trim();
34
+ return text ? text : undefined;
35
+ }
36
+
37
+ /** Expand a leading `~` against the OS home, matching the host's behavior. */
38
+ function expandTilde(value: string, osHome: string | undefined): string {
39
+ if (value !== "~" && !value.startsWith("~/") && !value.startsWith("~\\")) return value;
40
+ if (!osHome) return value;
41
+ return path.join(osHome, value.slice(1));
42
+ }
43
+
44
+ /**
45
+ * The home OpenClaw derives its defaults from: `OPENCLAW_HOME` wins, else the
46
+ * ordinary OS home (`HOME`, then `USERPROFILE`, then `os.homedir()`).
47
+ */
48
+ export function resolveEffectiveHomeDir(
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ homedir: () => string = os.homedir,
51
+ ): string {
52
+ const osHome = trimmed(env.HOME) ?? trimmed(env.USERPROFILE) ?? trimmed(homedir());
53
+ const explicit = trimmed(env.OPENCLAW_HOME);
54
+ if (explicit) return expandTilde(explicit, osHome);
55
+ return osHome ?? homedir();
56
+ }
57
+
58
+ /**
59
+ * Resolve the state dir the way the host would, from env alone:
60
+ * `OPENCLAW_STATE_DIR`, else the dirname of `OPENCLAW_CONFIG_PATH`, else
61
+ * `<effective home>/.openclaw`.
62
+ */
63
+ export function resolveStateDirFromEnv(
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ homedir: () => string = os.homedir,
66
+ ): string {
67
+ const stateDir = trimmed(env.OPENCLAW_STATE_DIR);
68
+ if (stateDir) return expandTilde(stateDir, resolveEffectiveHomeDir(env, homedir));
69
+ const configPath = trimmed(env.OPENCLAW_CONFIG_PATH);
70
+ if (configPath) return path.dirname(expandTilde(configPath, resolveEffectiveHomeDir(env, homedir)));
71
+ return path.join(resolveEffectiveHomeDir(env, homedir), STATE_DIRNAME);
72
+ }
package/src/storage.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { DatabaseSync } from "node:sqlite";
5
4
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
+ import { resolveStateDirFromEnv } from "./state-paths.ts";
6
6
 
7
7
  type Log = { error?: (message: string) => void };
8
8
 
@@ -406,9 +406,14 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
406
406
  },
407
407
  ];
408
408
 
409
+ /**
410
+ * Only reached when the host SDK's `resolveStateDir()` throws. It must still
411
+ * honor `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH`: guessing `~/.openclaw`
412
+ * here would point a named `--profile` instance at the DEFAULT profile's
413
+ * database. See `./state-paths.ts` for the full mirror + rationale.
414
+ */
409
415
  function fallbackDbPath(): string {
410
- const home = process.env.OPENCLAW_HOME || path.join(os.homedir(), ".openclaw");
411
- return path.join(home, DB_FILENAME);
416
+ return clawChatDbPathForStateDir(resolveStateDirFromEnv());
412
417
  }
413
418
 
414
419
  export function clawChatDbPathForStateDir(stateDir: string): string {