Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,18 @@ RUN set -eu; \
# symlinks resolve; the real security gates (realpath + isPathInside \
# containment) remain intact — a symlink escaping the base tree is still caught. \
# Scoped to install-safe-path + install-package-dir only. \
isp_file="$(grep -RIlE --include='*.js' 'const baseLstat = await fs\.lstat\(baseDir\)' "$OC_DIST/install-safe-path-"*.js)"; \
isp_file="$(grep -RIlE --include='*.js' 'const baseLstat = await fs\.(lstat|stat)\(baseDir\)' "$OC_DIST/install-safe-path-"*.js || true)"; \
test -n "$isp_file" || { echo "ERROR: install-safe-path baseLstat pattern not found" >&2; exit 1; }; \
sed -i 's/const baseLstat = await fs\.lstat(baseDir)/const baseLstat = await fs.stat(baseDir)/' "$isp_file"; \
if grep -q 'const baseLstat = await fs\.lstat(baseDir)' "$isp_file"; then echo "ERROR: Patch 3a (install-safe-path) left baseLstat lstat call" >&2; exit 1; fi; \
ipd_file="$(grep -RIlE --include='*.js' 'assertInstallBaseStable' "$OC_DIST/install-package-dir-"*.js)"; \
if ! grep -q 'const baseLstat = await fs\.stat(baseDir)' "$isp_file"; then echo "ERROR: Patch 3a (install-safe-path) did not find patched baseLstat stat call" >&2; exit 1; fi; \
ipd_file="$(grep -RIlE --include='*.js' 'assertInstallBaseStable' "$OC_DIST/install-package-dir-"*.js || true)"; \
test -n "$ipd_file" || { echo "ERROR: install-package-dir assertInstallBaseStable not found" >&2; exit 1; }; \
sed -i 's/const baseLstat = await fs\.lstat(params\.installBaseDir)/const baseLstat = await fs.stat(params.installBaseDir)/' "$ipd_file"; \
sed -i 's/baseLstat\.isSymbolicLink()/false \/* nemoclaw: symlink check disabled, realpath guards containment *\//' "$ipd_file"; \
if grep -q 'fs\.lstat(params\.installBaseDir)' "$ipd_file"; then echo "ERROR: Patch 3b (install-package-dir) left lstat in assertInstallBaseStable" >&2; exit 1; fi; \
if ! grep -q 'const baseLstat = await fs\.stat(params\.installBaseDir)' "$ipd_file" && ! grep -q 'await fs\.stat(params\.installBaseDir)).isDirectory()' "$ipd_file"; then echo "ERROR: Patch 3b (install-package-dir) did not find patched/safe installBaseDir stat call" >&2; exit 1; fi; \
if grep -q 'baseLstat\.isSymbolicLink()' "$ipd_file"; then echo "ERROR: Patch 3b (install-package-dir) left baseLstat symlink check" >&2; exit 1; fi; \
# --- Patch 5: bump default WS handshake timeout 10s -> 60s (#2484) --- \
# OpenClaw's WS connect handshake has a hard-coded 10s timeout on both \
# client and server. Server-side connect-handler processing can exceed \
Expand All @@ -219,10 +222,11 @@ RUN set -eu; \
# \
# Removal criteria: drop when openclaw fixes the underlying connect \
# latency, or exposes the timeout as an unbounded env override. \
hto_files="$(grep -RIlE --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4' "$OC_DIST")"; \
hto_files="$(grep -RIlE --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3|6e4)' "$OC_DIST" || true)"; \
test -n "$hto_files" || { echo "ERROR: handshake-timeout constant not found" >&2; exit 1; }; \
printf '%s\n' "$hto_files" | xargs sed -i -E 's|DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4|DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4|g'; \
if grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4' "$OC_DIST"; then echo "ERROR: Patch 5 left a 1e4 constant" >&2; exit 1; fi
printf '%s\n' "$hto_files" | xargs sed -i -E 's#DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)#DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4#g'; \
if grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)' "$OC_DIST"; then echo "ERROR: Patch 5 left a short handshake-timeout constant" >&2; exit 1; fi; \
if ! grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4' "$OC_DIST"; then echo "ERROR: Patch 5 did not find patched 6e4 constant" >&2; exit 1; fi

# Patch OpenClaw's pinned 2026.4.24 compiled selection runtime to expose a
# compact searchable tool catalog to the model while preserving the full
Expand Down
51 changes: 39 additions & 12 deletions scripts/seed-wechat-accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
# Files written (matching auth/accounts.ts in @tencent-weixin/openclaw-weixin@2.4.2):
# <stateDir>/openclaw-weixin/accounts.json — JSON array of accountIds
# <stateDir>/openclaw-weixin/accounts/<accountId>.json — { token, savedAt, baseUrl, userId }
# <stateDir>/openclaw.json (channels.openclaw-weixin) — registered channel + accounts.<id>.enabled
# <stateDir>/openclaw.json (plugins.load.paths + channels.openclaw-weixin)
# — registered plugin/channel + accounts.<id>.enabled
#
# The third file is the one OpenClaw consults at startup to know the channel
# is registered. Without channels.openclaw-weixin.accounts.<id>.enabled=true
# in openclaw.json, the plugin's auth/accounts.ts considers the account
# disabled and the bridge won't start, even if the per-account state files
# above exist. The patch also restores the openclaw-weixin plugin registry
# entry because later OpenClaw config rewrites can drop it while leaving the
# pre-installed extension files in place.
# above exist. The patch also restores the openclaw-weixin plugin registry and
# load path because later OpenClaw config rewrites can drop them while leaving
# the pre-installed extension files in place.
#
# State dir resolution mirrors the upstream's resolveStateDir():
# $OPENCLAW_STATE_DIR || $CLAWDBOT_STATE_DIR || ~/.openclaw
Expand Down Expand Up @@ -56,10 +57,6 @@

WECHAT_PLUGIN_ID = "openclaw-weixin"
WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.2"
WECHAT_PLUGIN_INSTALL = {
"source": "npm",
"spec": WECHAT_PLUGIN_SPEC,
}
WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"


Expand Down Expand Up @@ -88,6 +85,14 @@ def _state_dir() -> pathlib.Path:
return pathlib.Path(raw.strip()).resolve()


def _wechat_plugin_install_path(install_record: object | None = None) -> str:
if isinstance(install_record, dict):
install_path = install_record.get("installPath")
if isinstance(install_path, str) and install_path.strip():
return install_path.strip()
return str(_state_dir() / "extensions" / WECHAT_PLUGIN_ID)


def _decode_config() -> dict:
raw = os.environ.get("NEMOCLAW_WECHAT_CONFIG_B64", "e30=") or "e30="
try:
Expand Down Expand Up @@ -157,12 +162,34 @@ def _patch_openclaw_config(account_id: str) -> None:
installs = {}
plugins["installs"] = installs
wechat_install = installs.get(WECHAT_PLUGIN_ID)
if not isinstance(wechat_install, dict):
wechat_install = {}
wechat_install_path = _wechat_plugin_install_path(wechat_install)
if wechat_install.get("source") != "npm":
wechat_install["source"] = "npm"
if not isinstance(wechat_install.get("spec"), str) or not wechat_install["spec"].strip():
wechat_install["spec"] = WECHAT_PLUGIN_SPEC
if (
not isinstance(wechat_install, dict)
or wechat_install.get("source") != WECHAT_PLUGIN_INSTALL["source"]
or not wechat_install.get("spec")
not isinstance(wechat_install.get("installPath"), str)
or not wechat_install["installPath"].strip()
):
installs[WECHAT_PLUGIN_ID] = dict(WECHAT_PLUGIN_INSTALL)
wechat_install["installPath"] = wechat_install_path
installs[WECHAT_PLUGIN_ID] = wechat_install

load = plugins.setdefault("load", {})
if not isinstance(load, dict):
load = {}
plugins["load"] = load
load_paths = load.get("paths")
normalized_paths = (
[item.strip() for item in load_paths if isinstance(item, str) and item.strip()]
if isinstance(load_paths, list)
else []
)
if wechat_install_path not in normalized_paths:
normalized_paths.append(wechat_install_path)
load["paths"] = normalized_paths

entries = plugins.setdefault("entries", {})
if not isinstance(entries, dict):
entries = {}
Expand Down
20 changes: 18 additions & 2 deletions test/e2e/scenario-framework-tests/e2e-lib-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,14 @@ describe("baseline onboarding validation helper", () => {
const ctx = path.join(tmp, "ctx");
fs.mkdirSync(bin); fs.mkdirSync(ctx);
fs.writeFileSync(path.join(ctx, "context.env"), "E2E_SANDBOX_NAME=sb1\nE2E_PROVIDER=nvidia\nE2E_INFERENCE_ROUTE=inference-local\n");
fs.writeFileSync(path.join(bin, "nemoclaw"), "#!/usr/bin/env bash\n[[ $1 == --help ]] && echo help && exit 0\n", { mode: 0o755 });
fs.writeFileSync(path.join(bin, "nemoclaw"), `#!/usr/bin/env bash
case "$*" in
--help) echo help;;
"sb1 status") echo 'status running gateway healthy sandbox running';;
"sb1 logs") echo baseline-log;;
*) echo "unexpected nemoclaw args: $*" >&2; exit 64;;
esac
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 });
const r = runBash(`
set -euo pipefail
Expand All @@ -662,11 +669,15 @@ describe("baseline onboarding validation helper", () => {
baseline_assert_nemoclaw_on_path
baseline_assert_openshell_on_path
baseline_assert_nemoclaw_help_exits_zero
baseline_assert_sandbox_status_exits_zero
baseline_assert_logs_produce_output
`, { E2E_CONTEXT_DIR: ctx, PATH: `${bin}:${process.env.PATH}` });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain("PASS: validation.baseline_onboarding.nemoclaw_on_path");
expect(r.stdout).toContain("PASS: validation.baseline_onboarding.openshell_on_path");
expect(r.stdout).toContain("PASS: validation.baseline_onboarding.nemoclaw_help_exits_zero");
expect(r.stdout).toContain("PASS: validation.baseline_onboarding.sandbox_status");
expect(r.stdout).toContain("PASS: validation.baseline_onboarding.logs_available");
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
});
});
Expand Down Expand Up @@ -705,7 +716,12 @@ describe("sandbox lifecycle validation helper", () => {
try {
const bin = path.join(tmp, "bin"); fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, "nemoclaw"), `#!/usr/bin/env bash
case "$1" in list) echo sb1;; status) echo 'status running gateway healthy sandbox running';; logs) echo logline;; esac
case "$*" in
list) echo sb1;;
"sb1 status") echo 'status running gateway healthy sandbox running';;
"sb1 logs") echo logline;;
*) echo "unexpected nemoclaw args: $*" >&2; exit 64;;
esac
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, "openshell"), `#!/usr/bin/env bash
echo lifecycle-ok
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/validation_suites/lib/baseline_onboarding.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ baseline_assert_sandbox_list_contains_context_sandbox() {

baseline_assert_sandbox_status_exits_zero() {
local out
if out=$(nemoclaw status "$E2E_SANDBOX_NAME" 2>&1); then
if out=$(nemoclaw "$E2E_SANDBOX_NAME" status 2>&1); then
baseline_onboarding_pass validation.baseline_onboarding.sandbox_status "$E2E_SANDBOX_NAME status ok"
else
baseline_onboarding_fail validation.baseline_onboarding.sandbox_status "status failed: ${out:0:200}"
Expand All @@ -62,10 +62,10 @@ baseline_assert_sandbox_status_exits_zero() {

baseline_assert_logs_produce_output() {
local out
if out=$(nemoclaw logs "$E2E_SANDBOX_NAME" 2>&1) && [[ -n "$out" ]]; then
if out=$(nemoclaw "$E2E_SANDBOX_NAME" logs 2>&1) && [[ -n "$out" ]]; then
baseline_onboarding_pass validation.baseline_onboarding.logs_available "logs available"
else
baseline_onboarding_fail validation.baseline_onboarding.logs_available "logs unavailable"
baseline_onboarding_fail validation.baseline_onboarding.logs_available "logs unavailable: ${out:0:200}"
fi
}

Expand Down
4 changes: 2 additions & 2 deletions test/e2e/validation_suites/lib/sandbox_lifecycle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ sandbox_lifecycle_assert_nemoclaw_list_contains_sandbox() {

sandbox_lifecycle_assert_status_fields_present() {
local id="validation.sandbox_operations.status_fields_present"
sandbox_lifecycle_run_with_timeout 20 nemoclaw status "${E2E_SANDBOX_NAME}" >/dev/null || {
sandbox_lifecycle_run_with_timeout 20 nemoclaw "${E2E_SANDBOX_NAME}" status >/dev/null || {
sandbox_lifecycle_fail "${id}" "nemoclaw status failed"
return 1
}
Expand All @@ -90,7 +90,7 @@ sandbox_lifecycle_assert_status_fields_present() {

sandbox_lifecycle_assert_logs_available() {
local id="validation.sandbox_operations.logs_available"
sandbox_lifecycle_run_with_timeout 20 nemoclaw logs "${E2E_SANDBOX_NAME}" >/dev/null || {
sandbox_lifecycle_run_with_timeout 20 nemoclaw "${E2E_SANDBOX_NAME}" logs >/dev/null || {
sandbox_lifecycle_fail "${id}" "nemoclaw logs failed"
return 1
}
Expand Down
10 changes: 9 additions & 1 deletion test/generate-openclaw-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ function runConfigScript(envOverrides: Record<string, string> = {}): any {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
}

function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) {
return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin");
}

function writeRegistryManifest(
blueprintDir: string,
relativeManifestPath: string,
Expand Down Expand Up @@ -284,7 +288,11 @@ describe("generate-openclaw-config.py: config generation", () => {
NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig,
});

expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual(installEntry);
expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({
...installEntry,
installPath: wechatExtensionPath(),
});
expect(config.plugins?.load?.paths).toEqual([wechatExtensionPath()]);
expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({
enabled: true,
});
Expand Down
40 changes: 40 additions & 0 deletions test/seed-wechat-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ function writeOpenclawConfig(extra: Record<string, unknown> = {}) {
return cfgPath;
}

function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) {
return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin");
}

function readJson(p: string): any {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
Expand Down Expand Up @@ -257,12 +261,48 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei
expect(cfg.plugins.installs["openclaw-weixin"]).toEqual({
source: "npm",
spec: "@tencent-weixin/openclaw-weixin@2.4.2",
installPath: wechatExtensionPath(),
});
expect(cfg.plugins.load.paths).toEqual([wechatExtensionPath()]);
expect(cfg.plugins.entries["openclaw-weixin"].enabled).toBe(true);
expect(Object.keys(cfg.channels)).toEqual(["telegram", "slack", "openclaw-weixin"]);
expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true);
});

it("preserves existing plugin load paths and appends the WeChat extension path", () => {
writeOpenclawConfig({
plugins: {
load: { paths: ["/opt/custom-openclaw-plugin"] },
installs: {
"openclaw-weixin": {
source: "npm",
spec: "@tencent-weixin/openclaw-weixin@2.4.2",
installPath: "/already/installed/openclaw-weixin",
pinned: true,
},
},
},
});

const result = runSeed({
NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }),
});
expect(result.status).toBe(0);

const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json"));
expect(cfg.plugins.installs["openclaw-weixin"]).toEqual({
source: "npm",
spec: "@tencent-weixin/openclaw-weixin@2.4.2",
installPath: "/already/installed/openclaw-weixin",
pinned: true,
});
expect(cfg.plugins.load.paths).toEqual([
"/opt/custom-openclaw-plugin",
"/already/installed/openclaw-weixin",
]);
expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true);
});

it("bails (and warns) when openclaw.json is missing — does not invent a config", () => {
// generate-openclaw-config.py runs first and is responsible for producing
// openclaw.json. If it failed silently, we'd rather print a warning than
Expand Down
Loading