@clawling/clawchat-plugin-openclaw 2026.9.8-1 → 2026.9.9-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,14 @@ 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
  }
340
381
  export async function livewareLogin(opts) {
341
382
  const execFileFn = opts.execFileFn ?? nodeExecFile;
342
383
  await new Promise((resolve, reject) => {
343
- execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS }, (err, stdout, stderr) => {
384
+ execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, stdout, stderr) => {
344
385
  if (!err) {
345
386
  resolve();
346
387
  return;
@@ -466,7 +507,7 @@ function findNamedIdDeep(value, name) {
466
507
  export async function livewareAppFindByName(opts) {
467
508
  const execFileFn = opts.execFileFn ?? nodeExecFile;
468
509
  const stdout = await new Promise((resolve) => {
469
- execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
510
+ execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
470
511
  if (err) {
471
512
  opts.log?.debug?.(`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`);
472
513
  resolve(null);
@@ -480,7 +521,7 @@ export async function livewareAppFindByName(opts) {
480
521
  export async function livewareAppCreate(opts) {
481
522
  const execFileFn = opts.execFileFn ?? nodeExecFile;
482
523
  const stdout = await new Promise((resolve, reject) => {
483
- execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
524
+ execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
484
525
  if (err) {
485
526
  reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim()));
486
527
  return;
@@ -629,6 +670,27 @@ export class LivewareSampleSupervisor {
629
670
  this.killChildren();
630
671
  return true;
631
672
  }
673
+ /**
674
+ * Environment for every liveware CLI invocation in this flow. Creates the
675
+ * private HOME first: the CLI writes `<HOME>/.clawling` on login and a
676
+ * missing parent would fail the very step that isolates the credentials.
677
+ * Falls back to the ambient env when the dir cannot be created, so an
678
+ * unwritable state dir degrades to the old shared-HOME behavior instead of
679
+ * breaking the sample outright.
680
+ */
681
+ async ensureCliEnv() {
682
+ const cliHome = this.deps.cliHome?.trim();
683
+ if (!cliHome)
684
+ return undefined;
685
+ try {
686
+ await fs.promises.mkdir(cliHome, { recursive: true });
687
+ }
688
+ catch (err) {
689
+ this.deps.log?.warn?.(`liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`);
690
+ return undefined;
691
+ }
692
+ return livewareCliEnv(cliHome);
693
+ }
632
694
  async bootstrap() {
633
695
  const { deps } = this;
634
696
  if (this.stopped)
@@ -656,14 +718,17 @@ export class LivewareSampleSupervisor {
656
718
  });
657
719
  if (this.bailIfStopped())
658
720
  return;
721
+ const cliEnv = await this.ensureCliEnv();
659
722
  const server = await startSampleServer({
660
- appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
723
+ appDir,
724
+ port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
725
+ spawnFn: deps.spawnFn,
661
726
  agentUserId: deps.resolveAgentUserId?.() ?? null,
662
727
  });
663
728
  this.serverChild = server.child;
664
729
  if (this.bailIfStopped())
665
730
  return;
666
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
731
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
667
732
  // Reuse an app we already own before minting another one. `app create` is not
668
733
  // idempotent and nothing here ever reconciled against the liveware side, so
669
734
  // every bootstrap that died past this point (no row was written until the very
@@ -671,6 +736,7 @@ export class LivewareSampleSupervisor {
671
736
  // app quota. Best-effort: an unreadable list falls through to `app create`.
672
737
  const existingAppId = await livewareAppFindByName({
673
738
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
739
+ env: cliEnv,
674
740
  });
675
741
  if (this.bailIfStopped())
676
742
  return;
@@ -678,7 +744,7 @@ export class LivewareSampleSupervisor {
678
744
  deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
679
745
  }
680
746
  const appId = existingAppId ?? await livewareAppCreate({
681
- livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
747
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
682
748
  });
683
749
  // Persist the app id BEFORE the steps that can still fail. Without this a
684
750
  // failure anywhere below left no row at all, so the next boot ran bootstrap
@@ -690,11 +756,11 @@ export class LivewareSampleSupervisor {
690
756
  publicUrl: null, sampleVersion: version, status: "pending",
691
757
  });
692
758
  const publicUrl = await tunnelBind({
693
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
759
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
694
760
  });
695
761
  if (this.bailIfStopped())
696
762
  return;
697
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
763
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
698
764
  this.tunnelChild = agent.child;
699
765
  if (this.bailIfStopped())
700
766
  return;
@@ -749,8 +815,11 @@ export class LivewareSampleSupervisor {
749
815
  }
750
816
  if (this.bailIfStopped())
751
817
  return;
818
+ const cliEnv = await this.ensureCliEnv();
752
819
  const server = await startSampleServer({
753
- appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
820
+ appDir,
821
+ port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
822
+ spawnFn: deps.spawnFn,
754
823
  agentUserId: deps.resolveAgentUserId?.() ?? null,
755
824
  });
756
825
  this.serverChild = server.child;
@@ -766,7 +835,7 @@ export class LivewareSampleSupervisor {
766
835
  const token = deps.resolveToken();
767
836
  if (token) {
768
837
  try {
769
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
838
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
770
839
  }
771
840
  catch (err) {
772
841
  deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`);
@@ -786,6 +855,7 @@ export class LivewareSampleSupervisor {
786
855
  if (row.status === "pending") {
787
856
  const found = await livewareAppFindByName({
788
857
  livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
858
+ env: cliEnv,
789
859
  });
790
860
  if (found && found !== appId) {
791
861
  deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
@@ -795,11 +865,11 @@ export class LivewareSampleSupervisor {
795
865
  return;
796
866
  }
797
867
  const publicUrl = await tunnelBind({
798
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
868
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
799
869
  });
800
870
  if (this.bailIfStopped())
801
871
  return;
802
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
872
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
803
873
  this.tunnelChild = agent.child;
804
874
  if (this.bailIfStopped())
805
875
  return;
@@ -37,17 +37,17 @@ export class ExistingActivationError extends Error {
37
37
  agentId;
38
38
  constructor(userId, agentId = "") {
39
39
  const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
40
- super(`this OpenClaw instance is already paired to ClawChat ${who}. ` +
41
- "Redeeming another invite code here would re-bind that code to the SAME " +
42
- "agent — no second agent is created, and this instance's credentials are " +
43
- "overwritten.\n" +
44
- " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
45
- "home:\n" +
46
- " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
47
- " - To REPLACE this instance's agent with a brand-new identity: " +
48
- "/clawchat-activate <CODE> --new-account\n" +
49
- " - To re-pair THIS agent (e.g. after losing its token): " +
50
- "/clawchat-activate <CODE> --repair");
40
+ super(`this OpenClaw instance is already paired to ClawChat ${who}, and there is ` +
41
+ "nobody here to ask which outcome you want. The invite code was NOT " +
42
+ "spent. Re-run stating the intent:\n" +
43
+ " - Pair as a BRAND-NEW agent (this instance stops using the identity " +
44
+ "above): /clawchat-activate <CODE> --new-account\n" +
45
+ " - RESTORE the identity above (re-pairs that same agent; if it was " +
46
+ "deleted this brings it back with its history): " +
47
+ "/clawchat-activate <CODE> --repair\n" +
48
+ " - To run a SECOND agent alongside this one instead, give it its own " +
49
+ "OpenClaw home:\n" +
50
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw");
51
51
  this.name = "ExistingActivationError";
52
52
  this.userId = userId;
53
53
  this.agentId = agentId;
@@ -76,6 +76,43 @@ async function promptInviteCodeFromStdin(runtime) {
76
76
  rl?.close();
77
77
  }
78
78
  }
79
+ /**
80
+ * Ask the operator which outcome they want, on the same stdin channel the
81
+ * invite-code prompt already uses.
82
+ *
83
+ * Returns `null` when nobody can answer. Both outcomes are irreversible in
84
+ * opposite directions — "new" strands the agent this instance currently holds,
85
+ * "restore" can revive an agent the owner just deleted — so an unattended run
86
+ * must not pick one. `null` refuses instead, leaving the code redeemable.
87
+ *
88
+ * A non-TTY stdin is the unattended case: a piped/closed stdin makes
89
+ * `rl.question` resolve instantly with an empty line, which would otherwise
90
+ * read as a silent answer. An unrecognized reply is also `null`: this is the
91
+ * one prompt where guessing at the operator's meaning is worse than stopping.
92
+ */
93
+ async function promptActivationIntentFromStdin(runtime, who) {
94
+ if (!process.stdin.isTTY)
95
+ return null;
96
+ runtime.log(`This OpenClaw instance already holds ClawChat ${who}.\n` +
97
+ " [1] Pair as a BRAND-NEW agent — this instance stops using that identity.\n" +
98
+ " [2] RESTORE that identity — re-pairs the same agent; if it was deleted, " +
99
+ "this brings it back with its history.\n" +
100
+ "Enter 1 or 2 (press Enter to submit):");
101
+ let rl;
102
+ try {
103
+ rl = createInterface({ input: process.stdin, output: process.stdout });
104
+ const answer = (await rl.question("> ")).trim();
105
+ if (answer === "1")
106
+ return "new-account";
107
+ if (answer === "2")
108
+ return "repair";
109
+ runtime.log(`Unrecognized choice ${JSON.stringify(answer)} — not activating.`);
110
+ return null;
111
+ }
112
+ finally {
113
+ rl?.close();
114
+ }
115
+ }
79
116
  function buildLoginConfig(cfg, result) {
80
117
  const channels = (cfg.channels ?? {});
81
118
  const existing = (channels[CHANNEL_ID] ?? {});
@@ -165,13 +202,32 @@ export async function runOpenclawClawlingLogin(params) {
165
202
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
166
203
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
167
204
  const account = resolveOpenclawClawlingAccount(cfg);
168
- // Guard before the invite code is even read, so a refused activation never
169
- // spends the code — the operator can still redeem it the right way. "Live" is
170
- // decided by whether a usable token remains: auto-logout (§C) blanks the
171
- // tokens but preserves the identity, so a logged-out instance still re-pairs
172
- // with no flag at all, which is the flow the replay exists for.
173
- if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
174
- throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
205
+ // Fork before the invite code is even read, so neither branch spends the code
206
+ // before the outcome is settled. "Live" is decided by whether a usable token
207
+ // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
208
+ // logged-out instance still re-pairs with no flag at all, which is the flow
209
+ // the replay exists for.
210
+ //
211
+ // Holding an identity is not itself an error — it just means the run is
212
+ // ambiguous, because redeeming a code here can either mint a new agent or
213
+ // re-pair the incumbent one. Ask which, and only refuse when nobody answers.
214
+ // An explicit `newAccount` / `repair` from the caller has already settled it,
215
+ // so no prompt.
216
+ let newAccount = params.newAccount;
217
+ if (account.userId.trim() && account.token.trim() && !newAccount && !params.repair) {
218
+ const identity = account.agentId.trim()
219
+ ? `agent ${account.agentId.trim()} (shadow user ${account.userId.trim()})`
220
+ : `agent ${account.userId.trim()}`;
221
+ const intent = await (params.readActivationIntent ??
222
+ (() => promptActivationIntentFromStdin(runtime, identity)))();
223
+ // Only "new-account" changes what gets sent. Restoring needs no flag:
224
+ // replaying the stored `user_id` IS the re-pair, and that is already the
225
+ // default below — so choosing it simply means "don't refuse".
226
+ if (intent === "new-account")
227
+ newAccount = true;
228
+ else if (intent === null) {
229
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
230
+ }
175
231
  }
176
232
  const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
177
233
  if (!inviteCode) {
@@ -189,7 +245,7 @@ export async function runOpenclawClawlingLogin(params) {
189
245
  let result;
190
246
  // A brand-new identity is precisely "do not replay" — the replay is the only
191
247
  // thing that would bind this code to the incumbent agent.
192
- const existingUserId = params.newAccount ? "" : account.userId.trim();
248
+ const existingUserId = newAccount ? "" : account.userId.trim();
193
249
  try {
194
250
  result = await apiClient.agentsConnect({
195
251
  code: inviteCode,
@@ -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-1",
3
+ "version": "2026.9.9-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,
@@ -419,13 +471,14 @@ export async function livewareLogin(opts: {
419
471
  livewarePath: string;
420
472
  token: string;
421
473
  execFileFn?: ExecFileLike;
474
+ env?: NodeJS.ProcessEnv;
422
475
  }): Promise<void> {
423
476
  const execFileFn = opts.execFileFn ?? nodeExecFile;
424
477
  await new Promise<void>((resolve, reject) => {
425
478
  execFileFn(
426
479
  opts.livewarePath,
427
480
  ["login", "--access-token", opts.token],
428
- { timeout: CLI_TIMEOUT_MS },
481
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
429
482
  (err, stdout, stderr) => {
430
483
  if (!err) { resolve(); return; }
431
484
  const scrub = (s: string) => (opts.token ? String(s).split(opts.token).join("***") : String(s));
@@ -544,13 +597,14 @@ export async function livewareAppFindByName(opts: {
544
597
  name: string;
545
598
  execFileFn?: ExecFileLike;
546
599
  log?: LivewareLogger;
600
+ env?: NodeJS.ProcessEnv;
547
601
  }): Promise<string | null> {
548
602
  const execFileFn = opts.execFileFn ?? nodeExecFile;
549
603
  const stdout = await new Promise<string | null>((resolve) => {
550
604
  execFileFn(
551
605
  opts.livewarePath,
552
606
  ["app", "list"],
553
- { timeout: CLI_TIMEOUT_MS },
607
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
554
608
  (err, out, errOut) => {
555
609
  if (err) {
556
610
  opts.log?.debug?.(
@@ -570,13 +624,14 @@ export async function livewareAppCreate(opts: {
570
624
  livewarePath: string;
571
625
  name: string;
572
626
  execFileFn?: ExecFileLike;
627
+ env?: NodeJS.ProcessEnv;
573
628
  }): Promise<string> {
574
629
  const execFileFn = opts.execFileFn ?? nodeExecFile;
575
630
  const stdout = await new Promise<string>((resolve, reject) => {
576
631
  execFileFn(
577
632
  opts.livewarePath,
578
633
  ["app", "create", opts.name],
579
- { timeout: CLI_TIMEOUT_MS },
634
+ { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
580
635
  (err, out, errOut) => {
581
636
  if (err) { reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim())); return; }
582
637
  resolve(String(out ?? ""));
@@ -623,6 +678,11 @@ export type LivewareSampleSupervisorDeps = {
623
678
  ref?: string;
624
679
  spawnFn?: SpawnLike;
625
680
  execFileFn?: ExecFileLike;
681
+ /** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir)`), so two
682
+ * instances on one host do not share `$HOME/.clawling`. Absent → the CLI
683
+ * inherits the ambient HOME, which is the pre-isolation behavior. */
684
+ cliHome?: string;
685
+ probePortFn?: ProbePortLike;
626
686
  log?: LivewareLogger;
627
687
  setTimeoutFn?: typeof setTimeout;
628
688
  };
@@ -769,6 +829,28 @@ export class LivewareSampleSupervisor {
769
829
  return true;
770
830
  }
771
831
 
832
+ /**
833
+ * Environment for every liveware CLI invocation in this flow. Creates the
834
+ * private HOME first: the CLI writes `<HOME>/.clawling` on login and a
835
+ * missing parent would fail the very step that isolates the credentials.
836
+ * Falls back to the ambient env when the dir cannot be created, so an
837
+ * unwritable state dir degrades to the old shared-HOME behavior instead of
838
+ * breaking the sample outright.
839
+ */
840
+ private async ensureCliEnv(): Promise<NodeJS.ProcessEnv | undefined> {
841
+ const cliHome = this.deps.cliHome?.trim();
842
+ if (!cliHome) return undefined;
843
+ try {
844
+ await fs.promises.mkdir(cliHome, { recursive: true });
845
+ } catch (err) {
846
+ this.deps.log?.warn?.(
847
+ `liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`,
848
+ );
849
+ return undefined;
850
+ }
851
+ return livewareCliEnv(cliHome);
852
+ }
853
+
772
854
  private async bootstrap(): Promise<void> {
773
855
  const { deps } = this;
774
856
  if (this.stopped) return;
@@ -792,13 +874,16 @@ export class LivewareSampleSupervisor {
792
874
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
793
875
  });
794
876
  if (this.bailIfStopped()) return;
877
+ const cliEnv = await this.ensureCliEnv();
795
878
  const server = await startSampleServer({
796
- appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
879
+ appDir,
880
+ port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
881
+ spawnFn: deps.spawnFn,
797
882
  agentUserId: deps.resolveAgentUserId?.() ?? null,
798
883
  });
799
884
  this.serverChild = server.child;
800
885
  if (this.bailIfStopped()) return;
801
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
886
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
802
887
  // Reuse an app we already own before minting another one. `app create` is not
803
888
  // idempotent and nothing here ever reconciled against the liveware side, so
804
889
  // every bootstrap that died past this point (no row was written until the very
@@ -806,13 +891,14 @@ export class LivewareSampleSupervisor {
806
891
  // app quota. Best-effort: an unreadable list falls through to `app create`.
807
892
  const existingAppId = await livewareAppFindByName({
808
893
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
894
+ env: cliEnv,
809
895
  });
810
896
  if (this.bailIfStopped()) return;
811
897
  if (existingAppId) {
812
898
  deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
813
899
  }
814
900
  const appId = existingAppId ?? await livewareAppCreate({
815
- livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
901
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
816
902
  });
817
903
  // Persist the app id BEFORE the steps that can still fail. Without this a
818
904
  // failure anywhere below left no row at all, so the next boot ran bootstrap
@@ -824,10 +910,10 @@ export class LivewareSampleSupervisor {
824
910
  publicUrl: null, sampleVersion: version, status: "pending",
825
911
  });
826
912
  const publicUrl = await tunnelBind({
827
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
913
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
828
914
  });
829
915
  if (this.bailIfStopped()) return;
830
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
916
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
831
917
  this.tunnelChild = agent.child;
832
918
  if (this.bailIfStopped()) return;
833
919
  await deps.registerApp({ name: LIVEWARE_SAMPLE_APP_NAME, appId, url: publicUrl });
@@ -877,8 +963,11 @@ export class LivewareSampleSupervisor {
877
963
  deps.log?.debug?.(`liveware-sample: download failed, reusing local copy: ${String(err)}`);
878
964
  }
879
965
  if (this.bailIfStopped()) return;
966
+ const cliEnv = await this.ensureCliEnv();
880
967
  const server = await startSampleServer({
881
- appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
968
+ appDir,
969
+ port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
970
+ spawnFn: deps.spawnFn,
882
971
  agentUserId: deps.resolveAgentUserId?.() ?? null,
883
972
  });
884
973
  this.serverChild = server.child;
@@ -893,7 +982,7 @@ export class LivewareSampleSupervisor {
893
982
  const token = deps.resolveToken();
894
983
  if (token) {
895
984
  try {
896
- await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
985
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
897
986
  } catch (err) {
898
987
  deps.log?.warn?.(
899
988
  `liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
@@ -912,6 +1001,7 @@ export class LivewareSampleSupervisor {
912
1001
  if (row.status === "pending") {
913
1002
  const found = await livewareAppFindByName({
914
1003
  livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
1004
+ env: cliEnv,
915
1005
  });
916
1006
  if (found && found !== appId) {
917
1007
  deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
@@ -920,10 +1010,10 @@ export class LivewareSampleSupervisor {
920
1010
  if (this.bailIfStopped()) return;
921
1011
  }
922
1012
  const publicUrl = await tunnelBind({
923
- livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
1013
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
924
1014
  });
925
1015
  if (this.bailIfStopped()) return;
926
- const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
1016
+ const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
927
1017
  this.tunnelChild = agent.child;
928
1018
  if (this.bailIfStopped()) return;
929
1019
  // A pending row has never been registered with ClawChat (public_url is null),
@@ -63,8 +63,20 @@ export interface LoginParams {
63
63
  newAccount?: boolean;
64
64
  /** Re-pair the agent this instance already holds (keeps its identity). */
65
65
  repair?: boolean;
66
+ /**
67
+ * Ask the operator which outcome they want when this instance already holds
68
+ * an identity and the caller stated no intent.
69
+ *
70
+ * Resolving `null` means "nobody could be asked" and activation is refused
71
+ * with `ExistingActivationError`. Defaults to
72
+ * `promptActivationIntentFromStdin`; overridden by tests.
73
+ */
74
+ readActivationIntent?: () => Promise<ActivationIntent | null>;
66
75
  }
67
76
 
77
+ /** The two outcomes available once an identity is already stored. */
78
+ export type ActivationIntent = "new-account" | "repair";
79
+
68
80
  /**
69
81
  * Thrown when redeeming an invite code would silently replace a live activation.
70
82
  *
@@ -83,17 +95,17 @@ export class ExistingActivationError extends Error {
83
95
  constructor(userId: string, agentId = "") {
84
96
  const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
85
97
  super(
86
- `this OpenClaw instance is already paired to ClawChat ${who}. ` +
87
- "Redeeming another invite code here would re-bind that code to the SAME " +
88
- "agent — no second agent is created, and this instance's credentials are " +
89
- "overwritten.\n" +
90
- " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
91
- "home:\n" +
92
- " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
93
- " - To REPLACE this instance's agent with a brand-new identity: " +
94
- "/clawchat-activate <CODE> --new-account\n" +
95
- " - To re-pair THIS agent (e.g. after losing its token): " +
96
- "/clawchat-activate <CODE> --repair",
98
+ `this OpenClaw instance is already paired to ClawChat ${who}, and there is ` +
99
+ "nobody here to ask which outcome you want. The invite code was NOT " +
100
+ "spent. Re-run stating the intent:\n" +
101
+ " - Pair as a BRAND-NEW agent (this instance stops using the identity " +
102
+ "above): /clawchat-activate <CODE> --new-account\n" +
103
+ " - RESTORE the identity above (re-pairs that same agent; if it was " +
104
+ "deleted this brings it back with its history): " +
105
+ "/clawchat-activate <CODE> --repair\n" +
106
+ " - To run a SECOND agent alongside this one instead, give it its own " +
107
+ "OpenClaw home:\n" +
108
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw",
97
109
  );
98
110
  this.name = "ExistingActivationError";
99
111
  this.userId = userId;
@@ -126,6 +138,45 @@ async function promptInviteCodeFromStdin(runtime: {
126
138
  }
127
139
  }
128
140
 
141
+ /**
142
+ * Ask the operator which outcome they want, on the same stdin channel the
143
+ * invite-code prompt already uses.
144
+ *
145
+ * Returns `null` when nobody can answer. Both outcomes are irreversible in
146
+ * opposite directions — "new" strands the agent this instance currently holds,
147
+ * "restore" can revive an agent the owner just deleted — so an unattended run
148
+ * must not pick one. `null` refuses instead, leaving the code redeemable.
149
+ *
150
+ * A non-TTY stdin is the unattended case: a piped/closed stdin makes
151
+ * `rl.question` resolve instantly with an empty line, which would otherwise
152
+ * read as a silent answer. An unrecognized reply is also `null`: this is the
153
+ * one prompt where guessing at the operator's meaning is worse than stopping.
154
+ */
155
+ async function promptActivationIntentFromStdin(
156
+ runtime: { log: (message: string) => void },
157
+ who: string,
158
+ ): Promise<ActivationIntent | null> {
159
+ if (!process.stdin.isTTY) return null;
160
+ runtime.log(
161
+ `This OpenClaw instance already holds ClawChat ${who}.\n` +
162
+ " [1] Pair as a BRAND-NEW agent — this instance stops using that identity.\n" +
163
+ " [2] RESTORE that identity — re-pairs the same agent; if it was deleted, " +
164
+ "this brings it back with its history.\n" +
165
+ "Enter 1 or 2 (press Enter to submit):",
166
+ );
167
+ let rl: ReadlineInterface | undefined;
168
+ try {
169
+ rl = createInterface({ input: process.stdin, output: process.stdout });
170
+ const answer = (await rl.question("> ")).trim();
171
+ if (answer === "1") return "new-account";
172
+ if (answer === "2") return "repair";
173
+ runtime.log(`Unrecognized choice ${JSON.stringify(answer)} — not activating.`);
174
+ return null;
175
+ } finally {
176
+ rl?.close();
177
+ }
178
+ }
179
+
129
180
  function buildLoginConfig(cfg: OpenClawConfig, result: AgentConnectResult): OpenClawConfig {
130
181
  const channels = (cfg.channels ?? {}) as Record<string, unknown>;
131
182
  const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
@@ -235,13 +286,31 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
235
286
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
236
287
  const account = resolveOpenclawClawlingAccount(cfg);
237
288
 
238
- // Guard before the invite code is even read, so a refused activation never
239
- // spends the code — the operator can still redeem it the right way. "Live" is
240
- // decided by whether a usable token remains: auto-logout (§C) blanks the
241
- // tokens but preserves the identity, so a logged-out instance still re-pairs
242
- // with no flag at all, which is the flow the replay exists for.
243
- if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
244
- throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
289
+ // Fork before the invite code is even read, so neither branch spends the code
290
+ // before the outcome is settled. "Live" is decided by whether a usable token
291
+ // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
292
+ // logged-out instance still re-pairs with no flag at all, which is the flow
293
+ // the replay exists for.
294
+ //
295
+ // Holding an identity is not itself an error — it just means the run is
296
+ // ambiguous, because redeeming a code here can either mint a new agent or
297
+ // re-pair the incumbent one. Ask which, and only refuse when nobody answers.
298
+ // An explicit `newAccount` / `repair` from the caller has already settled it,
299
+ // so no prompt.
300
+ let newAccount = params.newAccount;
301
+ if (account.userId.trim() && account.token.trim() && !newAccount && !params.repair) {
302
+ const identity = account.agentId.trim()
303
+ ? `agent ${account.agentId.trim()} (shadow user ${account.userId.trim()})`
304
+ : `agent ${account.userId.trim()}`;
305
+ const intent = await (params.readActivationIntent ??
306
+ (() => promptActivationIntentFromStdin(runtime, identity)))();
307
+ // Only "new-account" changes what gets sent. Restoring needs no flag:
308
+ // replaying the stored `user_id` IS the re-pair, and that is already the
309
+ // default below — so choosing it simply means "don't refuse".
310
+ if (intent === "new-account") newAccount = true;
311
+ else if (intent === null) {
312
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
313
+ }
245
314
  }
246
315
 
247
316
  const inviteCode = (
@@ -264,7 +333,7 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
264
333
  let result;
265
334
  // A brand-new identity is precisely "do not replay" — the replay is the only
266
335
  // thing that would bind this code to the incumbent agent.
267
- const existingUserId = params.newAccount ? "" : account.userId.trim();
336
+ const existingUserId = newAccount ? "" : account.userId.trim();
268
337
  try {
269
338
  result = await apiClient.agentsConnect({
270
339
  code: inviteCode,
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 {