@clawling/clawchat-plugin-openclaw 2026.9.8-2 → 2026.9.10-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/client.js +22 -2
- package/dist/src/liveware-cli.js +27 -0
- package/dist/src/liveware-sample.js +123 -15
- 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 +146 -16
- 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,36 @@ export function parseAgentReady(output) {
|
|
|
333
374
|
*/
|
|
334
375
|
export async function startTunnelAgent(opts) {
|
|
335
376
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
336
|
-
const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
377
|
+
const child = spawnFn(opts.livewarePath, ["agent"], { stdio: ["ignore", "pipe", "pipe"], ...(opts.env ? { env: opts.env } : {}) });
|
|
337
378
|
await waitForOutput(child, (acc) => parseAgentReady(acc), opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS, "liveware agent start");
|
|
338
379
|
return { child };
|
|
339
380
|
}
|
|
381
|
+
/** Where the liveware CLI keeps its login state, relative to its `HOME`. */
|
|
382
|
+
const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
|
|
383
|
+
/**
|
|
384
|
+
* True when the liveware CLI has usable cached credentials under `cliHome`.
|
|
385
|
+
*
|
|
386
|
+
* Tolerates either shape the CLI might use: a credential file (non-empty) or a
|
|
387
|
+
* directory (non-empty). Anything unreadable answers "no" — the caller's only
|
|
388
|
+
* use is deciding whether it is safe to skip a login, and guessing "yes" there
|
|
389
|
+
* is exactly the failure this check exists to prevent.
|
|
390
|
+
*/
|
|
391
|
+
export async function hasCachedLivewareCredentials(cliHome) {
|
|
392
|
+
const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
|
|
393
|
+
try {
|
|
394
|
+
const stat = await fs.promises.stat(target);
|
|
395
|
+
if (stat.isDirectory())
|
|
396
|
+
return (await fs.promises.readdir(target)).length > 0;
|
|
397
|
+
return stat.size > 0;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
340
403
|
export async function livewareLogin(opts) {
|
|
341
404
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
342
405
|
await new Promise((resolve, reject) => {
|
|
343
|
-
execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
406
|
+
execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, stdout, stderr) => {
|
|
344
407
|
if (!err) {
|
|
345
408
|
resolve();
|
|
346
409
|
return;
|
|
@@ -466,7 +529,7 @@ function findNamedIdDeep(value, name) {
|
|
|
466
529
|
export async function livewareAppFindByName(opts) {
|
|
467
530
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
468
531
|
const stdout = await new Promise((resolve) => {
|
|
469
|
-
execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
|
|
532
|
+
execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
|
|
470
533
|
if (err) {
|
|
471
534
|
opts.log?.debug?.(`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`);
|
|
472
535
|
resolve(null);
|
|
@@ -480,7 +543,7 @@ export async function livewareAppFindByName(opts) {
|
|
|
480
543
|
export async function livewareAppCreate(opts) {
|
|
481
544
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
482
545
|
const stdout = await new Promise((resolve, reject) => {
|
|
483
|
-
execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
|
|
546
|
+
execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) }, (err, out, errOut) => {
|
|
484
547
|
if (err) {
|
|
485
548
|
reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim()));
|
|
486
549
|
return;
|
|
@@ -629,6 +692,27 @@ export class LivewareSampleSupervisor {
|
|
|
629
692
|
this.killChildren();
|
|
630
693
|
return true;
|
|
631
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* Environment for every liveware CLI invocation in this flow. Creates the
|
|
697
|
+
* private HOME first: the CLI writes `<HOME>/.clawling` on login and a
|
|
698
|
+
* missing parent would fail the very step that isolates the credentials.
|
|
699
|
+
* Falls back to the ambient env when the dir cannot be created, so an
|
|
700
|
+
* unwritable state dir degrades to the old shared-HOME behavior instead of
|
|
701
|
+
* breaking the sample outright.
|
|
702
|
+
*/
|
|
703
|
+
async ensureCliEnv() {
|
|
704
|
+
const cliHome = this.deps.cliHome?.trim();
|
|
705
|
+
if (!cliHome)
|
|
706
|
+
return undefined;
|
|
707
|
+
try {
|
|
708
|
+
await fs.promises.mkdir(cliHome, { recursive: true });
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
this.deps.log?.warn?.(`liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`);
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
return livewareCliEnv(cliHome);
|
|
715
|
+
}
|
|
632
716
|
async bootstrap() {
|
|
633
717
|
const { deps } = this;
|
|
634
718
|
if (this.stopped)
|
|
@@ -656,14 +740,17 @@ export class LivewareSampleSupervisor {
|
|
|
656
740
|
});
|
|
657
741
|
if (this.bailIfStopped())
|
|
658
742
|
return;
|
|
743
|
+
const cliEnv = await this.ensureCliEnv();
|
|
659
744
|
const server = await startSampleServer({
|
|
660
|
-
appDir,
|
|
745
|
+
appDir,
|
|
746
|
+
port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
|
|
747
|
+
spawnFn: deps.spawnFn,
|
|
661
748
|
agentUserId: deps.resolveAgentUserId?.() ?? null,
|
|
662
749
|
});
|
|
663
750
|
this.serverChild = server.child;
|
|
664
751
|
if (this.bailIfStopped())
|
|
665
752
|
return;
|
|
666
|
-
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
753
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
|
|
667
754
|
// Reuse an app we already own before minting another one. `app create` is not
|
|
668
755
|
// idempotent and nothing here ever reconciled against the liveware side, so
|
|
669
756
|
// every bootstrap that died past this point (no row was written until the very
|
|
@@ -671,6 +758,7 @@ export class LivewareSampleSupervisor {
|
|
|
671
758
|
// app quota. Best-effort: an unreadable list falls through to `app create`.
|
|
672
759
|
const existingAppId = await livewareAppFindByName({
|
|
673
760
|
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
|
|
761
|
+
env: cliEnv,
|
|
674
762
|
});
|
|
675
763
|
if (this.bailIfStopped())
|
|
676
764
|
return;
|
|
@@ -678,7 +766,7 @@ export class LivewareSampleSupervisor {
|
|
|
678
766
|
deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
|
|
679
767
|
}
|
|
680
768
|
const appId = existingAppId ?? await livewareAppCreate({
|
|
681
|
-
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
|
|
769
|
+
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
|
|
682
770
|
});
|
|
683
771
|
// Persist the app id BEFORE the steps that can still fail. Without this a
|
|
684
772
|
// failure anywhere below left no row at all, so the next boot ran bootstrap
|
|
@@ -690,11 +778,11 @@ export class LivewareSampleSupervisor {
|
|
|
690
778
|
publicUrl: null, sampleVersion: version, status: "pending",
|
|
691
779
|
});
|
|
692
780
|
const publicUrl = await tunnelBind({
|
|
693
|
-
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
781
|
+
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
|
|
694
782
|
});
|
|
695
783
|
if (this.bailIfStopped())
|
|
696
784
|
return;
|
|
697
|
-
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
|
|
785
|
+
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
|
|
698
786
|
this.tunnelChild = agent.child;
|
|
699
787
|
if (this.bailIfStopped())
|
|
700
788
|
return;
|
|
@@ -749,8 +837,11 @@ export class LivewareSampleSupervisor {
|
|
|
749
837
|
}
|
|
750
838
|
if (this.bailIfStopped())
|
|
751
839
|
return;
|
|
840
|
+
const cliEnv = await this.ensureCliEnv();
|
|
752
841
|
const server = await startSampleServer({
|
|
753
|
-
appDir,
|
|
842
|
+
appDir,
|
|
843
|
+
port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
|
|
844
|
+
spawnFn: deps.spawnFn,
|
|
754
845
|
agentUserId: deps.resolveAgentUserId?.() ?? null,
|
|
755
846
|
});
|
|
756
847
|
this.serverChild = server.child;
|
|
@@ -764,12 +855,14 @@ export class LivewareSampleSupervisor {
|
|
|
764
855
|
// lifetime. `login` is idempotent; best-effort, so a stale token still falls
|
|
765
856
|
// through to whatever credentials the CLI already has.
|
|
766
857
|
const token = deps.resolveToken();
|
|
858
|
+
let loggedIn = false;
|
|
767
859
|
if (token) {
|
|
768
860
|
try {
|
|
769
|
-
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
861
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
|
|
862
|
+
loggedIn = true;
|
|
770
863
|
}
|
|
771
864
|
catch (err) {
|
|
772
|
-
deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing
|
|
865
|
+
deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`);
|
|
773
866
|
}
|
|
774
867
|
if (this.bailIfStopped())
|
|
775
868
|
return;
|
|
@@ -777,6 +870,20 @@ export class LivewareSampleSupervisor {
|
|
|
777
870
|
else {
|
|
778
871
|
deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
|
|
779
872
|
}
|
|
873
|
+
// Falling through without a fresh login is only safe when the CLI actually
|
|
874
|
+
// has credentials cached. Since the private-HOME redirect those live in
|
|
875
|
+
// `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
|
|
876
|
+
// home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
|
|
877
|
+
// there makes every tunnelBind/agent call below fail auth, which surfaces as
|
|
878
|
+
// a confusing tunnel error rather than the credential problem it is, and
|
|
879
|
+
// burns the whole retry ladder on a state that cannot recover on its own.
|
|
880
|
+
// Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
|
|
881
|
+
// a delayed sample rather than a silent auth failure.
|
|
882
|
+
if (!loggedIn && cliEnv && deps.cliHome) {
|
|
883
|
+
if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
|
|
884
|
+
throw new Error(`liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
780
887
|
// Kept after the re-login above so the lookup runs against a CLI that is
|
|
781
888
|
// actually authenticated: a pending row's app id came straight from
|
|
782
889
|
// `app create`'s parser and was never confirmed by the liveware side, so
|
|
@@ -786,6 +893,7 @@ export class LivewareSampleSupervisor {
|
|
|
786
893
|
if (row.status === "pending") {
|
|
787
894
|
const found = await livewareAppFindByName({
|
|
788
895
|
livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
|
|
896
|
+
env: cliEnv,
|
|
789
897
|
});
|
|
790
898
|
if (found && found !== appId) {
|
|
791
899
|
deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
|
|
@@ -795,11 +903,11 @@ export class LivewareSampleSupervisor {
|
|
|
795
903
|
return;
|
|
796
904
|
}
|
|
797
905
|
const publicUrl = await tunnelBind({
|
|
798
|
-
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
906
|
+
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
|
|
799
907
|
});
|
|
800
908
|
if (this.bailIfStopped())
|
|
801
909
|
return;
|
|
802
|
-
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
|
|
910
|
+
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
|
|
803
911
|
this.tunnelChild = agent.child;
|
|
804
912
|
if (this.bailIfStopped())
|
|
805
913
|
return;
|
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,
|
|
@@ -415,17 +467,40 @@ export async function startTunnelAgent(opts: {
|
|
|
415
467
|
return { child };
|
|
416
468
|
}
|
|
417
469
|
|
|
470
|
+
/** Where the liveware CLI keeps its login state, relative to its `HOME`. */
|
|
471
|
+
const LIVEWARE_CREDENTIAL_DIRNAME = ".clawling";
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* True when the liveware CLI has usable cached credentials under `cliHome`.
|
|
475
|
+
*
|
|
476
|
+
* Tolerates either shape the CLI might use: a credential file (non-empty) or a
|
|
477
|
+
* directory (non-empty). Anything unreadable answers "no" — the caller's only
|
|
478
|
+
* use is deciding whether it is safe to skip a login, and guessing "yes" there
|
|
479
|
+
* is exactly the failure this check exists to prevent.
|
|
480
|
+
*/
|
|
481
|
+
export async function hasCachedLivewareCredentials(cliHome: string): Promise<boolean> {
|
|
482
|
+
const target = path.join(cliHome, LIVEWARE_CREDENTIAL_DIRNAME);
|
|
483
|
+
try {
|
|
484
|
+
const stat = await fs.promises.stat(target);
|
|
485
|
+
if (stat.isDirectory()) return (await fs.promises.readdir(target)).length > 0;
|
|
486
|
+
return stat.size > 0;
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
418
492
|
export async function livewareLogin(opts: {
|
|
419
493
|
livewarePath: string;
|
|
420
494
|
token: string;
|
|
421
495
|
execFileFn?: ExecFileLike;
|
|
496
|
+
env?: NodeJS.ProcessEnv;
|
|
422
497
|
}): Promise<void> {
|
|
423
498
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
424
499
|
await new Promise<void>((resolve, reject) => {
|
|
425
500
|
execFileFn(
|
|
426
501
|
opts.livewarePath,
|
|
427
502
|
["login", "--access-token", opts.token],
|
|
428
|
-
{ timeout: CLI_TIMEOUT_MS },
|
|
503
|
+
{ timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
|
|
429
504
|
(err, stdout, stderr) => {
|
|
430
505
|
if (!err) { resolve(); return; }
|
|
431
506
|
const scrub = (s: string) => (opts.token ? String(s).split(opts.token).join("***") : String(s));
|
|
@@ -544,13 +619,14 @@ export async function livewareAppFindByName(opts: {
|
|
|
544
619
|
name: string;
|
|
545
620
|
execFileFn?: ExecFileLike;
|
|
546
621
|
log?: LivewareLogger;
|
|
622
|
+
env?: NodeJS.ProcessEnv;
|
|
547
623
|
}): Promise<string | null> {
|
|
548
624
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
549
625
|
const stdout = await new Promise<string | null>((resolve) => {
|
|
550
626
|
execFileFn(
|
|
551
627
|
opts.livewarePath,
|
|
552
628
|
["app", "list"],
|
|
553
|
-
{ timeout: CLI_TIMEOUT_MS },
|
|
629
|
+
{ timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
|
|
554
630
|
(err, out, errOut) => {
|
|
555
631
|
if (err) {
|
|
556
632
|
opts.log?.debug?.(
|
|
@@ -570,13 +646,14 @@ export async function livewareAppCreate(opts: {
|
|
|
570
646
|
livewarePath: string;
|
|
571
647
|
name: string;
|
|
572
648
|
execFileFn?: ExecFileLike;
|
|
649
|
+
env?: NodeJS.ProcessEnv;
|
|
573
650
|
}): Promise<string> {
|
|
574
651
|
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
575
652
|
const stdout = await new Promise<string>((resolve, reject) => {
|
|
576
653
|
execFileFn(
|
|
577
654
|
opts.livewarePath,
|
|
578
655
|
["app", "create", opts.name],
|
|
579
|
-
{ timeout: CLI_TIMEOUT_MS },
|
|
656
|
+
{ timeout: CLI_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}) },
|
|
580
657
|
(err, out, errOut) => {
|
|
581
658
|
if (err) { reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim())); return; }
|
|
582
659
|
resolve(String(out ?? ""));
|
|
@@ -623,6 +700,11 @@ export type LivewareSampleSupervisorDeps = {
|
|
|
623
700
|
ref?: string;
|
|
624
701
|
spawnFn?: SpawnLike;
|
|
625
702
|
execFileFn?: ExecFileLike;
|
|
703
|
+
/** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir)`), so two
|
|
704
|
+
* instances on one host do not share `$HOME/.clawling`. Absent → the CLI
|
|
705
|
+
* inherits the ambient HOME, which is the pre-isolation behavior. */
|
|
706
|
+
cliHome?: string;
|
|
707
|
+
probePortFn?: ProbePortLike;
|
|
626
708
|
log?: LivewareLogger;
|
|
627
709
|
setTimeoutFn?: typeof setTimeout;
|
|
628
710
|
};
|
|
@@ -769,6 +851,28 @@ export class LivewareSampleSupervisor {
|
|
|
769
851
|
return true;
|
|
770
852
|
}
|
|
771
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Environment for every liveware CLI invocation in this flow. Creates the
|
|
856
|
+
* private HOME first: the CLI writes `<HOME>/.clawling` on login and a
|
|
857
|
+
* missing parent would fail the very step that isolates the credentials.
|
|
858
|
+
* Falls back to the ambient env when the dir cannot be created, so an
|
|
859
|
+
* unwritable state dir degrades to the old shared-HOME behavior instead of
|
|
860
|
+
* breaking the sample outright.
|
|
861
|
+
*/
|
|
862
|
+
private async ensureCliEnv(): Promise<NodeJS.ProcessEnv | undefined> {
|
|
863
|
+
const cliHome = this.deps.cliHome?.trim();
|
|
864
|
+
if (!cliHome) return undefined;
|
|
865
|
+
try {
|
|
866
|
+
await fs.promises.mkdir(cliHome, { recursive: true });
|
|
867
|
+
} catch (err) {
|
|
868
|
+
this.deps.log?.warn?.(
|
|
869
|
+
`liveware-sample: cannot create private CLI home ${cliHome}; using ambient HOME: ${String(err)}`,
|
|
870
|
+
);
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
return livewareCliEnv(cliHome);
|
|
874
|
+
}
|
|
875
|
+
|
|
772
876
|
private async bootstrap(): Promise<void> {
|
|
773
877
|
const { deps } = this;
|
|
774
878
|
if (this.stopped) return;
|
|
@@ -792,13 +896,16 @@ export class LivewareSampleSupervisor {
|
|
|
792
896
|
fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
|
|
793
897
|
});
|
|
794
898
|
if (this.bailIfStopped()) return;
|
|
899
|
+
const cliEnv = await this.ensureCliEnv();
|
|
795
900
|
const server = await startSampleServer({
|
|
796
|
-
appDir,
|
|
901
|
+
appDir,
|
|
902
|
+
port: await resolveSamplePort(DEFAULT_SAMPLE_PORT, deps.probePortFn),
|
|
903
|
+
spawnFn: deps.spawnFn,
|
|
797
904
|
agentUserId: deps.resolveAgentUserId?.() ?? null,
|
|
798
905
|
});
|
|
799
906
|
this.serverChild = server.child;
|
|
800
907
|
if (this.bailIfStopped()) return;
|
|
801
|
-
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
908
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
|
|
802
909
|
// Reuse an app we already own before minting another one. `app create` is not
|
|
803
910
|
// idempotent and nothing here ever reconciled against the liveware side, so
|
|
804
911
|
// every bootstrap that died past this point (no row was written until the very
|
|
@@ -806,13 +913,14 @@ export class LivewareSampleSupervisor {
|
|
|
806
913
|
// app quota. Best-effort: an unreadable list falls through to `app create`.
|
|
807
914
|
const existingAppId = await livewareAppFindByName({
|
|
808
915
|
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
|
|
916
|
+
env: cliEnv,
|
|
809
917
|
});
|
|
810
918
|
if (this.bailIfStopped()) return;
|
|
811
919
|
if (existingAppId) {
|
|
812
920
|
deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
|
|
813
921
|
}
|
|
814
922
|
const appId = existingAppId ?? await livewareAppCreate({
|
|
815
|
-
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
|
|
923
|
+
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, env: cliEnv,
|
|
816
924
|
});
|
|
817
925
|
// Persist the app id BEFORE the steps that can still fail. Without this a
|
|
818
926
|
// failure anywhere below left no row at all, so the next boot ran bootstrap
|
|
@@ -824,10 +932,10 @@ export class LivewareSampleSupervisor {
|
|
|
824
932
|
publicUrl: null, sampleVersion: version, status: "pending",
|
|
825
933
|
});
|
|
826
934
|
const publicUrl = await tunnelBind({
|
|
827
|
-
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
935
|
+
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
|
|
828
936
|
});
|
|
829
937
|
if (this.bailIfStopped()) return;
|
|
830
|
-
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
|
|
938
|
+
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
|
|
831
939
|
this.tunnelChild = agent.child;
|
|
832
940
|
if (this.bailIfStopped()) return;
|
|
833
941
|
await deps.registerApp({ name: LIVEWARE_SAMPLE_APP_NAME, appId, url: publicUrl });
|
|
@@ -877,8 +985,11 @@ export class LivewareSampleSupervisor {
|
|
|
877
985
|
deps.log?.debug?.(`liveware-sample: download failed, reusing local copy: ${String(err)}`);
|
|
878
986
|
}
|
|
879
987
|
if (this.bailIfStopped()) return;
|
|
988
|
+
const cliEnv = await this.ensureCliEnv();
|
|
880
989
|
const server = await startSampleServer({
|
|
881
|
-
appDir,
|
|
990
|
+
appDir,
|
|
991
|
+
port: await resolveSamplePort(row.port || DEFAULT_SAMPLE_PORT, deps.probePortFn),
|
|
992
|
+
spawnFn: deps.spawnFn,
|
|
882
993
|
agentUserId: deps.resolveAgentUserId?.() ?? null,
|
|
883
994
|
});
|
|
884
995
|
this.serverChild = server.child;
|
|
@@ -891,18 +1002,36 @@ export class LivewareSampleSupervisor {
|
|
|
891
1002
|
// lifetime. `login` is idempotent; best-effort, so a stale token still falls
|
|
892
1003
|
// through to whatever credentials the CLI already has.
|
|
893
1004
|
const token = deps.resolveToken();
|
|
1005
|
+
let loggedIn = false;
|
|
894
1006
|
if (token) {
|
|
895
1007
|
try {
|
|
896
|
-
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
1008
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn, env: cliEnv });
|
|
1009
|
+
loggedIn = true;
|
|
897
1010
|
} catch (err) {
|
|
898
1011
|
deps.log?.warn?.(
|
|
899
|
-
`liveware-sample: relaunch re-login failed; continuing
|
|
1012
|
+
`liveware-sample: relaunch re-login failed; continuing only if the CLI has cached credentials: ${String(err)}`,
|
|
900
1013
|
);
|
|
901
1014
|
}
|
|
902
1015
|
if (this.bailIfStopped()) return;
|
|
903
1016
|
} else {
|
|
904
1017
|
deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
|
|
905
1018
|
}
|
|
1019
|
+
// Falling through without a fresh login is only safe when the CLI actually
|
|
1020
|
+
// has credentials cached. Since the private-HOME redirect those live in
|
|
1021
|
+
// `<cliHome>/.clawling`, and a first relaunch after upgrading finds that
|
|
1022
|
+
// home EMPTY — the old ones are still under the ambient `$HOME`. Continuing
|
|
1023
|
+
// there makes every tunnelBind/agent call below fail auth, which surfaces as
|
|
1024
|
+
// a confusing tunnel error rather than the credential problem it is, and
|
|
1025
|
+
// burns the whole retry ladder on a state that cannot recover on its own.
|
|
1026
|
+
// Throwing hands it to `startAttempt`'s ladder instead, so the worst case is
|
|
1027
|
+
// a delayed sample rather than a silent auth failure.
|
|
1028
|
+
if (!loggedIn && cliEnv && deps.cliHome) {
|
|
1029
|
+
if (!(await hasCachedLivewareCredentials(deps.cliHome))) {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`liveware CLI has no cached credentials under ${deps.cliHome} and no token was available to log in`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
906
1035
|
// Kept after the re-login above so the lookup runs against a CLI that is
|
|
907
1036
|
// actually authenticated: a pending row's app id came straight from
|
|
908
1037
|
// `app create`'s parser and was never confirmed by the liveware side, so
|
|
@@ -912,6 +1041,7 @@ export class LivewareSampleSupervisor {
|
|
|
912
1041
|
if (row.status === "pending") {
|
|
913
1042
|
const found = await livewareAppFindByName({
|
|
914
1043
|
livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
|
|
1044
|
+
env: cliEnv,
|
|
915
1045
|
});
|
|
916
1046
|
if (found && found !== appId) {
|
|
917
1047
|
deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
|
|
@@ -920,10 +1050,10 @@ export class LivewareSampleSupervisor {
|
|
|
920
1050
|
if (this.bailIfStopped()) return;
|
|
921
1051
|
}
|
|
922
1052
|
const publicUrl = await tunnelBind({
|
|
923
|
-
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
1053
|
+
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn, env: cliEnv,
|
|
924
1054
|
});
|
|
925
1055
|
if (this.bailIfStopped()) return;
|
|
926
|
-
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
|
|
1056
|
+
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn, env: cliEnv });
|
|
927
1057
|
this.tunnelChild = agent.child;
|
|
928
1058
|
if (this.bailIfStopped()) return;
|
|
929
1059
|
// A pending row has never been registered with ClawChat (public_url is null),
|
package/src/runtime.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
|
19
19
|
import { buildActivationBootstrapText } from "./activation-greeting.ts";
|
|
20
20
|
import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
|
|
21
21
|
import path from "node:path";
|
|
22
|
-
import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.ts";
|
|
22
|
+
import { ensureLivewareCli, livewareCliHomeDir, resolveLivewarePath } from "./liveware-cli.ts";
|
|
23
23
|
import {
|
|
24
24
|
LivewareSampleSupervisor,
|
|
25
25
|
type LivewareSampleStoreLike,
|
|
@@ -1162,6 +1162,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1162
1162
|
resolveToken: () => account.token ?? "",
|
|
1163
1163
|
resolveAgentUserId: () => account.userId ?? null,
|
|
1164
1164
|
resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
|
|
1165
|
+
// Per-instance HOME for the liveware CLI: its credentials otherwise live
|
|
1166
|
+
// in the host-global `$HOME/.clawling`, which a second profile clobbers.
|
|
1167
|
+
cliHome: livewareCliHomeDir(stateDirForSample),
|
|
1165
1168
|
cliReady: livewareCliReady,
|
|
1166
1169
|
listApps: async () => {
|
|
1167
1170
|
const client = getConversationApiClient();
|
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 {
|