From a971188a6d0fa66514e7278ae9fdabc7571c7ead Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 00:23:36 -0700 Subject: [PATCH] build(client): make @openclaw/gateway-client publishable to npm (#111707) --- .github/workflows/openclaw-npm-release.yml | 55 ++++++- packages/gateway-client/CHANGELOG.md | 7 + packages/gateway-client/LICENSE | 21 +++ packages/gateway-client/README.md | 134 ++++++++++++++++++ packages/gateway-client/npm-shrinkwrap.json | 50 +++++++ packages/gateway-client/package.json | 43 +++++- pnpm-lock.yaml | 9 +- scripts/release-check.ts | 17 ++- scripts/sync-plugin-versions.ts | 6 +- test/scripts/generate-npm-shrinkwrap.test.ts | 10 ++ ...nclaw-npm-extended-stable-workflow.test.ts | 17 +++ .../package-acceptance-workflow.test.ts | 6 +- test/scripts/release-check.test.ts | 22 ++- tsdown.config.ts | 2 +- 14 files changed, 374 insertions(+), 25 deletions(-) create mode 100644 packages/gateway-client/CHANGELOG.md create mode 100644 packages/gateway-client/LICENSE create mode 100644 packages/gateway-client/README.md create mode 100644 packages/gateway-client/npm-shrinkwrap.json diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 8fd7c2ad36a..fcbcb1c6adb 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -204,7 +204,7 @@ jobs: - name: Pack publishable core packages id: core_package_tarballs env: - CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol + CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol packages/gateway-client run: | set -euo pipefail PACKAGE_VERSION="$(node -p "require('./package.json').version")" @@ -386,6 +386,7 @@ jobs: set -euo pipefail AI_TARBALL_PATH="" GATEWAY_PROTOCOL_TARBALL_PATH="" + GATEWAY_CLIENT_TARBALL_PATH="" if [[ -n "$CORE_PACKAGE_TARBALL_DIR" ]]; then mapfile -t AI_TARBALLS < <(find "$CORE_PACKAGE_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort) if node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then @@ -410,6 +411,17 @@ jobs: echo "Frozen target without a publishable @openclaw/gateway-protocol package contains unexpected artifacts." >&2 exit 1 fi + mapfile -t GATEWAY_CLIENT_TARBALLS < <(find "$CORE_PACKAGE_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-gateway-client-*.tgz' -print | sort) + if [[ -f packages/gateway-client/package.json ]] && node -e 'const pkg = require("./packages/gateway-client/package.json"); process.exit(pkg.openclaw?.release?.publishToNpm === true ? 0 : 1)'; then + if [[ "${#GATEWAY_CLIENT_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one prepared @openclaw/gateway-client tarball, found ${#GATEWAY_CLIENT_TARBALLS[@]}." >&2 + exit 1 + fi + GATEWAY_CLIENT_TARBALL_PATH="${GATEWAY_CLIENT_TARBALLS[0]}" + elif [[ "${#GATEWAY_CLIENT_TARBALLS[@]}" -ne 0 ]]; then + echo "Frozen target without a publishable @openclaw/gateway-client package contains unexpected artifacts." >&2 + exit 1 + fi fi PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" @@ -508,6 +520,9 @@ jobs: GATEWAY_PROTOCOL_TARBALL_NAME="" GATEWAY_PROTOCOL_TARBALL_SHA256="" GATEWAY_PROTOCOL_PACKAGE_VERSION="" + GATEWAY_CLIENT_TARBALL_NAME="" + GATEWAY_CLIENT_TARBALL_SHA256="" + GATEWAY_CLIENT_PACKAGE_VERSION="" ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" @@ -536,6 +551,18 @@ jobs: exit 1 fi fi + if [[ -n "$GATEWAY_CLIENT_TARBALL_PATH" ]]; then + GATEWAY_CLIENT_TARBALL_NAME="$(basename "$GATEWAY_CLIENT_TARBALL_PATH")" + GATEWAY_CLIENT_TARBALL_SHA256="$(sha256sum "$GATEWAY_CLIENT_TARBALL_PATH" | awk '{print $1}')" + GATEWAY_CLIENT_PACKAGE_VERSION="$( + tar -xOf "$GATEWAY_CLIENT_TARBALL_PATH" package/package.json | + node -e 'let raw = ""; process.stdin.on("data", (chunk) => (raw += chunk)); process.stdin.on("end", () => process.stdout.write(JSON.parse(raw).version));' + )" + if [[ "$GATEWAY_CLIENT_PACKAGE_VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Prepared @openclaw/gateway-client version ${GATEWAY_CLIENT_PACKAGE_VERSION} does not match openclaw ${PACKAGE_VERSION}." >&2 + exit 1 + fi + fi if [[ -n "$CORE_PACKAGE_TARBALL_DIR" ]]; then cp "$CORE_PACKAGE_TARBALL_DIR"/*.tgz "$ARTIFACT_DIR/" cp "$CORE_PACKAGE_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/core-packages-SHA256SUMS" @@ -544,7 +571,7 @@ jobs: printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt" printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt" printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt" - ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" AI_PACKAGE_VERSION="$AI_PACKAGE_VERSION" AI_TARBALL_NAME="$AI_TARBALL_NAME" AI_TARBALL_SHA256="$AI_TARBALL_SHA256" GATEWAY_PROTOCOL_PACKAGE_VERSION="$GATEWAY_PROTOCOL_PACKAGE_VERSION" GATEWAY_PROTOCOL_TARBALL_NAME="$GATEWAY_PROTOCOL_TARBALL_NAME" GATEWAY_PROTOCOL_TARBALL_SHA256="$GATEWAY_PROTOCOL_TARBALL_SHA256" node <<'NODE' + ARTIFACT_DIR="$ARTIFACT_DIR" RELEASE_TAG="$RELEASE_TAG" RELEASE_SHA="$RELEASE_SHA" RELEASE_NPM_DIST_TAG="$RELEASE_NPM_DIST_TAG" PACKAGE_VERSION="$PACKAGE_VERSION" TARBALL_NAME="$TARBALL_NAME" TARBALL_SHA256="$TARBALL_SHA256" AI_PACKAGE_VERSION="$AI_PACKAGE_VERSION" AI_TARBALL_NAME="$AI_TARBALL_NAME" AI_TARBALL_SHA256="$AI_TARBALL_SHA256" GATEWAY_PROTOCOL_PACKAGE_VERSION="$GATEWAY_PROTOCOL_PACKAGE_VERSION" GATEWAY_PROTOCOL_TARBALL_NAME="$GATEWAY_PROTOCOL_TARBALL_NAME" GATEWAY_PROTOCOL_TARBALL_SHA256="$GATEWAY_PROTOCOL_TARBALL_SHA256" GATEWAY_CLIENT_PACKAGE_VERSION="$GATEWAY_CLIENT_PACKAGE_VERSION" GATEWAY_CLIENT_TARBALL_NAME="$GATEWAY_CLIENT_TARBALL_NAME" GATEWAY_CLIENT_TARBALL_SHA256="$GATEWAY_CLIENT_TARBALL_SHA256" node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const manifest = { @@ -577,6 +604,16 @@ jobs: }, ] : []), + ...(process.env.GATEWAY_CLIENT_TARBALL_NAME + ? [ + { + packageName: "@openclaw/gateway-client", + packageVersion: process.env.GATEWAY_CLIENT_PACKAGE_VERSION, + tarballName: process.env.GATEWAY_CLIENT_TARBALL_NAME, + tarballSha256: process.env.GATEWAY_CLIENT_TARBALL_SHA256, + }, + ] + : []), ], dependencyTarballs: process.env.AI_TARBALL_NAME ? [ @@ -1046,6 +1083,7 @@ jobs: EXPECTED_PACKAGE_VERSION="$(node -p "require('./package.json').version")" SEEN_AI_TARBALL="false" SEEN_GATEWAY_PROTOCOL_TARBALL="false" + SEEN_GATEWAY_CLIENT_TARBALL="false" while IFS=$'\t' read -r package_name package_version tarball_name tarball_sha256; do if [[ -z "$tarball_name" || "$tarball_name" != "$(basename -- "$tarball_name")" || "$tarball_name" != *.tgz ]]; then echo "Prepared core package tarball name is unsafe: $tarball_name" >&2 @@ -1060,6 +1098,10 @@ jobs: [[ "$SEEN_GATEWAY_PROTOCOL_TARBALL" == "false" ]] || { echo "Prepared core package manifest contains duplicate @openclaw/gateway-protocol metadata." >&2; exit 1; } SEEN_GATEWAY_PROTOCOL_TARBALL="true" ;; + "@openclaw/gateway-client") + [[ "$SEEN_GATEWAY_CLIENT_TARBALL" == "false" ]] || { echo "Prepared core package manifest contains duplicate @openclaw/gateway-client metadata." >&2; exit 1; } + SEEN_GATEWAY_CLIENT_TARBALL="true" + ;; *) echo "Prepared core package manifest contains unsupported package: $package_name" >&2 exit 1 @@ -1093,6 +1135,15 @@ jobs: echo "Frozen target without a publishable @openclaw/gateway-protocol package contains unexpected artifacts." >&2 exit 1 fi + if [[ -f packages/gateway-client/package.json ]] && node -e 'const pkg = require("./packages/gateway-client/package.json"); process.exit(pkg.openclaw?.release?.publishToNpm === true ? 0 : 1)'; then + if [[ "$SEEN_GATEWAY_CLIENT_TARBALL" != "true" ]]; then + echo "Prepared @openclaw/gateway-client tarball is missing from the manifest." >&2 + exit 1 + fi + elif jq -e '.corePackageTarballs[] | select(.packageName == "@openclaw/gateway-client")' "$MANIFEST_FILE" >/dev/null || find preflight-tarball -maxdepth 1 -type f -name 'openclaw-gateway-client-*.tgz' -print -quit | grep -q .; then + echo "Frozen target without a publishable @openclaw/gateway-client package contains unexpected artifacts." >&2 + exit 1 + fi echo "tarball_name=$ARTIFACT_TARBALL_NAME" >> "$GITHUB_OUTPUT" echo "tarball_sha256=$ARTIFACT_TARBALL_SHA256" >> "$GITHUB_OUTPUT" echo "tarball_path=$ARTIFACT_TARBALL_PATH" >> "$GITHUB_OUTPUT" diff --git a/packages/gateway-client/CHANGELOG.md b/packages/gateway-client/CHANGELOG.md new file mode 100644 index 00000000000..4d97f2f1c2c --- /dev/null +++ b/packages/gateway-client/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +- Publish the reference Gateway WebSocket client for the first time. +- Record the client extraction from the Gateway protocol implementation in #87797. +- Support the current Gateway wire protocol, protocol v4. diff --git a/packages/gateway-client/LICENSE b/packages/gateway-client/LICENSE new file mode 100644 index 00000000000..ed064819ab5 --- /dev/null +++ b/packages/gateway-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenClaw Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/gateway-client/README.md b/packages/gateway-client/README.md new file mode 100644 index 00000000000..67a313d20b1 --- /dev/null +++ b/packages/gateway-client/README.md @@ -0,0 +1,134 @@ +# `@openclaw/gateway-client` + +Reference WebSocket client for the OpenClaw Gateway protocol. It provides the +connection state machine used by OpenClaw's own Node and browser clients: +challenge-based authentication, typed protocol frames, request correlation, +timeouts, reconnect backoff, device-token handling, and event delivery. + +The current wire protocol is version 4. General clients must negotiate v4 with +`minProtocol: 4` and `maxProtocol: 4`. See the +[Gateway protocol specification](https://docs.openclaw.ai/gateway/protocol) for +the complete handshake, authentication, role, scope, and method contracts. + +## Versioning + +Package versions follow the OpenClaw calendar release train: `YYYY.M.PATCH`, +including the OpenClaw prerelease suffix when applicable. The package version is +separate from the negotiated wire protocol number. + +## Install + +```bash +npm install @openclaw/gateway-client @openclaw/gateway-protocol +``` + +Node consumers use the `ws` transport included as a runtime dependency. Browser +consumers provide their platform WebSocket through the browser-safe protocol +client surface. + +## Entry points + +- `@openclaw/gateway-client` exports the Node `GatewayClient`, device-auth + helpers, readiness helpers, and timeout utilities. +- `@openclaw/gateway-client/browser` exports the browser-safe protocol client, + browser device-auth lifecycle, reconnect policy, and lightweight protocol + constants. Its module graph does not import Node built-ins or `ws`. +- `@openclaw/gateway-client/readiness` exports helpers that delay client startup + until the event loop can process Gateway IO. +- `@openclaw/gateway-client/timeouts` exports timeout constants and safe timer + resolution helpers. + +## Node quickstart + +```ts +import { GatewayClient } from "@openclaw/gateway-client"; +import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; + +const connected = Promise.withResolvers(); +const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: process.env.OPENCLAW_GATEWAY_TOKEN, + minProtocol: PROTOCOL_VERSION, // v4 + maxProtocol: PROTOCOL_VERSION, // v4 + onHelloOk: () => connected.resolve(), + onConnectError: (error) => connected.reject(error), + onEvent: (event) => { + console.log(event.event, event.payload); + }, +}); + +client.start(); +await connected.promise; + +const status = await client.request("status", {}); +console.log(status); + +client.stop(); +``` + +The client waits for the Gateway's `connect.challenge` event before sending its +`connect` request. It includes the challenge nonce in device authentication and +does not fall back to a pre-challenge handshake. `onHelloOk` fires only after the +Gateway accepts the v4 connection, so requests should wait for that callback. + +For remote connections, use `wss://`. Plaintext `ws://` is allowed by default +only for loopback addresses. Authentication material and Gateway traffic must +not cross an untrusted network without transport security. + +## Browser clients + +Import `@openclaw/gateway-client/browser` when the host owns the WebSocket +adapter and device-key storage. The browser entry includes +`GatewayProtocolClient` and `GatewayBrowserDeviceAuthLifecycle`; it deliberately +omits the Node transport, TLS fingerprint handling, and private-network address +policy. + +The host is responsible for: + +- creating a `GatewayProtocolSocket` adapter around the browser WebSocket; +- loading and storing browser device identity and issued device tokens; +- signing the challenge-bound device payload; +- supplying the client identity, role, scopes, and authentication selection; +- choosing close and reconnect behavior for product-specific errors. + +The shared protocol client still owns frame parsing, request correlation, +challenge ordering, timeout cleanup, sequence-gap detection, and reconnect +scheduling. + +## Defaults and reconnect behavior + +The Node client starts with a 30 second request timeout, a 15 second +connect-challenge timeout, and exponential reconnect delays from 1 second to 30 +seconds with a multiplier of 2. Server-provided startup retry hints may override +the next delay. + +The canonical defaults table and the server policy fields that can replace +pre-handshake values are documented in the +[Gateway protocol specification](https://docs.openclaw.ai/gateway/protocol#client-constants). + +Use the `./timeouts` entry point when a host must align readiness or watchdog +budgets with these defaults. Use the `./readiness` entry point when startup must +wait for an event-loop probe before opening the socket. + +## Bundled internals + +The retry supervisor and the small `@openclaw/net-policy/ip` implementation are +inlined into the published JavaScript and declarations. They are implementation +details, not public exports or supported API surfaces. `ipaddr.js` remains an +external dependency because the inlined IP helpers use its public runtime and +types. + +`ws`, `@openclaw/gateway-protocol`, and `ipaddr.js` remain external in the +published distribution. Consumers should import protocol types and constants +from `@openclaw/gateway-protocol`, not from bundled implementation paths. + +## Contract notes + +- The client is inert at module import and construction time. `start()` opens + the socket; `stop()` closes it and rejects pending requests. +- A request uses `request(method, params)` after `hello-ok`. Passing + `timeoutMs: null` creates an intentionally unbounded request. +- Device identity persistence, signing, proxy routing, TLS formatting, and + logging stay host-owned through `GatewayClientHostDeps`. +- Protocol changes are additive first. Incompatible changes require an explicit + wire-version decision and coordinated server/client follow-through. diff --git a/packages/gateway-client/npm-shrinkwrap.json b/packages/gateway-client/npm-shrinkwrap.json new file mode 100644 index 00000000000..96ca32138e9 --- /dev/null +++ b/packages/gateway-client/npm-shrinkwrap.json @@ -0,0 +1,50 @@ +{ + "name": "@openclaw/gateway-client", + "version": "2026.7.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/gateway-client", + "version": "2026.7.2", + "license": "MIT", + "dependencies": { + "ipaddr.js": "2.4.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/packages/gateway-client/package.json b/packages/gateway-client/package.json index 7647e58b7ca..a7b7854801e 100644 --- a/packages/gateway-client/package.json +++ b/packages/gateway-client/package.json @@ -1,10 +1,31 @@ { "name": "@openclaw/gateway-client", - "version": "0.0.0-private", - "private": true, - "files": [ - "dist" + "version": "2026.7.2", + "description": "Reference WebSocket client for the OpenClaw Gateway protocol", + "keywords": [ + "client", + "gateway", + "openclaw", + "websocket" ], + "homepage": "https://github.com/openclaw/openclaw#readme", + "bugs": { + "url": "https://github.com/openclaw/openclaw/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/openclaw/openclaw.git", + "directory": "packages/gateway-client" + }, + "files": [ + "dist", + "npm-shrinkwrap.json", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "sideEffects": false, "type": "module", "main": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -35,8 +56,18 @@ }, "dependencies": { "@openclaw/gateway-protocol": "workspace:*", - "@openclaw/net-policy": "workspace:*", - "@openclaw/retry": "workspace:*", + "ipaddr.js": "2.4.0", "ws": "8.21.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "publishConfig": { + "access": "public" + }, + "openclaw": { + "release": { + "publishToNpm": true + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ae6aeb755..1bd0d890fe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2090,12 +2090,9 @@ importers: '@openclaw/gateway-protocol': specifier: workspace:* version: link:../gateway-protocol - '@openclaw/net-policy': - specifier: workspace:* - version: link:../net-policy - '@openclaw/retry': - specifier: workspace:* - version: link:../retry + ipaddr.js: + specifier: 2.4.0 + version: 2.4.0 ws: specifier: 8.21.0 version: 8.21.0 diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 4e039819374..b6cd7a2e5db 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -477,7 +477,11 @@ export function resolveReleaseCheckLocalPackageTarballs( const gatewayProtocolTarballs = tarballs.filter( (tarballPath) => localPackageNameForTarball(tarballPath) === "@openclaw/gateway-protocol", ); - const recognizedTarballs = aiTarballs.length + gatewayProtocolTarballs.length; + const gatewayClientTarballs = tarballs.filter( + (tarballPath) => localPackageNameForTarball(tarballPath) === "@openclaw/gateway-client", + ); + const recognizedTarballs = + aiTarballs.length + gatewayProtocolTarballs.length + gatewayClientTarballs.length; if (recognizedTarballs !== tarballs.length) { throw new Error( `release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} contains an unsupported package tarball.`, @@ -487,9 +491,13 @@ export function resolveReleaseCheckLocalPackageTarballs( const aiTarballRequirement = requiresAi ? "exactly one @openclaw/ai tarball" : "no @openclaw/ai tarballs"; - if (aiTarballs.length !== expectedAiTarballs || gatewayProtocolTarballs.length > 1) { + if ( + aiTarballs.length !== expectedAiTarballs || + gatewayProtocolTarballs.length > 1 || + gatewayClientTarballs.length > 1 + ) { throw new Error( - `release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} must contain ${aiTarballRequirement} and at most one @openclaw/gateway-protocol tarball; found ${aiTarballs.length} and ${gatewayProtocolTarballs.length}.`, + `release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} must contain ${aiTarballRequirement}, at most one @openclaw/gateway-protocol tarball, and at most one @openclaw/gateway-client tarball; found ${aiTarballs.length}, ${gatewayProtocolTarballs.length}, and ${gatewayClientTarballs.length}.`, ); } return tarballs; @@ -510,6 +518,9 @@ function localPackageNameForTarball(tarballPath: string): string | undefined { if (/^openclaw-gateway-protocol(?:-.+)?\.tgz$/.test(filename)) { return "@openclaw/gateway-protocol"; } + if (/^openclaw-gateway-client(?:-.+)?\.tgz$/.test(filename)) { + return "@openclaw/gateway-client"; + } return undefined; } diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts index d12dc58f797..4beeca333d3 100644 --- a/scripts/sync-plugin-versions.ts +++ b/scripts/sync-plugin-versions.ts @@ -25,7 +25,11 @@ type SyncPluginVersionsOptions = { }; const OPENCLAW_VERSION_RANGE_RE = /^>=\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u; -const VERSION_ALIGNED_PACKAGE_DIRS = ["packages/ai", "packages/gateway-protocol"] as const; +const VERSION_ALIGNED_PACKAGE_DIRS = [ + "packages/ai", + "packages/gateway-client", + "packages/gateway-protocol", +] as const; function syncOpenClawDependencyRange( deps: Record | undefined, diff --git a/test/scripts/generate-npm-shrinkwrap.test.ts b/test/scripts/generate-npm-shrinkwrap.test.ts index 3518b6dfbea..dd89447a2b0 100644 --- a/test/scripts/generate-npm-shrinkwrap.test.ts +++ b/test/scripts/generate-npm-shrinkwrap.test.ts @@ -497,6 +497,15 @@ describe("generate-npm-shrinkwrap", () => { ).toEqual(["packages/gateway-protocol"]); }); + it("targets the changed publishable gateway client shrinkwrap", () => { + expect( + shrinkwrapPackageDirsForChangedPaths([ + "packages/gateway-client/package.json", + "packages/gateway-client/npm-shrinkwrap.json", + ]).map(repoRelativePath), + ).toEqual(["packages/gateway-client"]); + }); + it("targets changed tracked shrinkwraps for private packages", () => { expect( shrinkwrapPackageDirsForChangedPaths(["extensions/vault/package.json"]).map(repoRelativePath), @@ -509,6 +518,7 @@ describe("generate-npm-shrinkwrap", () => { ); expect(packageDirs).toContain(""); + expect(packageDirs).toContain("packages/gateway-client"); expect(packageDirs).toContain("packages/gateway-protocol"); expect(packageDirs).toContain("extensions/acpx"); expect(packageDirs).toContain("extensions/vault"); diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts index d46834f7e88..1a2cf4c49f3 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -257,4 +257,21 @@ describe("minimal npm extended-stable workflow", () => { "find preflight-tarball -type f -name '*.tgz'", ); }); + + it("publishes gateway packages in manifest order before the root package", () => { + const parsed = workflow(); + const preflightPack = step( + parsed.jobs?.preflight_openclaw_npm, + "Pack publishable core packages", + ); + const publish = step(parsed.jobs?.publish_openclaw_npm, "Publish"); + expect(preflightPack.env?.CORE_PACKAGE_DIRS).toBe( + "packages/ai packages/gateway-protocol packages/gateway-client", + ); + expect(readFileSync(workflowPath, "utf8")).toContain('packageName: "@openclaw/gateway-client"'); + expect(publish.run).toContain(".corePackageTarballs[] | [.packageName, .tarballName] | @tsv"); + expect(publish.run).toContain( + 'bash scripts/openclaw-npm-publish.sh --publish "${publish_target}"', + ); + }); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 98c9cde07af..1b97286bad8 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -3679,9 +3679,13 @@ describe("package artifact reuse", () => { expect(npmWorkflow).toContain("dependencyTarballs: process.env.AI_TARBALL_NAME"); expect(npmWorkflow).toContain('packageName: "@openclaw/ai"'); expect(npmWorkflow).toContain('packageName: "@openclaw/gateway-protocol"'); - expect(npmWorkflow).toContain("CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol"); + expect(npmWorkflow).toContain('packageName: "@openclaw/gateway-client"'); + expect(npmWorkflow).toContain( + "CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol packages/gateway-client", + ); expect(npmWorkflow).toContain("AI_TARBALL_SHA256"); expect(npmWorkflow).toContain("GATEWAY_PROTOCOL_TARBALL_SHA256"); + expect(npmWorkflow).toContain("GATEWAY_CLIENT_TARBALL_SHA256"); expect(npmWorkflow).toContain("does not match openclaw"); expect(npmWorkflow).toContain("Frozen target does not depend on @openclaw/ai"); expect(npmWorkflow).toContain("core-packages-SHA256SUMS"); diff --git a/test/scripts/release-check.test.ts b/test/scripts/release-check.test.ts index 26f109b2f2d..02a256f733d 100644 --- a/test/scripts/release-check.test.ts +++ b/test/scripts/release-check.test.ts @@ -35,10 +35,12 @@ describe("release-check", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-tarball-test-")); try { writeFileSync(join(root, "openclaw-ai-2026.6.33.tgz"), "fixture"); + writeFileSync(join(root, "openclaw-gateway-client-2026.6.33.tgz"), "fixture"); writeFileSync(join(root, "openclaw-gateway-protocol-2026.6.33.tgz"), "fixture"); writeFileSync(join(root, "SHA256SUMS"), "fixture"); expect(resolveReleaseCheckLocalPackageTarballs(root)).toEqual([ join(root, "openclaw-ai-2026.6.33.tgz"), + join(root, "openclaw-gateway-client-2026.6.33.tgz"), join(root, "openclaw-gateway-protocol-2026.6.33.tgz"), ]); expect(resolveReleaseCheckLocalPackageTarballs(undefined)).toEqual([]); @@ -47,12 +49,17 @@ describe("release-check", () => { } }); - it("accepts a gateway-only core package directory when the root does not require AI", () => { + it("accepts gateway core packages when the root does not require AI", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-tarball-test-")); try { const gatewayTarball = join(root, "openclaw-gateway-protocol-2026.7.2.tgz"); + const gatewayClientTarball = join(root, "openclaw-gateway-client-2026.7.2.tgz"); writeFileSync(gatewayTarball, "fixture"); - expect(resolveReleaseCheckLocalPackageTarballs(root, false)).toEqual([gatewayTarball]); + writeFileSync(gatewayClientTarball, "fixture"); + expect(resolveReleaseCheckLocalPackageTarballs(root, false)).toEqual([ + gatewayClientTarball, + gatewayTarball, + ]); } finally { rmSync(root, { recursive: true, force: true }); } @@ -63,6 +70,7 @@ describe("release-check", () => { try { writePackedTarballInstallManifest(root, "/tmp/openclaw.tgz", [ "/tmp/openclaw-ai.tgz", + "/tmp/openclaw-gateway-client.tgz", "/tmp/openclaw-gateway-protocol.tgz", ]); const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { @@ -72,6 +80,7 @@ describe("release-check", () => { expect(manifest.private).toBe(true); expect(manifest.dependencies).toEqual({ "@openclaw/ai": "file:///tmp/openclaw-ai.tgz", + "@openclaw/gateway-client": "file:///tmp/openclaw-gateway-client.tgz", "@openclaw/gateway-protocol": "file:///tmp/openclaw-gateway-protocol.tgz", openclaw: "file:///tmp/openclaw.tgz", }); @@ -80,19 +89,20 @@ describe("release-check", () => { } }); - it("writes a gateway-only local project when the root does not require AI", () => { + it("writes a gateway-packages-only local project when the root does not require AI", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-install-test-")); try { writePackedTarballInstallManifest( root, "/tmp/openclaw.tgz", - ["/tmp/openclaw-gateway-protocol.tgz"], + ["/tmp/openclaw-gateway-client.tgz", "/tmp/openclaw-gateway-protocol.tgz"], false, ); const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { dependencies?: Record; }; expect(manifest.dependencies).toEqual({ + "@openclaw/gateway-client": "file:///tmp/openclaw-gateway-client.tgz", "@openclaw/gateway-protocol": "file:///tmp/openclaw-gateway-protocol.tgz", openclaw: "file:///tmp/openclaw.tgz", }); @@ -128,7 +138,9 @@ describe("release-check", () => { preparedDir, "openclaw-gateway-protocol-2026.7.1-beta.3.tgz", ); + const gatewayClientTarball = join(preparedDir, "openclaw-gateway-client-2026.7.1-beta.3.tgz"); writeFileSync(preparedTarball, "fixture"); + writeFileSync(gatewayClientTarball, "fixture"); writeFileSync(gatewayProtocolTarball, "fixture"); const tarballs = prepareReleaseCheckLocalPackageTarballs({ tmpRoot: root, @@ -137,7 +149,7 @@ describe("release-check", () => { throw new Error("workspace pack should not run"); }, }); - expect(tarballs).toEqual([preparedTarball, gatewayProtocolTarball]); + expect(tarballs).toEqual([preparedTarball, gatewayClientTarball, gatewayProtocolTarball]); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/tsdown.config.ts b/tsdown.config.ts index dbe8f5728b5..0ba533f9a7e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -435,7 +435,7 @@ function shouldExternalizeGatewayProtocolDependency(id: string): boolean { } function shouldExternalizeGatewayClientDependency(id: string): boolean { - return ["ws", "@openclaw/net-policy", "@openclaw/gateway-protocol"].some( + return ["ws", "@openclaw/gateway-protocol"].some( (dependency) => id === dependency || id.startsWith(`${dependency}/`), ); }