From 625be01cba9a68c220c71620acd01ea061888111 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:34:07 +0300 Subject: [PATCH 1/2] fix: use OpenClaw session APIs for account routing (v3.10.3) --- .github/workflows/publish-clawhub-plugin.yml | 16 +- .github/workflows/test.yml | 33 +- CHANGELOG.md | 15 + README.md | 4 +- SKILL.md | 6 +- benchmarks.json | 2 +- examples/full-stack.json | 2 +- examples/openai-glm-kimi.json | 2 +- examples/openai-glm.json | 2 +- examples/openai-multi-account.json | 2 +- examples/openai-only.json | 2 +- examples/subscription-profile.json | 2 +- integrations/hermes/plugin.yaml | 2 +- package-lock.json | 6 +- package.json | 2 +- plugin/README.md | 2 +- plugin/__tests__/plugin-entry.test.ts | 12 +- plugin/__tests__/session-auth.test.ts | 486 +++++++++++------- plugin/__tests__/version-sync.test.ts | 2 +- plugin/benchmarks.json | 2 +- plugin/index.ts | 31 +- plugin/openclaw.plugin.json | 2 +- plugin/package.json | 6 +- plugin/session-auth.ts | 398 ++++++-------- plugin/skills/zeroapi/SKILL.md | 2 +- .../__fixtures__/openclaw/zeroapi-config.json | 12 + scripts/__tests__/release-preflight.test.mjs | 1 + scripts/openclaw_compat_smoke.mjs | 80 ++- scripts/openclaw_sdk_contract_witness.ts | 11 + scripts/release_preflight.mjs | 11 + 30 files changed, 697 insertions(+), 459 deletions(-) diff --git a/.github/workflows/publish-clawhub-plugin.yml b/.github/workflows/publish-clawhub-plugin.yml index e45ead4..8bade14 100644 --- a/.github/workflows/publish-clawhub-plugin.yml +++ b/.github/workflows/publish-clawhub-plugin.yml @@ -118,7 +118,18 @@ jobs: throw new Error(`ClawHub exact version ${expected} was not found in version metadata`); } - console.log(`ClawHub metadata verified for zeroapi@${expected}`); + const scanStatus = + inspect.package?.verification?.scanStatus ?? + inspect.package?.scanStatus ?? + inspect.verification?.scanStatus ?? + inspect.scanStatus; + if (scanStatus !== "clean") { + throw new Error( + `ClawHub exact release ${expected} is not scan-clean (status: ${scanStatus ?? "missing"}); refusing automated risk acknowledgement`, + ); + } + + console.log(`ClawHub metadata and clean scan verified for zeroapi@${expected}`); NODE - name: Verify ClawHub package is downloadable @@ -127,8 +138,7 @@ jobs: trap 'rm -rf "$tmp_dir"' EXIT cd "$tmp_dir" for attempt in $(seq 1 10); do - if timeout 300s env npm_config_yes=true npx --yes "openclaw@$OPENCLAW_VERIFY_VERSION" plugins install "clawhub:zeroapi@${{ steps.version.outputs.value }}" \ - --dangerously-force-unsafe-install; then + if timeout 300s env npm_config_yes=true npx --yes "openclaw@$OPENCLAW_VERIFY_VERSION" plugins install "clawhub:zeroapi@${{ steps.version.outputs.value }}"; then exit 0 fi if [ "$attempt" -eq 10 ]; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74044fb..0690e22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,8 +10,6 @@ permissions: contents: read env: - OPENCLAW_COMPAT_VERSION: '2026.7.1-2' - OPENCLAW_COMPAT_INTEGRITY: 'sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==' HERMES_COMPAT_REF: 'v2026.8.3' HERMES_COMPAT_SHA: '3c27eb6234bf91b8ceee9e9071591b31e9b148cb' @@ -39,8 +37,30 @@ jobs: - run: npm test openclaw-compat: + name: openclaw-compat (${{ matrix.label }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + # The minimum host gets a runtime fallback witness, v2026.7.1-2 is the + # typed target-stable contract, and the prerelease proves SQLite. + - version: '2026.5.2' + integrity: 'sha512-Jz00jdV/yE0/RfAln0tVlEq1hX9DIZdc7NRZVQRpt98XINduqoua9uOc1Ty4GeYlg5c746Lhl5ZCe6A4rsOmng==' + label: min-json + layout: json + typecheck: false + - version: '2026.7.1-2' + integrity: 'sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==' + label: stable-json + layout: json + typecheck: true + - version: '2026.8.1-beta.1' + integrity: 'sha512-ugsMNhvfDMQYZa4EpHk+2Y6IdMYRCoe9BiwJVv9ZFFKPeYcgypbMmLvPG1XvMdanixZU+oZb/knLGAoHGREc9Q==' + label: sqlite-beta + layout: sqlite + typecheck: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -49,10 +69,11 @@ jobs: - run: npm ci - name: Install exact OpenClaw package run: | - actual_integrity="$(npm view "openclaw@${OPENCLAW_COMPAT_VERSION}" dist.integrity)" - test "$actual_integrity" = "$OPENCLAW_COMPAT_INTEGRITY" - npm install --no-save --package-lock=false "openclaw@${OPENCLAW_COMPAT_VERSION}" + actual_integrity="$(npm view "openclaw@${{ matrix.version }}" dist.integrity)" + test "$actual_integrity" = "${{ matrix.integrity }}" + npm install --no-save --package-lock=false "openclaw@${{ matrix.version }}" - name: Typecheck adapter and public SDK contract witness + if: matrix.typecheck run: npx tsc --project tsconfig.openclaw-compat.json - name: Stage ClawHub plugin artifact run: node scripts/stage_clawhub_plugin.mjs /tmp/zeroapi-staged @@ -76,7 +97,7 @@ jobs: run: | OPENCLAW_STATE_DIR=/tmp/zeroapi-openclaw-state \ OPENCLAW_CONFIG_PATH=/tmp/zeroapi-openclaw-state/openclaw.json \ - node scripts/openclaw_compat_smoke.mjs /tmp/zeroapi-staged + node scripts/openclaw_compat_smoke.mjs /tmp/zeroapi-staged "${{ matrix.layout }}" hermes-compat: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ae57b..1eadb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +## [3.10.3] - 2026-08-10 + +### Fixed + +- Persist OpenClaw account/profile overrides through the public storage-neutral session API instead of inferring the active backend from `openclaw-agent.sqlite`; this keeps `v2026.7.1-2` JSON sessions working when its auth database is present and follows newer SQLite session storage without touching stale JSON. +- Use OpenClaw's official `auto` ownership marker for generated auth-profile overrides while continuing to recognize and clean up legacy `zeroapi` markers; user-pinned overrides remain untouched. + +### Changed + +- Make the ClawHub post-publish install smoke fail closed unless the exact release reports a clean scan, and remove the deprecated `--dangerously-force-unsafe-install` flag. + +### Tests + +- Exercise account routing against the exact `2026.5.2` legacy public session API, exact `2026.7.1-2` JSON storage, and exact `2026.8.1-beta.1` SQLite storage, including the stable auth-database regression and stale-JSON protection. + ## [3.10.2] - 2026-08-07 ### Fixed diff --git a/README.md b/README.md index 0635202..ec0d366 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Tests](https://github.com/dorukardahan/ZeroAPI/actions/workflows/test.yml/badge.svg)](https://github.com/dorukardahan/ZeroAPI/actions/workflows/test.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![OpenClaw](https://img.shields.io/badge/OpenClaw-2026.5.2+-blue)](https://openclaw.ai) -[![Version](https://img.shields.io/badge/version-3.10.2-green)](https://github.com/dorukardahan/ZeroAPI/releases/tag/v3.10.2) +[![Version](https://img.shields.io/badge/version-3.10.3-green)](https://github.com/dorukardahan/ZeroAPI/releases/tag/v3.10.3) **Your AI subscriptions. One plugin. Routing policy that improves with data.** @@ -170,7 +170,7 @@ ZeroAPI is a source-linked ClawHub package. Before installing from ClawHub, veri - source path: `plugin` - source tag or commit: matches the GitHub release you intend to install -Prefer exact version installs such as `clawhub:zeroapi@3.10.2` instead of an unpinned `latest` install. Do not install mirror packages, standalone skills, or similarly named packages that do not link back to this repository. +Prefer exact version installs such as `clawhub:zeroapi@3.10.3` instead of an unpinned `latest` install. Do not install mirror packages, standalone skills, or similarly named packages that do not link back to this repository. ZeroAPI does not require shell-piped installer commands. The GitHub release workflow publishes the ClawHub package from `plugin/`, verifies ClawHub latest/exact-version metadata, and runs an OpenClaw install smoke test before treating the release as published. diff --git a/SKILL.md b/SKILL.md index 7ffd2ce..704fcd2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: zeroapi -version: 3.10.2 +version: 3.10.3 description: > Route tasks to the best AI model across paid subscriptions via OpenClaw gateway plugin. Use when the user mentions model routing, multi-model setup, "which model should I use", @@ -13,7 +13,7 @@ compatibility: Requires OpenClaw 2026.4.2+ with at least one AI subscription. Cu metadata: {"openclaw":{"emoji":"⚡","category":"routing","os":["darwin","linux"],"requires":{"anyBins":["openclaw"],"config":["agents"]}}} --- -# ZeroAPI v3.10.2 - Plugin-Based Model Routing +# ZeroAPI v3.10.3 - Plugin-Based Model Routing You are configuring an OpenClaw **gateway plugin**. ZeroAPI routes **eligible** messages at runtime through the `before_model_resolve` hook. You do **not** route messages manually. Your job is to inspect the user's setup, generate `zeroapi-config.json`, align `openclaw.json`, install/update the plugin, and verify the result. @@ -235,7 +235,7 @@ Required config shape: ```json { - "version": "3.10.2", + "version": "3.10.3", "generated": "", "benchmarks_date": "", "subscription_catalog_version": "1.1.0", diff --git a/benchmarks.json b/benchmarks.json index 83a1d06..21208b6 100644 --- a/benchmarks.json +++ b/benchmarks.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "source": "Artificial Analysis Data API v2", "api": "https://artificialanalysis.ai/api/v2/data/llms/models", "fetched": "2026-08-09", diff --git a/examples/full-stack.json b/examples/full-stack.json index e1b663a..64844e2 100644 --- a/examples/full-stack.json +++ b/examples/full-stack.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/examples/openai-glm-kimi.json b/examples/openai-glm-kimi.json index d494d25..0d31441 100644 --- a/examples/openai-glm-kimi.json +++ b/examples/openai-glm-kimi.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/examples/openai-glm.json b/examples/openai-glm.json index 7b0d77a..10772ed 100644 --- a/examples/openai-glm.json +++ b/examples/openai-glm.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/examples/openai-multi-account.json b/examples/openai-multi-account.json index 00a35ee..92fadd4 100644 --- a/examples/openai-multi-account.json +++ b/examples/openai-multi-account.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/examples/openai-only.json b/examples/openai-only.json index 2fa34d1..5b0cb4e 100644 --- a/examples/openai-only.json +++ b/examples/openai-only.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/examples/subscription-profile.json b/examples/subscription-profile.json index 2fa34d1..5b0cb4e 100644 --- a/examples/subscription-profile.json +++ b/examples/subscription-profile.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "generated": "2026-08-09T00:00:00.000Z", "benchmarks_date": "2026-08-09", "subscription_catalog_version": "1.1.0", diff --git a/integrations/hermes/plugin.yaml b/integrations/hermes/plugin.yaml index 5ef3024..fb2eabb 100644 --- a/integrations/hermes/plugin.yaml +++ b/integrations/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: zeroapi-router -version: 3.10.2 +version: 3.10.3 description: Benchmark-aware model routing for Hermes Agent using ZeroAPI policy config provides_hooks: - pre_model_route diff --git a/package-lock.json b/package-lock.json index ef58126..d1ef25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zeroapi-workspace", - "version": "3.10.2", + "version": "3.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zeroapi-workspace", - "version": "3.10.2", + "version": "3.10.3", "workspaces": [ "plugin" ], @@ -1218,7 +1218,7 @@ }, "plugin": { "name": "zeroapi", - "version": "3.10.2", + "version": "3.10.3", "devDependencies": { "typescript": "^5.7.0", "vitest": "^3.2.4" diff --git a/package.json b/package.json index 9d52498..852e975 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zeroapi-workspace", - "version": "3.10.2", + "version": "3.10.3", "private": true, "description": "Subscription-aware, benchmark-informed routing policy for the OpenClaw plugin and experimental Hermes Agent adapter", "type": "module", diff --git a/plugin/README.md b/plugin/README.md index c202668..c5ac187 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -55,7 +55,7 @@ The advisory file and logs can still be used for operators who do not want chann Install only from the source-linked package: ```bash -openclaw plugins install clawhub:zeroapi@3.10.2 +openclaw plugins install clawhub:zeroapi@3.10.3 ``` Verify the package source points to: diff --git a/plugin/__tests__/plugin-entry.test.ts b/plugin/__tests__/plugin-entry.test.ts index 24d098f..c43c0e9 100644 --- a/plugin/__tests__/plugin-entry.test.ts +++ b/plugin/__tests__/plugin-entry.test.ts @@ -7,6 +7,10 @@ vi.mock("openclaw/plugin-sdk/plugin-entry", () => ({ definePluginEntry: (entry: unknown) => entry, })); +vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({ + patchSessionEntry: vi.fn(async () => null), +})); + const startSubscriptionAdvisoryMonitor = vi.fn(() => ({ stop: vi.fn() })); const maybePrefixChannelAdvisory = vi.fn(() => null); @@ -187,6 +191,7 @@ describe("plugin entry registration", () => { logRoutingEvent: vi.fn(), })); vi.doMock("../session-auth.js", () => ({ + createSessionEntryPatcher: (runtime: { patchSessionEntry: unknown }) => runtime.patchSessionEntry, syncSessionAuthProfileOverride: () => ({ action: "blocked", reason: "user_pinned_override", @@ -210,7 +215,7 @@ describe("plugin entry registration", () => { const handler = on.mock.calls[0]?.[1]; expect(typeof handler).toBe("function"); - const result = handler( + const result = await handler( { prompt: "coordinate a workflow across 3 services" }, { agentId: "main", @@ -383,6 +388,7 @@ describe("plugin entry registration", () => { logRoutingEvent: vi.fn(), })); vi.doMock("../session-auth.js", () => ({ + createSessionEntryPatcher: (runtime: { patchSessionEntry: unknown }) => runtime.patchSessionEntry, syncSessionAuthProfileOverride: () => ({ action: "noop", reason: "already_current", @@ -406,7 +412,7 @@ describe("plugin entry registration", () => { const handler = on.mock.calls[0]?.[1]; expect(typeof handler).toBe("function"); - handler( + await handler( { prompt: "implement the provider adapter" }, { agentId: "main", @@ -415,7 +421,7 @@ describe("plugin entry registration", () => { sessionKey: "agent:main:signal:dm:u1", }, ); - handler( + await handler( { prompt: "devam et" }, { agentId: "main", diff --git a/plugin/__tests__/session-auth.test.ts b/plugin/__tests__/session-auth.test.ts index 115326c..f04d126 100644 --- a/plugin/__tests__/session-auth.test.ts +++ b/plugin/__tests__/session-auth.test.ts @@ -1,243 +1,385 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { syncSessionAuthProfileOverride } from "../session-auth.js"; - -function writeJson(filePath: string, value: unknown): void { - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + createSessionEntryPatcher, + syncSessionAuthProfileOverride, + type SessionEntry, + type SessionEntryPatcher, +} from "../session-auth.js"; + +const tempDirs: string[] = []; + +function makeTempState(): string { + const dir = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); + tempDirs.push(dir); + return dir; } -function readJson(filePath: string): T { - return JSON.parse(readFileSync(filePath, "utf-8")) as T; +function cloneEntry(entry: SessionEntry): SessionEntry { + return JSON.parse(JSON.stringify(entry)) as SessionEntry; } -describe("syncSessionAuthProfileOverride", () => { - const tempDirs: string[] = []; +function createMemoryPatcher(entries: Record): SessionEntryPatcher { + return vi.fn(async ({ sessionKey, update }) => { + const existing = entries[sessionKey]; + if (!existing) { + return null; + } + const patch = await update(cloneEntry(existing), { + existingEntry: cloneEntry(existing), + }); + if (!patch) { + return cloneEntry(existing); + } + const next = { ...existing, ...patch }; + for (const [key, value] of Object.entries(next)) { + if (value === undefined) { + delete next[key]; + } + } + entries[sessionKey] = next; + return cloneEntry(next); + }); +} - afterEach(() => { - while (tempDirs.length > 0) { - rmSync(tempDirs.pop()!, { recursive: true, force: true }); +function createJsonStorePatcher(storePath: string): SessionEntryPatcher { + return vi.fn(async ({ sessionKey, update }) => { + const store = JSON.parse(readFileSync(storePath, "utf8")) as Record; + const existing = store[sessionKey]; + if (!existing) { + return null; + } + const patch = await update(cloneEntry(existing), { + existingEntry: cloneEntry(existing), + }); + if (!patch) { + return cloneEntry(existing); + } + const next = { ...existing, ...patch }; + for (const [key, value] of Object.entries(next)) { + if (value === undefined) { + delete next[key]; + } } + store[sessionKey] = next; + writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); + return cloneEntry(next); }); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); - it("writes an auto auth profile override into the current session entry", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); - - const openclawDir = join(home, ".openclaw"); - const storePath = join(openclawDir, "agents", "main", "sessions", "sessions.json"); - writeJson(storePath, { - "agent:main:slack:direct:u123": { - sessionId: "session-1", - updatedAt: 1, - compactionCount: 2, +describe("syncSessionAuthProfileOverride", () => { + it("prefers the modern storage-neutral host capability when available", async () => { + const entries: Record = { + "agent:main:main": { sessionId: "modern" }, + }; + const modern = createMemoryPatcher(entries); + const legacy = vi.fn(); + const patchSessionEntry = createSessionEntryPatcher( + { + patchSessionEntry: modern, + resolveStorePath: vi.fn(), + updateSessionStoreEntry: legacy, }, - }); + "/custom/sessions.json", + ); - const result = syncSessionAuthProfileOverride({ - openclawDir, - sessionKey: "agent:main:slack:direct:u123", - authProfileOverride: "openai:work", + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:work", + patchSessionEntry, }); expect(result.action).toBe("updated"); - expect(result.reason).toBe("set_auto_override"); - - const store = readJson>>(storePath); - expect(store["agent:main:slack:direct:u123"]?.authProfileOverride).toBe("openai:work"); - expect(store["agent:main:slack:direct:u123"]?.authProfileOverrideSource).toBe("auto"); - expect(store["agent:main:slack:direct:u123"]?.authProfileOverrideCompactionCount).toBe(2); + expect(modern).toHaveBeenCalledOnce(); + expect(legacy).not.toHaveBeenCalled(); }); - it("clears a previously auto-selected auth profile when no override is needed", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); - - const openclawDir = join(home, ".openclaw"); - const storePath = join(openclawDir, "agents", "main", "sessions", "sessions.json"); - writeJson(storePath, { - "agent:main:main": { - sessionId: "session-2", - updatedAt: 1, - authProfileOverride: "openai:work", - authProfileOverrideSource: "auto", - authProfileOverrideCompactionCount: 3, - }, + it("uses the legacy public JSON-store capability on pre-patchSessionEntry hosts", async () => { + const entries: Record = { + "agent:main:main": { sessionId: "legacy" }, + }; + const resolveStorePath = vi.fn(() => "/resolved/custom-sessions.json"); + const updateSessionStoreEntry = vi.fn(async ({ sessionKey, update }) => { + const existing = entries[sessionKey]; + if (!existing) return null; + const patch = await update(cloneEntry(existing)); + if (!patch) return cloneEntry(existing); + entries[sessionKey] = { ...existing, ...patch }; + return cloneEntry(entries[sessionKey]); }); + const patchSessionEntry = createSessionEntryPatcher( + { resolveStorePath, updateSessionStoreEntry }, + "/custom/sessions.json", + ); - const result = syncSessionAuthProfileOverride({ - openclawDir, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: null, + authProfileOverride: "openai-codex:work", + patchSessionEntry, }); expect(result.action).toBe("updated"); - expect(result.reason).toBe("cleared_auto_override"); - - const store = readJson>>(storePath); - expect(store["agent:main:main"]?.authProfileOverride).toBeUndefined(); - expect(store["agent:main:main"]?.authProfileOverrideSource).toBeUndefined(); - expect(store["agent:main:main"]?.authProfileOverrideCompactionCount).toBeUndefined(); + expect(resolveStorePath).toHaveBeenCalledWith("/custom/sessions.json", { agentId: "main" }); + expect(updateSessionStoreEntry).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + storePath: "/resolved/custom-sessions.json", + }), + ); + expect(entries["agent:main:main"]).toMatchObject({ + authProfileOverride: "openai-codex:work", + authProfileOverrideSource: "auto", + }); }); - it("does not overwrite a user-pinned auth profile", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); + it("uses the host session API on OpenClaw 2026.7.1-2 even when the auth database has a SQLite header", async () => { + const stateDir = makeTempState(); + const storePath = join(stateDir, "agents", "main", "sessions", "sessions.json"); + const authDbPath = join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); + mkdirSync(join(stateDir, "agents", "main", "sessions"), { recursive: true }); + mkdirSync(join(stateDir, "agents", "main", "agent"), { recursive: true }); + writeFileSync( + storePath, + JSON.stringify({ + "agent:main:slack:channel:c1": { + sessionId: "synthetic-session", + updatedAt: 1, + }, + }), + "utf8", + ); + writeFileSync(authDbPath, "SQLite format 3\u0000synthetic-auth-state", "latin1"); + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:slack:channel:c1", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createJsonStorePatcher(storePath), + }); - const openclawDir = join(home, ".openclaw"); - const storePath = join(openclawDir, "agents", "main", "sessions", "sessions.json"); - writeJson(storePath, { - "agent:main:main": { - sessionId: "session-3", - updatedAt: 1, - authProfileOverride: "openai:personal", - authProfileOverrideSource: "user", - }, + expect(result).toMatchObject({ action: "updated", reason: "updated" }); + const stored = JSON.parse(readFileSync(storePath, "utf8")); + expect(stored["agent:main:slack:channel:c1"]).toMatchObject({ + authProfileOverride: "openai-codex:work", + authProfileOverrideSource: "auto", }); + }); - const result = syncSessionAuthProfileOverride({ - openclawDir, + it("writes a supported SQLite-backed session through the host API and leaves stale JSON untouched", async () => { + const stateDir = makeTempState(); + const staleJsonPath = join(stateDir, "sessions.json"); + const staleJson = '{"agent:main:main":{"sessionId":"stale"}}\n'; + writeFileSync(staleJsonPath, staleJson, "utf8"); + const sqliteEntries: Record = { + "agent:main:main": { sessionId: "sqlite-live", updatedAt: 10 }, + }; + const patchSessionEntry = createMemoryPatcher(sqliteEntries); + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: "openai:work", + authProfileOverride: "anthropic:work", + patchSessionEntry, }); - expect(result.action).toBe("blocked"); - expect(result.reason).toBe("user_pinned_override"); - - const store = readJson>>(storePath); - expect(store["agent:main:main"]?.authProfileOverride).toBe("openai:personal"); - expect(store["agent:main:main"]?.authProfileOverrideSource).toBe("user"); + expect(result).toMatchObject({ action: "updated", reason: "updated" }); + expect(sqliteEntries["agent:main:main"]).toMatchObject({ + authProfileOverride: "anthropic:work", + authProfileOverrideSource: "auto", + }); + expect(readFileSync(staleJsonPath, "utf8")).toBe(staleJson); }); - it("resolves custom session.store templates with agentId placeholders", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); + it("fails closed when the host session API rejects an unsupported backend", async () => { + const stateDir = makeTempState(); + const staleJsonPath = join(stateDir, "sessions.json"); + const staleJson = '{"agent:main:main":{"sessionId":"stale"}}\n'; + writeFileSync(staleJsonPath, staleJson, "utf8"); + const patchSessionEntry: SessionEntryPatcher = vi.fn(async () => { + throw new Error("unsupported backend at postgres://user:super-secret@db/private"); + }); - const openclawDir = join(home, ".openclaw"); - writeJson(join(openclawDir, "openclaw.json"), { - session: { - store: "~/.openclaw/custom-sessions/{agentId}/sessions.json", - }, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:work", + patchSessionEntry, }); - const storePath = join(openclawDir, "custom-sessions", "ops", "sessions.json"); - writeJson(storePath, { - "agent:ops:main": { - sessionId: "session-4", - updatedAt: 1, + + expect(result).toEqual({ + action: "skipped", + reason: "session_store_update_failed", + sessionKey: "agent:main:main", + }); + expect(JSON.stringify(result)).not.toContain("super-secret"); + expect(readFileSync(staleJsonPath, "utf8")).toBe(staleJson); + }); + + it("preserves a user-pinned auth profile", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-user-pinned", + authProfileOverride: "openai-codex:personal", + authProfileOverrideSource: "user", }, + }; + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createMemoryPatcher(entries), }); - const result = syncSessionAuthProfileOverride({ - openclawDir, - agentId: "ops", - sessionKey: "agent:ops:main", - authProfileOverride: "zai:ops", + expect(result).toEqual({ + action: "blocked", + reason: "user_pinned_preserved", + sessionKey: "agent:main:main", }); + expect(entries["agent:main:main"]?.authProfileOverride).toBe("openai-codex:personal"); + }); - expect(result.action).toBe("updated"); - expect(result.storePath).toBe(storePath); + it("updates an existing ZeroAPI-managed auth profile", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-auto", + authProfileOverride: "openai-codex:old", + authProfileOverrideSource: "zeroapi", + }, + }; - const store = readJson>>(storePath); - expect(store["agent:ops:main"]?.authProfileOverride).toBe("zai:ops"); - expect(store["agent:ops:main"]?.authProfileOverrideSource).toBe("auto"); - }); + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:new", + patchSessionEntry: createMemoryPatcher(entries), + }); - it("skips the default SQLite backend when session.store is absent and a stale JSON store remains", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); + expect(result.action).toBe("updated"); + expect(entries["agent:main:main"]?.authProfileOverride).toBe("openai-codex:new"); + expect(entries["agent:main:main"]?.authProfileOverrideSource).toBe("auto"); + }); - const openclawDir = join(home, ".openclaw"); - const legacyJsonPath = join(openclawDir, "agents", "main", "sessions", "sessions.json"); - writeJson(legacyJsonPath, { + it("leaves an already-current official auto override unchanged", async () => { + const entries: Record = { "agent:main:main": { - sessionId: "stale-session", - updatedAt: 1, + sessionId: "session-auto", + authProfileOverride: "openai-codex:work", + authProfileOverrideSource: "auto", }, - }); - // Simulate the canonical OpenClaw 2026.7.x SQLite database - const sqlitePath = join(openclawDir, "agents", "main", "agent", "openclaw-agent.sqlite"); - mkdirSync(dirname(sqlitePath), { recursive: true }); - writeFileSync(sqlitePath, "SQLite format 3\u0000", "latin1"); + }; - const result = syncSessionAuthProfileOverride({ - openclawDir, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: "openai:work", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createMemoryPatcher(entries), }); - expect(result.action).toBe("skipped"); - expect(result.reason).toBe("session_store_non_json_backend"); - - // The stale JSON file must NOT have been written to - const store = readJson>>(legacyJsonPath); - expect(store["agent:main:main"]?.authProfileOverride).toBeUndefined(); + expect(result).toMatchObject({ action: "noop", reason: "already_current" }); }); - it("skips auth-profile routing when session.store is an explicit SQLite URI", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); - - const openclawDir = join(home, ".openclaw"); - writeJson(join(openclawDir, "openclaw.json"), { - session: { - store: "sqlite:./sessions.db", + it("clears only a ZeroAPI-managed auth profile", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-auto", + authProfileOverride: "openai-codex:old", + authProfileOverrideSource: "zeroapi", }, - }); + }; - const result = syncSessionAuthProfileOverride({ - openclawDir, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: "openai:work", + authProfileOverride: null, + patchSessionEntry: createMemoryPatcher(entries), }); - expect(result.action).toBe("skipped"); - expect(result.reason).toBe("session_store_non_json_backend"); + expect(result).toMatchObject({ action: "updated", reason: "cleared" }); + expect(entries["agent:main:main"]).not.toHaveProperty("authProfileOverride"); + expect(entries["agent:main:main"]).not.toHaveProperty("authProfileOverrideSource"); }); - it("skips auth-profile routing when session.store is a .sqlite file path", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); - - const openclawDir = join(home, ".openclaw"); - writeJson(join(openclawDir, "openclaw.json"), { - session: { - store: "~/.openclaw/sessions.sqlite3", + it("does not clear a user-pinned auth profile", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-user-pinned", + authProfileOverride: "openai-codex:personal", + authProfileOverrideSource: "user", }, - }); + }; - const result = syncSessionAuthProfileOverride({ - openclawDir, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: "openai:work", + authProfileOverride: null, + patchSessionEntry: createMemoryPatcher(entries), }); - expect(result.action).toBe("skipped"); - expect(result.reason).toBe("session_store_non_json_backend"); + expect(result).toMatchObject({ action: "noop", reason: "no_auto_override_to_clear" }); + expect(entries["agent:main:main"]?.authProfileOverride).toBe("openai-codex:personal"); }); - it("still routes auth-profile overrides when no SQLite backend or non-JSON store exists", () => { - const home = mkdtempSync(join(tmpdir(), "zeroapi-session-auth-")); - tempDirs.push(home); + it("returns a clear skip reason when the session key is missing", async () => { + const patchSessionEntry = createMemoryPatcher({}); - const openclawDir = join(home, ".openclaw"); - const storePath = join(openclawDir, "agents", "main", "sessions", "sessions.json"); - writeJson(storePath, { - "agent:main:main": { - sessionId: "session-ok", - updatedAt: 1, - }, + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + authProfileOverride: "openai-codex:work", + patchSessionEntry, }); - // No SQLite database, no configured store — default JSON path should work - const result = syncSessionAuthProfileOverride({ - openclawDir, + expect(result).toEqual({ action: "skipped", reason: "session_key_missing" }); + expect(patchSessionEntry).not.toHaveBeenCalled(); + }); + + it("does not synthesize a session entry when the host cannot find one", async () => { + const patchSessionEntry = createMemoryPatcher({}); + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:missing", + authProfileOverride: "openai-codex:work", + patchSessionEntry, + }); + + expect(result).toEqual({ + action: "skipped", + reason: "session_entry_missing", + sessionKey: "agent:main:missing", + }); + }); + + it("does not treat a JSONL transcript as session-store evidence", async () => { + const stateDir = makeTempState(); + const transcriptPath = join(stateDir, "synthetic-session.jsonl"); + writeFileSync(transcriptPath, '{"type":"message","content":"synthetic"}\n', "utf8"); + const before = readFileSync(transcriptPath, "utf8"); + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", sessionKey: "agent:main:main", - authProfileOverride: "openai:work", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createMemoryPatcher({}), }); - expect(result.action).toBe("updated"); - expect(result.reason).toBe("set_auto_override"); + expect(result.reason).toBe("session_entry_missing"); + expect(readFileSync(transcriptPath, "utf8")).toBe(before); }); }); diff --git a/plugin/__tests__/version-sync.test.ts b/plugin/__tests__/version-sync.test.ts index d84a004..8ce2565 100644 --- a/plugin/__tests__/version-sync.test.ts +++ b/plugin/__tests__/version-sync.test.ts @@ -72,7 +72,7 @@ describe("version sync", () => { expect(packageJson.openclaw?.build).toEqual({ openclawVersion: "2026.5.2", pluginSdkVersion: "2026.5.2", - lastAuditedOpenClaw: "2026.7.1-2", + lastAuditedOpenClaw: "2026.8.1-beta.1", }); }); }); diff --git a/plugin/benchmarks.json b/plugin/benchmarks.json index 83a1d06..21208b6 100644 --- a/plugin/benchmarks.json +++ b/plugin/benchmarks.json @@ -1,5 +1,5 @@ { - "version": "3.10.2", + "version": "3.10.3", "source": "Artificial Analysis Data API v2", "api": "https://artificialanalysis.ai/api/v2/data/llms/models", "fetched": "2026-08-09", diff --git a/plugin/index.ts b/plugin/index.ts index 17b7bfa..9c26172 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,16 +1,21 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import * as sessionStoreRuntime from "openclaw/plugin-sdk/session-store-runtime"; import { loadConfig, getConfigLoadStatus } from "./config.js"; import { resolveRoutingDecision } from "./decision.js"; import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { initLogger, logRouting, logRoutingEvent } from "./logger.js"; -import { syncSessionAuthProfileOverride } from "./session-auth.js"; +import { + createSessionEntryPatcher, + syncSessionAuthProfileOverride, + type HostSessionStoreRuntime, +} from "./session-auth.js"; import { startSubscriptionAdvisoryMonitor } from "./subscription-advisory.js"; import { maybePrefixChannelAdvisory } from "./advisory-delivery.js"; import { readPreviousCategory, recordRouteCategory } from "./route-state.js"; import type { TaskCategory } from "./types.js"; -const PLUGIN_VERSION = "3.10.2"; +const PLUGIN_VERSION = "3.10.3"; const REGISTER_STATE_KEY = Symbol.for("zeroapi-router.register-state"); type RegisterState = { @@ -69,6 +74,16 @@ export default definePluginEntry({ register(api) { const openclawDir = resolveOpenClawDir(); + const hostConfig = (api as typeof api & { + config?: { session?: { store?: unknown } }; + }).config; + const configuredSessionStore = typeof hostConfig?.session?.store === "string" + ? hostConfig.session.store + : undefined; + const patchSessionEntry = createSessionEntryPatcher( + sessionStoreRuntime as unknown as HostSessionStoreRuntime, + configuredSessionStore, + ); const config = loadConfig(openclawDir); initLogger(openclawDir); @@ -127,7 +142,7 @@ export default definePluginEntry({ logRoutingEvent({ category: "system", reason: `runtime_config_check_failed:${message}` }); } - api.on("before_model_resolve", (event, ctx) => { + api.on("before_model_resolve", async (event, ctx) => { const currentModel = ctx.modelId ? `${ctx.modelProviderId}/${ctx.modelId}` : config.default_model; @@ -161,11 +176,11 @@ export default definePluginEntry({ resolution.action === "route" || (resolution.action === "stay" && resolution.reason.includes("no_switch_needed")); if (shouldSyncSessionAuth) { - const syncResult = syncSessionAuthProfileOverride({ - openclawDir, + const syncResult = await syncSessionAuthProfileOverride({ agentId: ctx.agentId, sessionKey: "sessionKey" in ctx ? ctx.sessionKey : undefined, authProfileOverride: resolution.authProfileOverride, + patchSessionEntry, }); if (syncResult.action === "blocked") { runtimeAuthProfileOverride = null; @@ -176,6 +191,12 @@ export default definePluginEntry({ if (syncResult.reason === "user_pinned_preserved") { runtimeAuthProfileOverride = null; } + if (syncResult.reason === "session_store_update_failed") { + runtimeAuthProfileOverride = null; + api.logger.warn( + "ZeroAPI could not persist the auth-profile override through OpenClaw's session API; model routing remains active." + ); + } if ( syncResult.reason !== "already_current" && syncResult.reason !== "no_auto_override_to_clear" && diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1e892a3..aa403e0 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "zeroapi-router", "name": "ZeroAPI Router", "description": "Transparent subscription-aware model and account routing for OpenClaw", - "version": "3.10.2", + "version": "3.10.3", "activation": { "onStartup": true }, diff --git a/plugin/package.json b/plugin/package.json index c1be687..1d442ec 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "zeroapi", - "version": "3.10.2", + "version": "3.10.3", "private": true, "description": "ZeroAPI - transparent subscription-aware model and account routing for OpenClaw", "type": "module", @@ -19,12 +19,12 @@ "compat": { "pluginApi": ">=2026.5.2", "minGatewayVersion": "2026.5.2", - "notes": "Model/provider routing is fully compatible. Auth-profile/account routing requires a JSON-backed session store; non-JSON backends (e.g. SQLite) are detected and disabled gracefully, including the default per-agent SQLite backend introduced in OpenClaw 2026.7.x." + "notes": "Model/provider routing is fully compatible. Auth-profile/account choices are persisted through OpenClaw's public session-store API across the v2026.7.1-2 JSON layout and newer SQLite layout. Upstream before_model_resolve does not expose a same-turn authProfileOverride result field; same-turn selection still requires a compatible host extension." }, "build": { "openclawVersion": "2026.5.2", "pluginSdkVersion": "2026.5.2", - "lastAuditedOpenClaw": "2026.7.1-2" + "lastAuditedOpenClaw": "2026.8.1-beta.1" } }, "devDependencies": { diff --git a/plugin/session-auth.ts b/plugin/session-auth.ts index bb1c796..989c37e 100644 --- a/plugin/session-auth.ts +++ b/plugin/session-auth.ts @@ -1,276 +1,192 @@ -import { randomUUID } from "node:crypto"; -import { - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, - readSync, - renameSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, resolve } from "node:path"; - -type SessionEntry = { - sessionId?: string; - updatedAt?: number; - compactionCount?: number; +export type SessionEntry = { authProfileOverride?: string; - authProfileOverrideSource?: "auto" | "user"; - authProfileOverrideCompactionCount?: number; + authProfileOverrideSource?: string; [key: string]: unknown; }; -type SessionStore = Record; +type SessionEntryPatch = Partial | null; -export type SessionAuthSyncResult = - | { - action: "updated" | "unchanged" | "blocked" | "skipped"; - reason: string; - storePath?: string; - sessionKey?: string; - }; +type PatchSessionEntryParams = { + agentId?: string; + sessionKey: string; + preserveActivity?: boolean; + update: ( + entry: SessionEntry, + context: { existingEntry?: SessionEntry }, + ) => Promise | SessionEntryPatch; +}; + +/** + * Storage-neutral OpenClaw SDK contract implemented by + * `openclaw/plugin-sdk/session-store-runtime`. + */ +export type SessionEntryPatcher = ( + params: PatchSessionEntryParams, +) => Promise; + +export type HostSessionStoreRuntime = { + patchSessionEntry?: SessionEntryPatcher; + resolveStorePath: ( + store?: string, + options?: { agentId?: string }, + ) => string; + updateSessionStoreEntry: (params: { + sessionKey: string; + storePath: string; + update: (entry: SessionEntry) => Promise; + }) => Promise; +}; + +export type SessionAuthSyncReason = + | "updated" + | "cleared" + | "already_current" + | "no_auto_override_to_clear" + | "user_pinned_preserved" + | "session_key_missing" + | "session_entry_missing" + | "session_store_update_failed"; + +export type SessionAuthSyncResult = { + action: "updated" | "noop" | "blocked" | "skipped"; + reason: SessionAuthSyncReason; + sessionKey?: string; +}; type SyncSessionAuthProfileParams = { - openclawDir: string; agentId?: string; sessionKey?: string; authProfileOverride?: string | null; + patchSessionEntry: SessionEntryPatcher; }; -const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; -const INVALID_AGENT_CHARS_RE = /[^a-z0-9_-]+/g; - function normalizeString(value: unknown): string | null { - if (typeof value !== "string") return null; + if (typeof value !== "string") { + return null; + } const trimmed = value.trim(); return trimmed || null; } -function normalizeAgentId(value: string | null | undefined): string { - const trimmed = normalizeString(value); - if (!trimmed) return "main"; - if (VALID_AGENT_ID_RE.test(trimmed)) { - return trimmed.toLowerCase(); - } - return trimmed.toLowerCase().replace(INVALID_AGENT_CHARS_RE, "-").replace(/^-+|-+$/g, "") || "main"; -} - -function resolveAgentId(params: { agentId?: string; sessionKey?: string }): string { - const explicit = normalizeString(params.agentId); - if (explicit) { - return normalizeAgentId(explicit); - } - - const sessionKey = normalizeString(params.sessionKey); - if (!sessionKey) { - return "main"; - } - - const match = /^agent:([^:]+):/i.exec(sessionKey); - return normalizeAgentId(match?.[1]); +function isAutoManagedSource(value: string | null): boolean { + return value === "auto" || value === "zeroapi"; } -function resolveHomeDir(openclawDir: string): string { - return dirname(resolve(openclawDir)); -} - -const SQLITE_FILE_HEADER = "SQLite format 3\u0000"; - /** - * Detect the canonical per-agent SQLite backend introduced in OpenClaw 2026.7.x. - * - * When `session.store` is absent from openclaw.json, OpenClaw 2026.7.x creates - * a per-agent SQLite database at `agents//agent/openclaw-agent.sqlite`. - * The legacy fallback to `agents//sessions/sessions.json` is now dead, but - * a stale JSON file from a pre-SQLite version may still exist and look writable. - * Without this check, ZeroAPI would silently write auth-profile overrides into - * a dead JSON file while the live store is SQLite — a silent no-op. - * - * Detection is by the 16-byte SQLite file header, not file extension alone. + * Adapt the host's public session-store capabilities without requiring a named + * export that older supported OpenClaw versions do not provide. */ -function hasCanonicalSqliteSessionBackend(openclawDir: string, agentId: string): boolean { - const databasePath = join(openclawDir, "agents", agentId, "agent", "openclaw-agent.sqlite"); - let fd: number | undefined; - try { - fd = openSync(databasePath, "r"); - const header = Buffer.alloc(16); - const bytesRead = readSync(fd, header, 0, 16, 0); - return bytesRead === 16 && header.toString("latin1") === SQLITE_FILE_HEADER; - } catch { - return false; - } finally { - if (fd !== undefined) { - try { - closeSync(fd); - } catch { - // already closed - } - } - } +export function createSessionEntryPatcher( + runtime: HostSessionStoreRuntime, + configuredStore?: string, +): SessionEntryPatcher { + if (typeof runtime.patchSessionEntry === "function") { + return (params) => runtime.patchSessionEntry!(params); + } + + return async ({ agentId, sessionKey, update }) => { + const storePath = runtime.resolveStorePath(configuredStore, { + ...(agentId ? { agentId } : {}), + }); + return await runtime.updateSessionStoreEntry({ + sessionKey, + storePath, + update: async (entry) => + await update(entry, { + existingEntry: { ...entry }, + }), + }); + }; } /** - * Detect non-JSON session backends configured explicitly via `session.store`. - * Returns the reason string for the skip result, or null when the store path - * is JSON-compatible. - */ -function detectNonJsonConfiguredStore(configured: string): string | null { - const lower = configured.toLowerCase(); - // URI-style: sqlite:, sqlite2:, sqlite3:, postgres:, mysql:, redis:, level: - if (/^(sqlite[23]?:|postgres(?:ql)?:|mysql:|redis:|level:)/i.test(lower)) { - return "session_store_non_json_backend"; - } - // File extension: .sqlite, .sqlite2, .sqlite3, .db, .duckdb, .fdb - if (/\.(sqlite[23]?|db|duckdb|fdb)$/i.test(lower)) { - return "session_store_non_json_backend"; - } - return null; -} - -function expandHomePrefix(value: string, homeDir: string): string { - if (value === "~") return homeDir; - if (value.startsWith("~/")) { - return join(homeDir, value.slice(2)); - } - return value; -} - -function resolveConfiguredStorePath(openclawDir: string, agentId: string): { path: string | null; nonJsonBackend: boolean } { - if (hasCanonicalSqliteSessionBackend(openclawDir, agentId)) { - return { path: null, nonJsonBackend: true }; - } - - const configPath = join(openclawDir, "openclaw.json"); - if (!existsSync(configPath)) { - return { path: null, nonJsonBackend: false }; - } - - try { - const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { - session?: { store?: unknown }; - }; - const configured = normalizeString(parsed?.session?.store); - if (!configured) { - return { path: null, nonJsonBackend: false }; - } - - if (detectNonJsonConfiguredStore(configured)) { - return { path: null, nonJsonBackend: true }; - } - - const expanded = expandHomePrefix(configured.replaceAll("{agentId}", agentId), resolveHomeDir(openclawDir)); - return { path: resolve(openclawDir, expanded), nonJsonBackend: false }; - } catch { - return { path: null, nonJsonBackend: false }; - } -} - -function resolveSessionStorePath(openclawDir: string, agentId: string): string { - return ( - resolveConfiguredStorePath(openclawDir, agentId).path ?? - join(openclawDir, "agents", agentId, "sessions", "sessions.json") - ); -} - -/** - * Check whether the configured session backend is non-JSON (e.g. SQLite), - * which means auth-profile routing must be disabled gracefully. + * Persist ZeroAPI's auth-profile choice through OpenClaw's public, + * storage-neutral session API. + * + * OpenClaw v2026.7.1-2 stores sessions in sessions.json even though the same + * agent already has an openclaw-agent.sqlite database for auth/cache/memory + * state. Newer OpenClaw versions route this API to SQLite session tables. By + * delegating to the host contract instead of probing files or writing raw + * storage, ZeroAPI follows either backend without guessing or bypassing locks. */ -function isNonJsonSessionBackend(openclawDir: string, agentId: string): boolean { - return resolveConfiguredStorePath(openclawDir, agentId).nonJsonBackend; -} - -function readSessionStore(storePath: string): SessionStore | null { - if (!existsSync(storePath)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(storePath, "utf-8")) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - return parsed as SessionStore; - } catch { - return null; - } -} - -function writeSessionStore(storePath: string, store: SessionStore): void { - mkdirSync(dirname(storePath), { recursive: true }); - const tmpPath = `${storePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; - writeFileSync(tmpPath, `${JSON.stringify(store, null, 2)}\n`, "utf-8"); - renameSync(tmpPath, storePath); -} - -function shouldTreatAsUserPinned(entry: SessionEntry): boolean { - return entry.authProfileOverrideSource === "user" && Boolean(normalizeString(entry.authProfileOverride)); -} - -export function syncSessionAuthProfileOverride( +export async function syncSessionAuthProfileOverride( params: SyncSessionAuthProfileParams, -): SessionAuthSyncResult { +): Promise { const sessionKey = normalizeString(params.sessionKey); if (!sessionKey) { - return { action: "skipped", reason: "missing_session_key" }; - } - - const agentId = resolveAgentId({ agentId: params.agentId, sessionKey }); - if (isNonJsonSessionBackend(params.openclawDir, agentId)) { - return { action: "skipped", reason: "session_store_non_json_backend", sessionKey }; - } - const storePath = resolveSessionStorePath(params.openclawDir, agentId); - const store = readSessionStore(storePath); - if (!store) { - return { action: "skipped", reason: "session_store_unavailable", storePath, sessionKey }; - } - - const current = store[sessionKey]; - if (!current || typeof current !== "object") { - return { action: "skipped", reason: "session_entry_missing", storePath, sessionKey }; + return { action: "skipped", reason: "session_key_missing" }; } const targetProfile = normalizeString(params.authProfileOverride); - if (shouldTreatAsUserPinned(current)) { - if (!targetProfile || current.authProfileOverride === targetProfile) { - return { action: "unchanged", reason: "user_pinned_preserved", storePath, sessionKey }; - } - return { action: "blocked", reason: "user_pinned_override", storePath, sessionKey }; - } + let updateObserved = false; + let outcome: SessionAuthSyncResult = { + action: "skipped", + reason: "session_entry_missing", + sessionKey, + }; - if (!targetProfile) { - if (current.authProfileOverrideSource !== "auto" || !normalizeString(current.authProfileOverride)) { - return { action: "unchanged", reason: "no_auto_override_to_clear", storePath, sessionKey }; + try { + const persisted = await params.patchSessionEntry({ + ...(normalizeString(params.agentId) ? { agentId: normalizeString(params.agentId)! } : {}), + sessionKey, + preserveActivity: true, + update: (entry) => { + updateObserved = true; + const currentProfile = normalizeString(entry.authProfileOverride); + const currentSource = normalizeString(entry.authProfileOverrideSource); + + if (targetProfile) { + if (currentProfile && !isAutoManagedSource(currentSource)) { + outcome = { + action: "blocked", + reason: "user_pinned_preserved", + sessionKey, + }; + return null; + } + if (currentProfile === targetProfile && currentSource === "auto") { + outcome = { action: "noop", reason: "already_current", sessionKey }; + return null; + } + outcome = { action: "updated", reason: "updated", sessionKey }; + return { + authProfileOverride: targetProfile, + authProfileOverrideSource: "auto", + }; + } + + if (!isAutoManagedSource(currentSource)) { + outcome = { + action: "noop", + reason: "no_auto_override_to_clear", + sessionKey, + }; + return null; + } + + outcome = { action: "updated", reason: "cleared", sessionKey }; + return { + authProfileOverride: undefined, + authProfileOverrideSource: undefined, + }; + }, + }); + + if (!updateObserved || !persisted) { + return { + action: "skipped", + reason: "session_entry_missing", + sessionKey, + }; } - - delete current.authProfileOverride; - delete current.authProfileOverrideSource; - delete current.authProfileOverrideCompactionCount; - current.updatedAt = Date.now(); - store[sessionKey] = current; - writeSessionStore(storePath, store); - return { action: "updated", reason: "cleared_auto_override", storePath, sessionKey }; - } - - const compactionCount = - typeof current.compactionCount === "number" && Number.isFinite(current.compactionCount) - ? current.compactionCount - : 0; - - if ( - current.authProfileOverride === targetProfile && - current.authProfileOverrideSource === "auto" && - current.authProfileOverrideCompactionCount === compactionCount - ) { - return { action: "unchanged", reason: "already_current", storePath, sessionKey }; + return outcome; + } catch { + // Do not project host errors: backend paths may contain private locations, + // credentials, or provider-specific connection details. + return { + action: "skipped", + reason: "session_store_update_failed", + sessionKey, + }; } - - current.authProfileOverride = targetProfile; - current.authProfileOverrideSource = "auto"; - current.authProfileOverrideCompactionCount = compactionCount; - current.updatedAt = Date.now(); - store[sessionKey] = current; - writeSessionStore(storePath, store); - return { action: "updated", reason: "set_auto_override", storePath, sessionKey }; } diff --git a/plugin/skills/zeroapi/SKILL.md b/plugin/skills/zeroapi/SKILL.md index 7c291a6..6516e3b 100644 --- a/plugin/skills/zeroapi/SKILL.md +++ b/plugin/skills/zeroapi/SKILL.md @@ -1,6 +1,6 @@ --- name: zeroapi -version: 3.10.2 +version: 3.10.3 description: Configure the ZeroAPI OpenClaw plugin for subscription-aware model routing. Use when the user runs /zeroapi, asks to set up model routing, pastes the ZeroAPI repo URL, or asks what the repo does or whether it would help. user-invocable: true metadata: {"openclaw":{"emoji":"⚡","category":"routing","os":["darwin","linux"],"requires":{"anyBins":["openclaw"],"config":["agents"]}}} diff --git a/scripts/__fixtures__/openclaw/zeroapi-config.json b/scripts/__fixtures__/openclaw/zeroapi-config.json index 8f42e07..203d6be 100644 --- a/scripts/__fixtures__/openclaw/zeroapi-config.json +++ b/scripts/__fixtures__/openclaw/zeroapi-config.json @@ -3,6 +3,18 @@ "generated": "2026-05-30T00:00:00Z", "benchmarks_date": "2026-05-30", "subscription_catalog_version": "1.0.0", + "subscription_inventory": { + "version": "1.1.0", + "accounts": { + "openai-ci": { + "provider": "openai-codex", + "tierId": "pro", + "authProfile": "openai-codex:ci", + "usagePriority": 1, + "intendedUse": ["code"] + } + } + }, "routing_mode": "balanced", "routing_modifier": "coding-aware", "default_model": "zai/glm-5.1", diff --git a/scripts/__tests__/release-preflight.test.mjs b/scripts/__tests__/release-preflight.test.mjs index 6bf6074..cc00717 100644 --- a/scripts/__tests__/release-preflight.test.mjs +++ b/scripts/__tests__/release-preflight.test.mjs @@ -25,6 +25,7 @@ const PREFLIGHT_INPUTS = [ "plugin/README.md", "scripts/stage_clawhub_plugin.mjs", "scripts/refresh_benchmarks.py", + ".github/workflows/publish-clawhub-plugin.yml", "benchmarks.json", "plugin/benchmarks.json", "examples/openai-only.json", diff --git a/scripts/openclaw_compat_smoke.mjs b/scripts/openclaw_compat_smoke.mjs index a2b9bf8..21c3f3c 100644 --- a/scripts/openclaw_compat_smoke.mjs +++ b/scripts/openclaw_compat_smoke.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import * as sessionStoreRuntime from "openclaw/plugin-sdk/session-store-runtime"; function fail(message) { console.error(`OpenClaw compatibility smoke failed: ${message}`); @@ -9,11 +10,48 @@ function fail(message) { } const stagedPluginDir = resolve(process.argv[2] || "/tmp/zeroapi-staged"); +const expectedLayout = process.argv[3] || "json"; +if (expectedLayout !== "json" && expectedLayout !== "sqlite") { + fail(`unsupported expected session layout: ${expectedLayout}`); +} const entryPath = resolve(stagedPluginDir, "index.js"); if (!existsSync(entryPath)) { fail(`staged plugin entry is missing: ${entryPath}`); } +const stateDir = resolve(process.env.OPENCLAW_STATE_DIR || "/tmp/zeroapi-openclaw-state"); +const sessionKey = "agent:main:main"; +const jsonStorePath = join(stateDir, "agents", "main", "sessions", "sessions.json"); +const authDatabasePath = join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); +mkdirSync(dirname(jsonStorePath), { recursive: true }); +mkdirSync(dirname(authDatabasePath), { recursive: true }); +const syntheticEntry = { + sessionId: "zeroapi-openclaw-compat", + updatedAt: Date.now(), +}; +if (typeof sessionStoreRuntime.upsertSessionEntry === "function") { + await sessionStoreRuntime.upsertSessionEntry({ + agentId: "main", + sessionKey, + entry: syntheticEntry, + }); +} else { + const legacyStorePath = sessionStoreRuntime.resolveStorePath(undefined, { agentId: "main" }); + await sessionStoreRuntime.updateSessionStore(legacyStorePath, (store) => { + store[sessionKey] = syntheticEntry; + }); +} +let staleJsonBefore; +if (expectedLayout === "json") { + writeFileSync(authDatabasePath, "SQLite format 3\u0000synthetic-auth-state", "latin1"); +} else { + if (!existsSync(authDatabasePath)) { + fail("SQLite host did not create its canonical agent database"); + } + staleJsonBefore = '{"agent:main:main":{"sessionId":"stale-json"}}\n'; + writeFileSync(jsonStorePath, staleJsonBefore, "utf8"); +} + const registered = new Map(); const warnings = []; const api = { @@ -42,19 +80,47 @@ for (const hookName of ["before_model_resolve", "message_sending"]) { } } -const route = registered.get("before_model_resolve")( +const route = await registered.get("before_model_resolve")( { prompt: "implement a compatibility regression test" }, { agentId: "main", modelId: "glm-5.1", modelProviderId: "zai", - sessionKey: "agent:main:main", + sessionKey, }, ); if (route?.providerOverride !== "openai-codex" || route?.modelOverride !== "gpt-5.4") { fail(`unexpected route: ${JSON.stringify(route)}`); } +const persistedEntry = typeof sessionStoreRuntime.getSessionEntry === "function" + ? sessionStoreRuntime.getSessionEntry({ + agentId: "main", + readConsistency: "latest", + sessionKey, + }) + : sessionStoreRuntime.loadSessionStore( + sessionStoreRuntime.resolveStorePath(undefined, { agentId: "main" }), + { skipCache: true }, + )[sessionKey]; +if ( + persistedEntry?.authProfileOverride !== "openai-codex:ci" || + persistedEntry?.authProfileOverrideSource !== "auto" +) { + fail("host session API did not persist the synthetic account route"); +} +if (expectedLayout === "json") { + if (!existsSync(jsonStorePath)) { + fail("exact host did not use its documented sessions.json store layout"); + } + const storedJson = JSON.parse(readFileSync(jsonStorePath, "utf8")); + if (storedJson?.[sessionKey]?.authProfileOverride !== "openai-codex:ci") { + fail("exact host JSON store does not contain the synthetic account route"); + } +} else if (readFileSync(jsonStorePath, "utf8") !== staleJsonBefore) { + fail("SQLite host route mutated the stale sessions.json fixture"); +} + console.log( JSON.stringify({ status: "ok", @@ -63,6 +129,12 @@ console.log( providerOverride: route.providerOverride, modelOverride: route.modelOverride, }, + sessionStore: { + accountRouting: "persisted", + authDatabasePresent: existsSync(authDatabasePath), + layout: expectedLayout === "json" ? "sessions.json" : "sqlite", + staleJsonUntouched: expectedLayout === "sqlite" ? true : undefined, + }, warnings, }), ); diff --git a/scripts/openclaw_sdk_contract_witness.ts b/scripts/openclaw_sdk_contract_witness.ts index 8e2b79f..af15308 100644 --- a/scripts/openclaw_sdk_contract_witness.ts +++ b/scripts/openclaw_sdk_contract_witness.ts @@ -1,4 +1,5 @@ import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { patchSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import type { PluginHookBeforeModelResolveResult, PluginHookMessageSendingResult, @@ -15,6 +16,15 @@ const unsupportedAuthResult: PluginHookBeforeModelResolveResult = { }; const validMessageResult: PluginHookMessageSendingResult = { content: "ok" }; +const sessionPatchWitness = patchSessionEntry({ + agentId: "main", + preserveActivity: true, + sessionKey: "agent:main:main", + update: (entry) => ({ + authProfileOverride: entry.authProfileOverride ?? "openai-codex:default", + authProfileOverrideSource: "auto", + }), +}); const entry = definePluginEntry({ id: "zeroapi-contract-witness", name: "ZeroAPI contract witness", @@ -25,4 +35,5 @@ const entry = definePluginEntry({ }, }); void entry; +void sessionPatchWitness; void unsupportedAuthResult; diff --git a/scripts/release_preflight.mjs b/scripts/release_preflight.mjs index 64c417f..e0ad9cd 100755 --- a/scripts/release_preflight.mjs +++ b/scripts/release_preflight.mjs @@ -77,4 +77,15 @@ assert( "root and plugin benchmark snapshots must be byte-identical", ); +const clawHubWorkflow = readText(join(repoRoot, ".github", "workflows", "publish-clawhub-plugin.yml")); +assert( + !clawHubWorkflow.includes("--dangerously-force-unsafe-install"), + "ClawHub install smoke must not use the deprecated dangerous-force flag", +); +assert( + clawHubWorkflow.includes('scanStatus !== "clean"') && + clawHubWorkflow.includes("refusing automated risk acknowledgement"), + "ClawHub install smoke must fail closed unless the exact release scan is clean", +); + console.log(`ZeroAPI release preflight ok for ${version}`); From 82bbceb44e2099dbc32cf8de5969c4b4b5012c4d Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:58:14 +0300 Subject: [PATCH 2/2] fix: preserve authProfileOverrideCompactionCount in session patch --- plugin/__tests__/session-auth.test.ts | 64 +++++++++++++++++++++++++++ plugin/session-auth.ts | 30 ++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/plugin/__tests__/session-auth.test.ts b/plugin/__tests__/session-auth.test.ts index f04d126..39b87b2 100644 --- a/plugin/__tests__/session-auth.test.ts +++ b/plugin/__tests__/session-auth.test.ts @@ -282,6 +282,8 @@ describe("syncSessionAuthProfileOverride", () => { sessionId: "session-auto", authProfileOverride: "openai-codex:work", authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 0, + compactionCount: 0, }, }; @@ -382,4 +384,66 @@ describe("syncSessionAuthProfileOverride", () => { expect(result.reason).toBe("session_entry_missing"); expect(readFileSync(transcriptPath, "utf8")).toBe(before); }); + + it("persists authProfileOverrideCompactionCount when setting an override", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-compaction", + compactionCount: 3, + }, + }; + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createMemoryPatcher(entries), + }); + + expect(result.action).toBe("updated"); + expect(entries["agent:main:main"]?.authProfileOverrideCompactionCount).toBe(3); + }); + + it("updates the compaction marker when compactionCount advances", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-advanced-compaction", + authProfileOverride: "openai-codex:work", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + compactionCount: 5, + }, + }; + + const result = await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: "openai-codex:work", + patchSessionEntry: createMemoryPatcher(entries), + }); + + expect(result.action).toBe("updated"); + expect(entries["agent:main:main"]?.authProfileOverrideCompactionCount).toBe(5); + }); + + it("removes the compaction marker when clearing an auto override", async () => { + const entries: Record = { + "agent:main:main": { + sessionId: "session-clear-compaction", + authProfileOverride: "openai-codex:old", + authProfileOverrideSource: "zeroapi", + authProfileOverrideCompactionCount: 2, + compactionCount: 2, + }, + }; + + await syncSessionAuthProfileOverride({ + agentId: "main", + sessionKey: "agent:main:main", + authProfileOverride: null, + patchSessionEntry: createMemoryPatcher(entries), + }); + + expect(entries["agent:main:main"]).not.toHaveProperty("authProfileOverrideCompactionCount"); + }); }); diff --git a/plugin/session-auth.ts b/plugin/session-auth.ts index 989c37e..cabdb7a 100644 --- a/plugin/session-auth.ts +++ b/plugin/session-auth.ts @@ -1,6 +1,8 @@ export type SessionEntry = { authProfileOverride?: string; authProfileOverrideSource?: string; + authProfileOverrideCompactionCount?: number; + compactionCount?: number; [key: string]: unknown; }; @@ -72,6 +74,13 @@ function isAutoManagedSource(value: string | null): boolean { return value === "auto" || value === "zeroapi"; } +function normalizeNumber(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return value; +} + /** * Adapt the host's public session-store capabilities without requiring a named * export that older supported OpenClaw versions do not provide. @@ -145,13 +154,29 @@ export async function syncSessionAuthProfileOverride( return null; } if (currentProfile === targetProfile && currentSource === "auto") { - outcome = { action: "noop", reason: "already_current", sessionKey }; - return null; + const currentCompaction = normalizeNumber( + entry.authProfileOverrideCompactionCount, + ); + const sessionCompaction = normalizeNumber(entry.compactionCount) ?? 0; + if (currentCompaction === sessionCompaction) { + outcome = { action: "noop", reason: "already_current", sessionKey }; + return null; + } + outcome = { action: "updated", reason: "updated", sessionKey }; + return { + authProfileOverride: targetProfile, + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: sessionCompaction, + }; } outcome = { action: "updated", reason: "updated", sessionKey }; return { authProfileOverride: targetProfile, authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: + typeof entry.compactionCount === "number" + ? entry.compactionCount + : 0, }; } @@ -168,6 +193,7 @@ export async function syncSessionAuthProfileOverride( return { authProfileOverride: undefined, authProfileOverrideSource: undefined, + authProfileOverrideCompactionCount: undefined, }; }, });