@clawling/clawchat-plugin-openclaw 2026.9.8-2 → 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.
- package/dist/src/client.js +22 -2
- package/dist/src/liveware-cli.js +27 -0
- package/dist/src/liveware-sample.js +84 -14
- package/dist/src/runtime.js +4 -1
- package/dist/src/skill-update.js +7 -11
- package/dist/src/state-paths.js +65 -0
- package/dist/src/storage.js +8 -3
- package/package.json +1 -1
- package/src/client.ts +27 -2
- package/src/liveware-cli.ts +32 -0
- package/src/liveware-sample.ts +105 -15
- package/src/runtime.ts +4 -1
- package/src/skill-update.ts +7 -9
- package/src/state-paths.ts +72 -0
- package/src/storage.ts +8 -3
package/dist/src/client.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
}
|
package/dist/src/liveware-cli.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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;
|
package/dist/src/runtime.js
CHANGED
|
@@ -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();
|
package/dist/src/skill-update.js
CHANGED
|
@@ -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
|
|
333
|
-
* `
|
|
334
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/storage.js
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.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
|
-
|
|
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
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
|
-
|
|
30
|
-
|
|
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
|
}
|
package/src/liveware-cli.ts
CHANGED
|
@@ -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";
|
package/src/liveware-sample.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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),
|
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();
|
package/src/skill-update.ts
CHANGED
|
@@ -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
|
|
437
|
-
* `
|
|
438
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
return path.join(home, DB_FILENAME);
|
|
416
|
+
return clawChatDbPathForStateDir(resolveStateDirFromEnv());
|
|
412
417
|
}
|
|
413
418
|
|
|
414
419
|
export function clawChatDbPathForStateDir(stateDir: string): string {
|