From fef846a3f0e25b16602dda0677374a7e73c7432b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 23:18:24 -0700 Subject: [PATCH] build(protocol): make @openclaw/gateway-protocol publishable to npm (#111664) * build(protocol): make @openclaw/gateway-protocol publishable to npm * chore(mxc): align package version with release train * fix(release): support gateway-only core packages --- .github/workflows/openclaw-npm-release.yml | 200 ++++++++++++++---- .gitignore | 1 + extensions/mxc/npm-shrinkwrap.json | 4 +- extensions/mxc/package.json | 6 +- packages/gateway-protocol/CHANGELOG.md | 143 +++++++++++++ packages/gateway-protocol/LICENSE | 21 ++ packages/gateway-protocol/README.md | 158 ++++++++++++++ packages/gateway-protocol/npm-shrinkwrap.json | 25 +++ packages/gateway-protocol/package.json | 42 +++- scripts/protocol-gen.ts | 31 ++- scripts/release-check.ts | 79 ++++++- scripts/sync-plugin-versions.ts | 2 +- test/scripts/generate-npm-shrinkwrap.test.ts | 10 + ...nclaw-npm-extended-stable-workflow.test.ts | 2 +- .../package-acceptance-workflow.test.ts | 8 +- test/scripts/release-check.test.ts | 63 +++++- 16 files changed, 720 insertions(+), 75 deletions(-) create mode 100644 packages/gateway-protocol/CHANGELOG.md create mode 100644 packages/gateway-protocol/LICENSE create mode 100644 packages/gateway-protocol/README.md create mode 100644 packages/gateway-protocol/npm-shrinkwrap.json diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index d8c588e651c9..8fd7c2ad36a0 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -201,28 +201,44 @@ jobs: if: steps.dist_build_cache.outputs.cache-hit != 'true' run: pnpm build - - name: Pack AI runtime package - id: ai_runtime_tarballs + - name: Pack publishable core packages + id: core_package_tarballs + env: + CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol run: | set -euo pipefail - if ! node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then - echo "Frozen target does not depend on @openclaw/ai; packing OpenClaw without a separate AI runtime tarball." - exit 0 - fi - if [[ ! -f packages/ai/package.json ]]; then - echo "OpenClaw depends on @openclaw/ai but packages/ai/package.json is missing." >&2 - exit 1 - fi PACKAGE_VERSION="$(node -p "require('./package.json').version")" - package_version="$(node -p "require('./packages/ai/package.json').version")" - if [[ "$package_version" != "$PACKAGE_VERSION" ]]; then - echo "packages/ai version ${package_version} does not match openclaw ${PACKAGE_VERSION}." >&2 - exit 1 - fi - ARTIFACT_DIR="$RUNNER_TEMP/openclaw-ai-runtime-packages" + ARTIFACT_DIR="$RUNNER_TEMP/openclaw-core-packages" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" - pnpm --dir packages/ai pack --pack-destination "$ARTIFACT_DIR" + for package_dir in $CORE_PACKAGE_DIRS; do + if [[ "$package_dir" == "packages/ai" ]] && ! node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then + echo "Frozen target does not depend on @openclaw/ai; packing OpenClaw without a separate AI runtime tarball." + continue + fi + if [[ ! -f "$package_dir/package.json" ]]; then + if [[ "$package_dir" == "packages/ai" ]]; then + echo "Publishable core package manifest is missing: $package_dir/package.json" >&2 + exit 1 + fi + echo "Frozen target does not contain $package_dir; skipping its core package tarball." + continue + fi + if [[ "$package_dir" != "packages/ai" ]] && ! node -e 'const pkg = require(process.argv[1]); process.exit(pkg.openclaw?.release?.publishToNpm === true ? 0 : 1)' "./$package_dir/package.json"; then + echo "Frozen target does not publish $package_dir; skipping its core package tarball." + continue + fi + package_version="$(node -p "require('./$package_dir/package.json').version")" + if [[ "$package_version" != "$PACKAGE_VERSION" ]]; then + echo "$package_dir version ${package_version} does not match openclaw ${PACKAGE_VERSION}." >&2 + exit 1 + fi + pnpm --dir "$package_dir" pack --pack-destination "$ARTIFACT_DIR" + done + if ! find "$ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print -quit | grep -q .; then + echo "dir=" >> "$GITHUB_OUTPUT" + exit 0 + fi (cd "$ARTIFACT_DIR" && sha256sum ./*.tgz > SHA256SUMS) echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" @@ -293,7 +309,7 @@ jobs: # IT BELONGS IN openclaw-release-checks.yml INSTEAD OF BLOCKING npm PUBLISH. - name: Verify release contents env: - OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }} + OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR: ${{ steps.core_package_tarballs.outputs.dir }} run: pnpm release:check - name: Exercise all extended-stable plugin npm packages @@ -365,18 +381,35 @@ jobs: RELEASE_REF: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }} - AI_RUNTIME_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }} + CORE_PACKAGE_TARBALL_DIR: ${{ steps.core_package_tarballs.outputs.dir }} run: | set -euo pipefail AI_TARBALL_PATH="" - if [[ -n "$AI_RUNTIME_TARBALL_DIR" ]]; then - mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort) - if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then - echo "Expected exactly one prepared @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2 + GATEWAY_PROTOCOL_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 + if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one prepared @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2 + exit 1 + fi + AI_TARBALL_PATH="${AI_TARBALLS[0]}" + node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts "$AI_TARBALL_PATH" + elif [[ "${#AI_TARBALLS[@]}" -ne 0 ]]; then + echo "Frozen target without an @openclaw/ai dependency contains unexpected AI runtime artifacts." >&2 + exit 1 + fi + mapfile -t GATEWAY_PROTOCOL_TARBALLS < <(find "$CORE_PACKAGE_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-gateway-protocol-*.tgz' -print | sort) + if [[ -f packages/gateway-protocol/package.json ]] && node -e 'const pkg = require("./packages/gateway-protocol/package.json"); process.exit(pkg.openclaw?.release?.publishToNpm === true ? 0 : 1)'; then + if [[ "${#GATEWAY_PROTOCOL_TARBALLS[@]}" -ne 1 ]]; then + echo "Expected exactly one prepared @openclaw/gateway-protocol tarball, found ${#GATEWAY_PROTOCOL_TARBALLS[@]}." >&2 + exit 1 + fi + GATEWAY_PROTOCOL_TARBALL_PATH="${GATEWAY_PROTOCOL_TARBALLS[0]}" + elif [[ "${#GATEWAY_PROTOCOL_TARBALLS[@]}" -ne 0 ]]; then + echo "Frozen target without a publishable @openclaw/gateway-protocol package contains unexpected artifacts." >&2 exit 1 fi - AI_TARBALL_PATH="${AI_TARBALLS[0]}" - node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts "$AI_TARBALL_PATH" fi PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" @@ -472,6 +505,9 @@ jobs: AI_TARBALL_NAME="" AI_TARBALL_SHA256="" AI_PACKAGE_VERSION="" + GATEWAY_PROTOCOL_TARBALL_NAME="" + GATEWAY_PROTOCOL_TARBALL_SHA256="" + GATEWAY_PROTOCOL_PACKAGE_VERSION="" ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" @@ -487,14 +523,28 @@ jobs: echo "Prepared @openclaw/ai version ${AI_PACKAGE_VERSION} does not match openclaw ${PACKAGE_VERSION}." >&2 exit 1 fi - cp "$AI_TARBALL_PATH" "$ARTIFACT_DIR/" - cp "$AI_RUNTIME_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/ai-runtime-SHA256SUMS" + fi + if [[ -n "$GATEWAY_PROTOCOL_TARBALL_PATH" ]]; then + GATEWAY_PROTOCOL_TARBALL_NAME="$(basename "$GATEWAY_PROTOCOL_TARBALL_PATH")" + GATEWAY_PROTOCOL_TARBALL_SHA256="$(sha256sum "$GATEWAY_PROTOCOL_TARBALL_PATH" | awk '{print $1}')" + GATEWAY_PROTOCOL_PACKAGE_VERSION="$( + tar -xOf "$GATEWAY_PROTOCOL_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_PROTOCOL_PACKAGE_VERSION" != "$PACKAGE_VERSION" ]]; then + echo "Prepared @openclaw/gateway-protocol version ${GATEWAY_PROTOCOL_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" fi cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence" 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" 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" node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const manifest = { @@ -506,6 +556,28 @@ jobs: packageVersion: process.env.PACKAGE_VERSION, tarballName: process.env.TARBALL_NAME, tarballSha256: process.env.TARBALL_SHA256, + corePackageTarballs: [ + ...(process.env.AI_TARBALL_NAME + ? [ + { + packageName: "@openclaw/ai", + packageVersion: process.env.AI_PACKAGE_VERSION, + tarballName: process.env.AI_TARBALL_NAME, + tarballSha256: process.env.AI_TARBALL_SHA256, + }, + ] + : []), + ...(process.env.GATEWAY_PROTOCOL_TARBALL_NAME + ? [ + { + packageName: "@openclaw/gateway-protocol", + packageVersion: process.env.GATEWAY_PROTOCOL_PACKAGE_VERSION, + tarballName: process.env.GATEWAY_PROTOCOL_TARBALL_NAME, + tarballSha256: process.env.GATEWAY_PROTOCOL_TARBALL_SHA256, + }, + ] + : []), + ], dependencyTarballs: process.env.AI_TARBALL_NAME ? [ { @@ -959,16 +1031,68 @@ jobs: echo "Prepared preflight tarball digest mismatch." >&2 exit 1 fi - if node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then - if [[ ! -f preflight-tarball/ai-runtime-SHA256SUMS ]]; then - echo "Prepared AI runtime dependency checksums are missing." >&2 + if ! jq -e '.corePackageTarballs | type == "array"' "$MANIFEST_FILE" >/dev/null; then + echo "Prepared core package metadata is missing." >&2 + exit 1 + fi + CORE_PACKAGE_TARBALL_COUNT="$(jq -r '.corePackageTarballs | length' "$MANIFEST_FILE")" + if [[ "$CORE_PACKAGE_TARBALL_COUNT" -gt 0 ]]; then + if [[ ! -f preflight-tarball/core-packages-SHA256SUMS ]]; then + echo "Prepared core package checksums are missing." >&2 exit 1 fi - (cd preflight-tarball && sha256sum -c ai-runtime-SHA256SUMS) - elif [[ -f preflight-tarball/ai-runtime-SHA256SUMS ]] || find preflight-tarball -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print -quit | grep -q .; then + (cd preflight-tarball && sha256sum -c core-packages-SHA256SUMS) + fi + EXPECTED_PACKAGE_VERSION="$(node -p "require('./package.json').version")" + SEEN_AI_TARBALL="false" + SEEN_GATEWAY_PROTOCOL_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 + exit 1 + fi + case "$package_name" in + "@openclaw/ai") + [[ "$SEEN_AI_TARBALL" == "false" ]] || { echo "Prepared core package manifest contains duplicate @openclaw/ai metadata." >&2; exit 1; } + SEEN_AI_TARBALL="true" + ;; + "@openclaw/gateway-protocol") + [[ "$SEEN_GATEWAY_PROTOCOL_TARBALL" == "false" ]] || { echo "Prepared core package manifest contains duplicate @openclaw/gateway-protocol metadata." >&2; exit 1; } + SEEN_GATEWAY_PROTOCOL_TARBALL="true" + ;; + *) + echo "Prepared core package manifest contains unsupported package: $package_name" >&2 + exit 1 + ;; + esac + if [[ "$package_version" != "$EXPECTED_PACKAGE_VERSION" ]]; then + echo "Prepared ${package_name} version ${package_version} does not match openclaw ${EXPECTED_PACKAGE_VERSION}." >&2 + exit 1 + fi + core_tarball_path="preflight-tarball/$tarball_name" + if [[ ! -f "$core_tarball_path" ]] || [[ "$(sha256sum "$core_tarball_path" | awk '{print $1}')" != "$tarball_sha256" ]]; then + echo "Prepared ${package_name} tarball is missing or has a digest mismatch." >&2 + exit 1 + fi + done < <(jq -r '.corePackageTarballs[] | [.packageName, .packageVersion, .tarballName, .tarballSha256] | @tsv' "$MANIFEST_FILE") + if node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then + if [[ "$SEEN_AI_TARBALL" != "true" ]] || ! jq -e '.dependencyTarballs[] | select(.packageName == "@openclaw/ai")' "$MANIFEST_FILE" >/dev/null; then + echo "Prepared AI runtime dependency tarball is missing from the manifest." >&2 + exit 1 + fi + elif jq -e '.dependencyTarballs[] | select(.packageName == "@openclaw/ai")' "$MANIFEST_FILE" >/dev/null || find preflight-tarball -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print -quit | grep -q .; then echo "Frozen target without an @openclaw/ai dependency contains unexpected AI runtime artifacts." >&2 exit 1 fi + if [[ -f packages/gateway-protocol/package.json ]] && node -e 'const pkg = require("./packages/gateway-protocol/package.json"); process.exit(pkg.openclaw?.release?.publishToNpm === true ? 0 : 1)'; then + if [[ "$SEEN_GATEWAY_PROTOCOL_TARBALL" != "true" ]]; then + echo "Prepared @openclaw/gateway-protocol tarball is missing from the manifest." >&2 + exit 1 + fi + elif jq -e '.corePackageTarballs[] | select(.packageName == "@openclaw/gateway-protocol")' "$MANIFEST_FILE" >/dev/null || find preflight-tarball -maxdepth 1 -type f -name 'openclaw-gateway-protocol-*.tgz' -print -quit | grep -q .; then + echo "Frozen target without a publishable @openclaw/gateway-protocol 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" @@ -1049,13 +1173,9 @@ jobs: fi bash scripts/openclaw-npm-publish.sh --publish "./${tarball_path}" } - ai_tarball="$(find preflight-tarball -type f -name 'openclaw-ai-*.tgz' -print | head -n 1)" - if [[ -n "$ai_tarball" ]]; then - publish_if_missing "@openclaw/ai" "$ai_tarball" - elif node -e 'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)'; then - echo "Prepared tarball for @openclaw/ai was not found." >&2 - exit 1 - fi + while IFS=$'\t' read -r package_name tarball_name; do + publish_if_missing "$package_name" "preflight-tarball/$tarball_name" + done < <(jq -r '.corePackageTarballs[] | [.packageName, .tarballName] | @tsv' preflight-tarball/preflight-manifest.json) bash scripts/openclaw-npm-publish.sh --publish "${publish_target}" - name: Verify extended-stable registry readback diff --git a/.gitignore b/.gitignore index df29540a1f84..e4446490e045 100644 --- a/.gitignore +++ b/.gitignore @@ -247,6 +247,7 @@ apps/swabble/build/ # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json +packages/gateway-protocol/protocol.schema.json .ant-colony/ # Eclipse diff --git a/extensions/mxc/npm-shrinkwrap.json b/extensions/mxc/npm-shrinkwrap.json index 2c6911f4045b..3a013fc00df0 100644 --- a/extensions/mxc/npm-shrinkwrap.json +++ b/extensions/mxc/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@openclaw/mxc-sandbox", - "version": "2026.6.11", + "version": "2026.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openclaw/mxc-sandbox", - "version": "2026.6.11", + "version": "2026.7.2", "dependencies": { "@microsoft/mxc-sdk": "0.7.0", "zod": "4.4.3" diff --git a/extensions/mxc/package.json b/extensions/mxc/package.json index b44bcd9ec4f8..0ff25bedce95 100644 --- a/extensions/mxc/package.json +++ b/extensions/mxc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mxc-sandbox", - "version": "2026.6.11", + "version": "2026.7.2", "description": "OpenClaw MXC sandbox execution plugin for MXC-capable hosts", "repository": { "type": "git", @@ -24,10 +24,10 @@ "minHostVersion": ">=2026.6.11" }, "compat": { - "pluginApi": ">=2026.6.11" + "pluginApi": ">=2026.7.2" }, "build": { - "openclawVersion": "2026.6.11", + "openclawVersion": "2026.7.2", "bundledDist": false, "staticAssets": [ { diff --git a/packages/gateway-protocol/CHANGELOG.md b/packages/gateway-protocol/CHANGELOG.md new file mode 100644 index 000000000000..c63103f1ed19 --- /dev/null +++ b/packages/gateway-protocol/CHANGELOG.md @@ -0,0 +1,143 @@ +# Changelog — @openclaw/gateway-protocol + +Wire-protocol and schema contracts for the OpenClaw Gateway (WebSocket JSON-RPC-style +frames, handshake, and method/event payload schemas). Protocol version is negotiated +per connection via `minProtocol`/`maxProtocol`. This log covers the wire protocol +version and the additive schema surface. Dates are authoring dates (2026). + +## Unreleased + +- Rename structured-question item `id` to `questionId` and flatten keyed answer arrays. +- Slim worker and session-catalog payloads to the active wire contract. +- Remove dead protocol surfaces and add since-vintage metadata to retained schemas and methods. + +## Protocol v4 (current) + +Introduced 2026-05-07 (commit `330ba1f`). The stable, current wire version. + +Wire contract: +- Frame envelopes: `req` / `res` / `event`, discriminated on `type`. +- Handshake: client sends `ConnectParams` (advertises `minProtocol`/`maxProtocol`, + client identity, `caps`, `commands`, `permissions`, `role`/`scopes`, optional signed + `device` identity, and an `auth` bag: token / bootstrapToken / deviceToken / password / + approvalRuntimeToken / agentRuntimeIdentityToken). +- Server replies `hello-ok` with the negotiated `protocol`, server identity, the live + `features` map (`methods`, `events`, `capabilities`), initial `snapshot`, minted `auth` + device tokens, and connection `policy` (maxPayload / maxBufferedBytes / tickIntervalMs). +- Server events: `tick` heartbeat and `shutdown` notice; event frames may carry `seq` + and `stateVersion` for ordered state sync. + +Changed vs v3: +- `hello-ok` handshake replaced the single `canvasHostUrl` string with a + `pluginSurfaceUrls` map (canvas generalized into arbitrary plugin surfaces). This + field change is the breaking bump behind v4. +- Later additively extended (no bump) with `controlUiTabs` (plugin-declared Control UI + tabs) and multi-`deviceTokens` in the handshake auth block. + +Compatibility window (`version.ts`): +- `MIN_CLIENT_PROTOCOL_VERSION = 4` — general clients must speak v4. +- `MIN_NODE_PROTOCOL_VERSION = 3` and `MIN_PROBE_PROTOCOL_VERSION = 3` — authenticated + nodes and lightweight probes are accepted at N-1 (v3) to stay manageable during + rolling upgrades. Added 2026-07-06 (#101109). +- A transient v5 (2026-05-16, `07f05e9`) renamed `inboundTurnKind` -> `inboundEventKind`; + it was reverted the next day (2026-05-17, `ad155fb`, "restore v4 message action + protocol"). v5 never shipped as a stable ceiling; v4 remains current. + +## Protocol v3 + +Baseline wire version. Present since repo genesis (2026-04-21) as an inline literal in +`protocol-schemas.ts`; extracted into `version.ts` unchanged on 2026-05-04 (`2949171`). +The first externally-relevant version — there is no 2->3 bump in tracked history. + +Established the still-current shape: `req`/`res`/`event` frame envelopes, the +`ConnectParams` -> `hello-ok` handshake with protocol negotiation, `snapshot` state sync, +and the founding method/event families: sessions, agent chat, cron, devices, nodes, +channels, config, commands, logs-chat, exec-approvals, plugin-approvals, secrets, push, +wizard. (v3 `hello-ok` carried `canvasHostUrl`; v4 replaced it — see above.) + +## Schema surface history + +Additive method/event/schema families added over time. Pure refactors, test-only, and +docs commits are omitted. The package was extracted from `src/gateway/protocol` to +`packages/gateway-protocol` on 2026-05-29 (#87797); paths before that date lived under +the old tree. + +### 2026-04 (v3 baseline era) + +- Ship baseline families: frames/handshake, sessions, agent chat, cron, devices, nodes, + channels, config, commands, logs-chat, exec-approvals, plugin-approvals, secrets, push, + wizard, snapshot, primitives, agents-models-skills. +- Add WhatsApp `replyToMode` quoting (#62305). +- Add browser realtime Talk and transports — origin of the talk/voice families. +- Add Control UI PWA web push support (#44590). +- Add plugins and artifacts schema modules. +- Add OpenClaw SDK package and authenticated iOS background presence beacon (#73330). + +### 2026-05 + +- Add environments discovery RPCs (#74867) and task-ledger RPCs (#74847) — tasks family. +- Add unified Talk gateway sessions, realtime active-run control, and typed `sessionKey` + on the wake protocol. +- Add SDK `tools.invoke` RPC; extend cron with agentId filtering (#77602), run + diagnostics (#75928), and direct job lookup. +- Add Skill Workshop gateway methods: proposal files, revision requests, persisted origin. +- Add core session goals (#87469). +- Add heartbeat flag on agent event broadcast (#80610), warm-MCP effective inventory, and + plugin approval action metadata. +- Harden auth/device identity: bind approval access to requester metadata (#81380); + require approval for setup-code device pairing (#81292); scope Talk session to resolver. +- Introduce and revert transient protocol v5 (`07f05e9` / `ad155fb`). + +### 2026-06 + +Enhancement-only month (no new schema modules): +- Extend cron with command jobs, compact list responses (#93395), and an on-exit + schedule kind that fires when a watched command exits. +- Forward-port fast-Talks auto mode (#85104); add session workspace rail (#92856). + +### 2026-07 (largest expansion) + +- Add terminal family: `terminal.*` RPC methods/events, detach/reattach with output + replay, `terminal.list`/`terminal.text`, and file uploads into terminals (#107364). +- Add managed git worktrees: lifecycle create/provision/snapshot/restore/GC (#100535), + new-session-in-worktree (#100788), session worktree targeting and branch listing + (#103432); add read-only `agents.workspace` browsing RPCs (#100738). +- Add audit family: metadata-only message audit events (#103903), native-search audit + correlation (#98704), and audit-activity schema. +- Add `tts.speak` returning synthesized audio inline (#100770). +- Add cooperative host suspension / gateway-suspend prepare/status/resume RPCs (#103618). +- Add durable approvals: persisted operator approvals (#103579), typed cross-surface + approval actions (#103679), approval-id, and the durable-approvals stack (#104837). +- Add cloud-workers stack: durable environments + lifecycle RPCs (#104401), worker bundle + + SSH bootstrap + admission handshake (#104532), authenticated worker protocol with + minted credentials (#104688), durable transcript commit (#104809), live-event streaming + (#105275), inference proxy (#105719), and session placement/dispatch (#106332). +- Add session catalog: sessions-catalog + sessions-create with external-session + pagination unification (#104717). +- Add fs family: `sessions.files.set` hash-CAS writes (#104757) and gateway/node folder + browsing (#105114). +- Add node-hosted plugins — dynamic tools, MCP servers, skills (#90431) — plus node + invoke/presence protocol schemas. +- Add migrations family: log-migration protocol schemas and Codex/Claude memory import + (#106406). +- Add durable device rename for human-friendly device names (#94517). +- Add follow-up task suggestions (#102422) and task-suggestions schema. +- Add cron event triggers via polled condition-watcher scripts (#101195) and native + mobile Automations parity (#106355). +- Add system-agent conversational onboarding (#99935); rename `crestodian.*` methods to + `openclaw.chat` / `openclaw.setup.*` (2026-07-14, `a6a0716`). +- Add typed structured questions / `ask_user` with live option cards (#109922, #110242) + and the questions schema module. +- Add ui-command / screen-tool Control UI layout control and capability-gated + `show_widget` inline web chat widgets (#101840). +- Add direct watch/watchOS node connect to Gateway (#102893); widen node/probe protocol + acceptance to N-1 (#101109). + +## Notes for external versioning + +- Post-v4 changes are additive except: the transient v5 rename (reverted, net-zero) and + the 2026-07-14 `crestodian.*` -> `openclaw.*` method rename. The renamed feature was + introduced only 9 days earlier (2026-07-05, #99935), never left the v4 window, and + predates public publish, so no released method name changed under v4. +- `schema/types.ts` was removed 2026-07-11 (#103679); it re-exported compile-time type + aliases only and has no wire impact. diff --git a/packages/gateway-protocol/LICENSE b/packages/gateway-protocol/LICENSE new file mode 100644 index 000000000000..ed064819ab53 --- /dev/null +++ b/packages/gateway-protocol/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-protocol/README.md b/packages/gateway-protocol/README.md new file mode 100644 index 000000000000..4fbcef8843d2 --- /dev/null +++ b/packages/gateway-protocol/README.md @@ -0,0 +1,158 @@ +# `@openclaw/gateway-protocol` + +Typed schemas, inferred TypeScript types, and runtime validators for the OpenClaw +Gateway WebSocket protocol. + +The current wire protocol is version 4. General clients must use v4; authenticated +node clients and lightweight probes may use the N-1 window during rolling upgrades. +See the [Gateway protocol specification](https://docs.openclaw.ai/gateway/protocol) +for transport, authentication, roles, scopes, and complete frame examples. + +## Versioning + +Package versions follow the OpenClaw calendar release train: +`YYYY.M.PATCH`, with the same prerelease suffix when applicable. A package version +therefore identifies the OpenClaw source release that produced the schemas; it is +not the wire protocol number. + +The wire protocol integer is versioned separately. Its current value is exported +as `PROTOCOL_VERSION` from `@openclaw/gateway-protocol/version`. Gateway protocol +changes are additive first. An incompatible wire change requires an explicit +protocol-version decision and coordinated client follow-through. See +[`CHANGELOG.md`](./CHANGELOG.md) for the wire and schema history. + +## Install + +```bash +npm install @openclaw/gateway-protocol +``` + +## Entry points + +- `@openclaw/gateway-protocol` exports runtime validators, selected schemas, error + formatting, and their TypeScript types. This is the main TypeBox-backed entry. +- `@openclaw/gateway-protocol/schema` exports the TypeBox schema graph, including + the `ProtocolSchemas` registry used by generators. +- `@openclaw/gateway-protocol/frame-guards` exports dependency-free structural + guards for gateway event and response envelopes. +- `@openclaw/gateway-protocol/client-info` exports client ID, mode, and capability + registries plus normalization helpers. +- `@openclaw/gateway-protocol/connect-error-details` exports structured connect + error readers and recovery metadata. +- `@openclaw/gateway-protocol/gateway-error-details` exports helpers for reading + structured details from general gateway errors. +- `@openclaw/gateway-protocol/startup-unavailable` exports startup retry constants + and helpers. +- `@openclaw/gateway-protocol/version` exports the current and minimum accepted + protocol versions. + +The `frame-guards`, `client-info`, `connect-error-details`, `gateway-error-details`, +`startup-unavailable`, and `version` entry points are TypeBox-free. Prefer them when +a browser bundle only needs envelope dispatch, handshake constants, or reconnect +policy. This also avoids runtime compilation in CSP-sensitive consumers. The root +and `schema` entry points provide the full validation surface and depend on TypeBox. + +## Validate an inbound frame + +The compiled validators are callable type guards. Their `errors` property contains +the most recent validation errors. + +```ts +import { formatValidationErrors, validateRequestFrame } from "@openclaw/gateway-protocol"; + +const frame: unknown = JSON.parse(inboundText); + +if (!validateRequestFrame(frame)) { + throw new Error(formatValidationErrors(validateRequestFrame.errors)); +} + +console.log(frame.id, frame.method); +``` + +`validateRequestFrame` validates the request envelope. Dispatch code must also use +the validator for the selected method's `params`; the root entry point exports those +validators as `validate*Params` functions. + +## Guard an event without TypeBox + +Use the lightweight guards when code only needs safe frame discrimination. They +check dispatch-critical envelope fields and intentionally allow additive payload +fields. + +```ts +import { isGatewayEventFrame } from "@openclaw/gateway-protocol/frame-guards"; + +const frame: unknown = JSON.parse(inboundText); + +if (isGatewayEventFrame(frame)) { + console.log(frame.event, frame.seq); +} +``` + +## Build handshake version and capability fields + +Protocol levels and client capabilities live in TypeBox-free entry points. + +```ts +import { GATEWAY_CLIENT_CAPS } from "@openclaw/gateway-protocol/client-info"; +import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; + +const handshake = { + minProtocol: MIN_CLIENT_PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS], +}; +``` + +Nodes and probes use `MIN_NODE_PROTOCOL_VERSION` and +`MIN_PROBE_PROTOCOL_VERSION`, respectively. A capability advertises client support; +it does not grant authorization. + +## Contract notes + +### Session identifiers + +Several identifier names coexist because they identify different things: + +- `key` is the established logical session selector used by most `sessions.*` + CRUD, send, subscription, patch, reset, delete, compaction, and usage methods. + A key can be canonicalized or resolved within an agent's session store. +- `sessionKey` names the same logical routing identity where the contract needs to + make that meaning explicit. `chat.*`, session file and diff APIs, transcript + branch/rewind/fork APIs, agent events, and channel delivery payloads use this + spelling. +- `sessionId` is the opaque stored transcript or runtime instance ID. Session + results may return it beside a key. Talk, terminal, worker, and selected channel + protocols also use `sessionId` for their own concrete session instances; do not + substitute a logical session key there. + +Follow each method schema rather than converting fields based on their spelling. +`sessions.resolve` is the explicit bridge when a caller has a key, raw session ID, +label, or parent/agent scope. + +### Intentionally open fields + +The schema graph is strict by default, but roughly 60 fields intentionally use +`Type.Unknown()` passthroughs. The main clusters are transport-owned channel +payloads, logs-chat message and attachment passthrough, worker and node tool +arguments/results, and the dynamic `config.schema` response. Frame `params`, +`payload`, and error `details` are also open at the envelope layer because the +selected method, event, or error code owns their concrete shape. + +Do not treat these fields as validated domain objects. Narrow them at their owner +boundary before reading nested values. + +### Machine-readable schema + +`protocol.schema.json` ships in the npm tarball as the generated machine-readable +contract. It contains the frame union, named schema definitions, and core method +metadata. It is generated during `prepack` and is not committed to the repository. + +### Method discovery + +The `hello-ok.features.methods` list is conservative discovery, not a complete +enumeration of every callable method. It reflects the methods the connected +Gateway intentionally advertises. Core-internal, role-specific, plugin-provided, +or otherwise non-advertised methods can have valid schemas without appearing in +that list. Clients should use discovery to enable optional UI, not to reject an +otherwise documented method contract. diff --git a/packages/gateway-protocol/npm-shrinkwrap.json b/packages/gateway-protocol/npm-shrinkwrap.json new file mode 100644 index 000000000000..ef4f788dcbe9 --- /dev/null +++ b/packages/gateway-protocol/npm-shrinkwrap.json @@ -0,0 +1,25 @@ +{ + "name": "@openclaw/gateway-protocol", + "version": "2026.7.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/gateway-protocol", + "version": "2026.7.2", + "license": "MIT", + "dependencies": { + "typebox": "1.3.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/typebox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz", + "integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==", + "license": "MIT" + } + } +} diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index 902fbc11b102..923b6d380b02 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -1,9 +1,31 @@ { "name": "@openclaw/gateway-protocol", - "version": "0.0.0-private", - "private": true, + "version": "2026.7.2", + "description": "Typed schemas and runtime validators for the OpenClaw Gateway WebSocket protocol", + "keywords": [ + "gateway", + "openclaw", + "protocol", + "typebox", + "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-protocol" + }, "files": [ - "dist" + "dist", + "npm-shrinkwrap.json", + "LICENSE", + "README.md", + "CHANGELOG.md", + "protocol.schema.json" ], "sideEffects": false, "type": "module", @@ -52,9 +74,21 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean", + "prepack": "pnpm run build && node --import tsx ../../scripts/protocol-gen.ts --out ./protocol.schema.json" }, "dependencies": { "typebox": "1.3.3" + }, + "engines": { + "node": ">=22.19.0" + }, + "publishConfig": { + "access": "public" + }, + "openclaw": { + "release": { + "publishToNpm": true + } } } diff --git a/scripts/protocol-gen.ts b/scripts/protocol-gen.ts index 8e5454eb049b..a9b228fec892 100644 --- a/scripts/protocol-gen.ts +++ b/scripts/protocol-gen.ts @@ -7,8 +7,31 @@ import { listCoreGatewayMethodMetadata } from "../src/gateway/methods/core-descr const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); +const defaultOutputPath = path.join(repoRoot, "dist", "protocol.schema.json"); -async function writeJsonSchema() { +function resolveOutputPath(args: string[]): string { + let outputPath = defaultOutputPath; + let hasOutputPath = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg !== "--out") { + throw new Error(`Unknown argument: ${arg}`); + } + if (hasOutputPath) { + throw new Error("--out may only be specified once."); + } + const value = args[index + 1]?.trim(); + if (!value || value === "--out") { + throw new Error("--out requires a path."); + } + outputPath = path.resolve(value); + hasOutputPath = true; + index += 1; + } + return outputPath; +} + +async function writeJsonSchema(jsonSchemaPath: string) { const definitions: Record = {}; for (const [name, schema] of Object.entries(ProtocolSchemas)) { definitions[name] = schema; @@ -39,16 +62,14 @@ async function writeJsonSchema() { definitions, }; - const distDir = path.join(repoRoot, "dist"); - await fs.mkdir(distDir, { recursive: true }); - const jsonSchemaPath = path.join(distDir, "protocol.schema.json"); + await fs.mkdir(path.dirname(jsonSchemaPath), { recursive: true }); await fs.writeFile(jsonSchemaPath, JSON.stringify(rootSchema, null, 2)); console.log(`wrote ${jsonSchemaPath}`); return { jsonSchemaPath, schemaString: JSON.stringify(rootSchema) }; } async function main() { - await writeJsonSchema(); + await writeJsonSchema(resolveOutputPath(process.argv.slice(2))); } main().catch((err: unknown) => { diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 79a90a75618b..4e039819374b 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -456,6 +456,7 @@ export function resolvePackedTarballPath(packDestination: string, results: PackR export function resolveReleaseCheckLocalPackageTarballs( tarballDir: string | undefined = process.env[RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV], + requiresAi = rootPackageRequiresLocalAiTarball(), ): string[] { if (!tarballDir) { return []; @@ -470,14 +471,48 @@ export function resolveReleaseCheckLocalPackageTarballs( .filter((entry) => entry.isFile() && entry.name.endsWith(".tgz")) .map((entry) => resolve(resolvedDir, entry.name)) .toSorted((left, right) => left.localeCompare(right)); - if (tarballs.length !== 1) { + const aiTarballs = tarballs.filter( + (tarballPath) => localPackageNameForTarball(tarballPath) === "@openclaw/ai", + ); + const gatewayProtocolTarballs = tarballs.filter( + (tarballPath) => localPackageNameForTarball(tarballPath) === "@openclaw/gateway-protocol", + ); + const recognizedTarballs = aiTarballs.length + gatewayProtocolTarballs.length; + if (recognizedTarballs !== tarballs.length) { throw new Error( - `release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} contains ${tarballs.length} tarballs; expected exactly one.`, + `release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} contains an unsupported package tarball.`, + ); + } + const expectedAiTarballs = requiresAi ? 1 : 0; + const aiTarballRequirement = requiresAi + ? "exactly one @openclaw/ai tarball" + : "no @openclaw/ai tarballs"; + if (aiTarballs.length !== expectedAiTarballs || gatewayProtocolTarballs.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}.`, ); } return tarballs; } +function rootPackageRequiresLocalAiTarball(): boolean { + const packageJson = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as { + dependencies?: Record; + }; + return typeof packageJson.dependencies?.["@openclaw/ai"] === "string"; +} + +function localPackageNameForTarball(tarballPath: string): string | undefined { + const filename = basename(tarballPath); + if (/^openclaw-ai(?:-.+)?\.tgz$/.test(filename)) { + return "@openclaw/ai"; + } + if (/^openclaw-gateway-protocol(?:-.+)?\.tgz$/.test(filename)) { + return "@openclaw/gateway-protocol"; + } + return undefined; +} + export function prepareReleaseCheckLocalPackageTarballs(params: { tmpRoot: string; tarballDir?: string; @@ -505,17 +540,38 @@ export function writePackedTarballInstallManifest( prefixDir: string, tarballPath: string, localPackageTarballs: string[], + requiresAi = rootPackageRequiresLocalAiTarball(), ): void { - const aiTarball = localPackageTarballs[0]; - if (localPackageTarballs.length !== 1 || !aiTarball) { + const localPackages = localPackageTarballs.map((localPackageTarballPath) => ({ + packageName: localPackageNameForTarball(localPackageTarballPath), + tarballPath: localPackageTarballPath, + })); + const aiTarballs = localPackages.filter(({ packageName }) => packageName === "@openclaw/ai"); + const expectedAiTarballs = requiresAi ? 1 : 0; + const aiTarballRequirement = requiresAi + ? "exactly one @openclaw/ai tarball" + : "no @openclaw/ai tarballs"; + if (aiTarballs.length !== expectedAiTarballs) { throw new Error( - `release-check: packed install requires exactly one @openclaw/ai tarball; found ${localPackageTarballs.length}.`, + `release-check: packed install requires ${aiTarballRequirement}; found ${aiTarballs.length}.`, ); } - const dependencies: Record = { - "@openclaw/ai": pathToFileURL(aiTarball).href, - openclaw: pathToFileURL(tarballPath).href, - }; + const unsupportedTarball = localPackages.find(({ packageName }) => !packageName); + if (unsupportedTarball) { + throw new Error( + `release-check: packed install received an unsupported package tarball: ${basename(unsupportedTarball.tarballPath)}.`, + ); + } + const dependencies = Object.fromEntries( + localPackages.map(({ packageName, tarballPath: localPackageTarballPath }) => [ + packageName as string, + pathToFileURL(localPackageTarballPath).href, + ]), + ); + if (Object.keys(dependencies).length !== localPackages.length) { + throw new Error("release-check: packed install received duplicate local package tarballs."); + } + dependencies.openclaw = pathToFileURL(tarballPath).href; mkdirSync(prefixDir, { recursive: true }); writeFileSync( join(prefixDir, "package.json"), @@ -765,10 +821,13 @@ function runPackedPluginSdkTypescriptSmoke( localPackageTarballs: string[], ): void { const consumerDir = join(tmpRoot, "plugin-sdk-type-consumer"); + const aiTarball = localPackageTarballs.find( + (localPackageTarball) => localPackageNameForTarball(localPackageTarball) === "@openclaw/ai", + ); createPackedPluginSdkTypescriptSmokeProject({ consumerDir, packageSpec: `file:${tarballPath}`, - aiPackageSpec: localPackageTarballs[0] ? `file:${localPackageTarballs[0]}` : undefined, + aiPackageSpec: aiTarball ? `file:${aiTarball}` : undefined, }); execNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: consumerDir, diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts index f873a05f8c86..d12dc58f7979 100644 --- a/scripts/sync-plugin-versions.ts +++ b/scripts/sync-plugin-versions.ts @@ -25,7 +25,7 @@ type SyncPluginVersionsOptions = { }; const OPENCLAW_VERSION_RANGE_RE = /^>=\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u; -const VERSION_ALIGNED_PACKAGE_DIRS = ["packages/ai"] as const; +const VERSION_ALIGNED_PACKAGE_DIRS = ["packages/ai", "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 55af543dd261..3518b6dfbea6 100644 --- a/test/scripts/generate-npm-shrinkwrap.test.ts +++ b/test/scripts/generate-npm-shrinkwrap.test.ts @@ -488,6 +488,15 @@ describe("generate-npm-shrinkwrap", () => { ).toEqual(["extensions/acpx"]); }); + it("targets the changed publishable gateway protocol shrinkwrap", () => { + expect( + shrinkwrapPackageDirsForChangedPaths([ + "packages/gateway-protocol/package.json", + "packages/gateway-protocol/npm-shrinkwrap.json", + ]).map(repoRelativePath), + ).toEqual(["packages/gateway-protocol"]); + }); + it("targets changed tracked shrinkwraps for private packages", () => { expect( shrinkwrapPackageDirsForChangedPaths(["extensions/vault/package.json"]).map(repoRelativePath), @@ -500,6 +509,7 @@ describe("generate-npm-shrinkwrap", () => { ); expect(packageDirs).toContain(""); + 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 35e1e667f347..d46834f7e888 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -138,7 +138,7 @@ describe("minimal npm extended-stable workflow", () => { const plugins = step(preflight, "Exercise all extended-stable plugin npm packages"); expect(step(preflight, "Verify release contents").env).toMatchObject({ OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR: - "${{ steps.ai_runtime_tarballs.outputs.dir }}", + "${{ steps.core_package_tarballs.outputs.dir }}", }); expect(plugins.if).toBe("${{ inputs.npm_dist_tag == 'extended-stable' }}"); expect(plugins.env).toMatchObject({ diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 1e4c0358d331..98c9cde07afb 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -3674,12 +3674,18 @@ describe("package artifact reuse", () => { "Workflow-dispatched real publish requires release_publish_run_id", ); expect(npmWorkflow).toContain("tarballSha256"); + expect(npmWorkflow).toContain("corePackageTarballs"); expect(npmWorkflow).toContain("dependencyTarballs"); + 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("AI_TARBALL_SHA256"); + expect(npmWorkflow).toContain("GATEWAY_PROTOCOL_TARBALL_SHA256"); expect(npmWorkflow).toContain("does not match openclaw"); expect(npmWorkflow).toContain("Frozen target does not depend on @openclaw/ai"); - expect(npmWorkflow).toContain("dependencyTarballs: process.env.AI_TARBALL_NAME"); + expect(npmWorkflow).toContain("core-packages-SHA256SUMS"); + expect(npmWorkflow).toContain(".corePackageTarballs[] | [.packageName, .tarballName] | @tsv"); expect(npmWorkflow).toContain('verify_args=("$TARBALL_PATH" "$PACKAGE_VERSION")'); expect(npmWorkflow).toContain("Frozen target without an @openclaw/ai dependency"); const npmTelegramWorkflow = readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8"); diff --git a/test/scripts/release-check.test.ts b/test/scripts/release-check.test.ts index db01d07de34e..26f109b2f2dd 100644 --- a/test/scripts/release-check.test.ts +++ b/test/scripts/release-check.test.ts @@ -31,13 +31,15 @@ describe("release-check", () => { ]); }); - it("resolves exactly one prepacked local dependency tarball", () => { + it("resolves prepacked publishable core package tarballs", () => { 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-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-protocol-2026.6.33.tgz"), ]); expect(resolveReleaseCheckLocalPackageTarballs(undefined)).toEqual([]); } finally { @@ -45,10 +47,24 @@ describe("release-check", () => { } }); - it("writes an explicit local project for unpublished core and AI tarballs", () => { + it("accepts a gateway-only core package directory 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"); + writeFileSync(gatewayTarball, "fixture"); + expect(resolveReleaseCheckLocalPackageTarballs(root, false)).toEqual([gatewayTarball]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("writes an explicit local project for unpublished core package tarballs", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-install-test-")); try { - writePackedTarballInstallManifest(root, "/tmp/openclaw.tgz", ["/tmp/openclaw-ai.tgz"]); + writePackedTarballInstallManifest(root, "/tmp/openclaw.tgz", [ + "/tmp/openclaw-ai.tgz", + "/tmp/openclaw-gateway-protocol.tgz", + ]); const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { dependencies?: Record; private?: boolean; @@ -56,6 +72,28 @@ describe("release-check", () => { expect(manifest.private).toBe(true); expect(manifest.dependencies).toEqual({ "@openclaw/ai": "file:///tmp/openclaw-ai.tgz", + "@openclaw/gateway-protocol": "file:///tmp/openclaw-gateway-protocol.tgz", + openclaw: "file:///tmp/openclaw.tgz", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("writes a gateway-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"], + false, + ); + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + dependencies?: Record; + }; + expect(manifest.dependencies).toEqual({ + "@openclaw/gateway-protocol": "file:///tmp/openclaw-gateway-protocol.tgz", openclaw: "file:///tmp/openclaw.tgz", }); } finally { @@ -80,13 +118,18 @@ describe("release-check", () => { } }); - it("prefers the prepared AI tarball over packing the workspace", () => { + it("prefers prepared core package tarballs over packing the AI workspace", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-ai-pack-test-")); try { const preparedDir = join(root, "prepared"); mkdirSync(preparedDir); const preparedTarball = join(preparedDir, "openclaw-ai-2026.7.1-beta.3.tgz"); + const gatewayProtocolTarball = join( + preparedDir, + "openclaw-gateway-protocol-2026.7.1-beta.3.tgz", + ); writeFileSync(preparedTarball, "fixture"); + writeFileSync(gatewayProtocolTarball, "fixture"); const tarballs = prepareReleaseCheckLocalPackageTarballs({ tmpRoot: root, tarballDir: preparedDir, @@ -94,7 +137,7 @@ describe("release-check", () => { throw new Error("workspace pack should not run"); }, }); - expect(tarballs).toEqual([preparedTarball]); + expect(tarballs).toEqual([preparedTarball, gatewayProtocolTarball]); } finally { rmSync(root, { recursive: true, force: true }); } @@ -117,7 +160,7 @@ describe("release-check", () => { } }); - it("rejects missing, empty, or ambiguous local dependency tarball directories", () => { + it("rejects missing, incomplete, or ambiguous local package tarball directories", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-tarball-test-")); try { expect(() => resolveReleaseCheckLocalPackageTarballs(join(root, "missing"))).toThrow( @@ -125,10 +168,14 @@ describe("release-check", () => { ); const empty = join(root, "empty"); mkdirSync(empty); - expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow("contains 0 tarballs"); + expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow( + "must contain exactly one @openclaw/ai tarball", + ); writeFileSync(join(empty, "one.tgz"), "fixture"); writeFileSync(join(empty, "two.tgz"), "fixture"); - expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow("contains 2 tarballs"); + expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow( + "contains an unsupported package tarball", + ); } finally { rmSync(root, { recursive: true, force: true }); }