From fe8eff20eebaaa29535dd9e204ddc5b7f544e373 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 23 Jul 2026 08:36:41 -0700 Subject: [PATCH] feat: keep external skill rollouts production-dark (#3236) * feat: add fail-closed skill rollout gates * fix: preserve scan queue pagination semantics --- .github/workflows/deploy-test.yml | 26 ++ .github/workflows/deploy.yml | 38 +++ convex/_generated/api.d.ts | 4 + convex/githubSkillSources.test.ts | 37 ++- convex/githubSkillSources.ts | 17 +- convex/githubSkillSync.test.ts | 125 ++++++- convex/githubSkillSync.ts | 29 +- convex/httpApiV1/skillsShCatalogV1.test.ts | 40 +++ convex/httpApiV1/skillsShCatalogV1.ts | 7 + convex/lib/rolloutCapabilities.ts | 36 ++ convex/lib/skillsShCatalogEnvironment.test.ts | 29 +- convex/lib/skillsShCatalogEnvironment.ts | 11 + convex/rolloutCapabilities.test.ts | 84 +++++ convex/rolloutCapabilities.ts | 45 +++ convex/schema.ts | 1 + convex/securityScan.test.ts | 309 +++++++++++++++++- convex/securityScan.ts | 140 +++++++- convex/skillsShCatalog.test.ts | 19 ++ convex/skillsShCatalog.ts | 5 +- convex/skillsShCatalogCanary.test.ts | 42 +++ packages/schema/dist/index.d.ts | 1 + packages/schema/dist/index.js | 1 + packages/schema/dist/index.js.map | 2 +- packages/schema/dist/rolloutCapabilities.d.ts | 31 ++ packages/schema/dist/rolloutCapabilities.js | 100 ++++++ .../schema/dist/rolloutCapabilities.js.map | 1 + packages/schema/src/index.ts | 1 + .../schema/src/rolloutCapabilities.test.ts | 149 +++++++++ packages/schema/src/rolloutCapabilities.ts | 139 ++++++++ scripts/vercel-build-frontend.test.ts | 25 ++ scripts/vercel-build-frontend.ts | 12 + server/skillsShCatalogSource.test.ts | 22 ++ server/skillsShCatalogSource.ts | 10 + src/__tests__/deploy-test-workflow.test.ts | 20 ++ src/__tests__/deploy-workflow.test.ts | 19 ++ src/routes/-settings.test.tsx | 36 ++ src/routes/settings.tsx | 5 +- 37 files changed, 1584 insertions(+), 34 deletions(-) create mode 100644 convex/lib/rolloutCapabilities.ts create mode 100644 convex/rolloutCapabilities.test.ts create mode 100644 convex/rolloutCapabilities.ts create mode 100644 packages/schema/dist/rolloutCapabilities.d.ts create mode 100644 packages/schema/dist/rolloutCapabilities.js create mode 100644 packages/schema/dist/rolloutCapabilities.js.map create mode 100644 packages/schema/src/rolloutCapabilities.test.ts create mode 100644 packages/schema/src/rolloutCapabilities.ts diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index cb246646..668c1adb 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -95,6 +95,14 @@ jobs: - uses: ./.github/actions/setup-bun + - name: Enable Test rollout modes + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + run: | + set -euo pipefail + bunx convex env set CLAWHUB_SKILLS_SH_ROLLOUT_MODE test --prod + bunx convex env set CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE test --prod + - name: Stamp Convex build SHA env: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} @@ -115,6 +123,20 @@ jobs: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} run: bun run verify:convex-contract -- --prod + - name: Verify Test rollout capabilities + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + run: | + set -euo pipefail + capabilities="$(bunx convex run rolloutCapabilities:getPublicCapabilities --prod)" + jq -e ' + .environment == "test" and + .skillsSh.mode == "test" and + .skillsSh.runtimeEnabled == true and + .githubSkillSync.mode == "test" and + .githubSkillSync.selfServiceEnabled == true + ' <<< "$capabilities" + - name: Apply additive Test fixtures env: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} @@ -145,6 +167,8 @@ jobs: --build-env VERCEL_ENV=test \ --build-env VERCEL_TARGET_ENV=test \ --build-env CLAWHUB_ENV=test \ + --build-env CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test \ + --build-env CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE=test \ --build-env SITE_URL="$TEST_SITE_URL" \ --build-env VITE_CLAWHUB_DEPLOY_ENV=test \ --build-env VITE_CONVEX_URL="$VITE_CONVEX_URL" \ @@ -152,6 +176,8 @@ jobs: --build-env VITE_SITE_URL="$TEST_SITE_URL" \ --env CONVEX_DEPLOY_KEY= \ --env CLAWHUB_ENV=test \ + --env CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test \ + --env CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE=test \ --env SITE_URL="$TEST_SITE_URL" \ --env VITE_CLAWHUB_DEPLOY_ENV=test \ --env VITE_CONVEX_URL="$VITE_CONVEX_URL" \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 300b7426..a0b1ade1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -113,6 +113,26 @@ jobs: - name: Install run: bun install --frozen-lockfile + - name: Require dark rollout modes + if: needs.validate-deploy-request.outputs.deploy_backend == 'true' + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + run: | + set -euo pipefail + for name in \ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE \ + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE + do + value="$(bunx convex env get "$name" --prod 2>/dev/null || true)" + case "$value" in + ""|off) ;; + *) + echo "::error::$name must be missing or off before an ordinary production deploy" + exit 1 + ;; + esac + done + - name: Stamp Convex build SHA if: needs.validate-deploy-request.outputs.deploy_backend == 'true' env: @@ -149,6 +169,24 @@ jobs: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} run: bun run verify:convex-contract -- --prod + - name: Verify dark rollout capabilities + if: needs.validate-deploy-request.outputs.deploy_backend == 'true' + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + run: | + set -euo pipefail + capabilities="$(bunx convex run rolloutCapabilities:getPublicCapabilities --prod)" + jq -e ' + .environment == "production" and + .skillsSh.mode == "off" and + .skillsSh.runtimeEnabled == false and + .skillsSh.publicCatalogEnabled == false and + .skillsSh.scanPlanningEnabled == false and + .skillsSh.scanAdmissionEnabled == false and + .githubSkillSync.mode == "off" and + .githubSkillSync.selfServiceEnabled == false + ' <<< "$capabilities" + - name: Wait for Vercel production deployment id: vercel if: needs.validate-deploy-request.outputs.deploy_frontend == 'true' diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index fc6ad4d2..ffccea0d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -111,6 +111,7 @@ import type * as lib_reporting from "../lib/reporting.js"; import type * as lib_reservedHandles from "../lib/reservedHandles.js"; import type * as lib_reservedSlugs from "../lib/reservedSlugs.js"; import type * as lib_retentionPolicy from "../lib/retentionPolicy.js"; +import type * as lib_rolloutCapabilities from "../lib/rolloutCapabilities.js"; import type * as lib_searchRanking from "../lib/searchRanking.js"; import type * as lib_searchText from "../lib/searchText.js"; import type * as lib_securityPrompt from "../lib/securityPrompt.js"; @@ -160,6 +161,7 @@ import type * as publisherAbuseTemporalScan from "../publisherAbuseTemporalScan. import type * as publishers from "../publishers.js"; import type * as rateLimits from "../rateLimits.js"; import type * as retention from "../retention.js"; +import type * as rolloutCapabilities from "../rolloutCapabilities.js"; import type * as search from "../search.js"; import type * as securityDataset from "../securityDataset.js"; import type * as securityDatasetNode from "../securityDatasetNode.js"; @@ -289,6 +291,7 @@ declare const fullApi: ApiFromModules<{ "lib/reservedHandles": typeof lib_reservedHandles; "lib/reservedSlugs": typeof lib_reservedSlugs; "lib/retentionPolicy": typeof lib_retentionPolicy; + "lib/rolloutCapabilities": typeof lib_rolloutCapabilities; "lib/searchRanking": typeof lib_searchRanking; "lib/searchText": typeof lib_searchText; "lib/securityPrompt": typeof lib_securityPrompt; @@ -338,6 +341,7 @@ declare const fullApi: ApiFromModules<{ publishers: typeof publishers; rateLimits: typeof rateLimits; retention: typeof retention; + rolloutCapabilities: typeof rolloutCapabilities; search: typeof search; securityDataset: typeof securityDataset; securityDatasetNode: typeof securityDatasetNode; diff --git a/convex/githubSkillSources.test.ts b/convex/githubSkillSources.test.ts index b6657d73..a06f7394 100644 --- a/convex/githubSkillSources.test.ts +++ b/convex/githubSkillSources.test.ts @@ -1,5 +1,5 @@ import { ConvexError } from "convex/values"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./lib/access", () => ({ requireUser: vi.fn(), @@ -22,6 +22,15 @@ const { } = await import("./githubSkillSources"); const { buildSkillInstallResolution } = await import("./lib/installResolver"); +beforeEach(() => { + vi.stubEnv("CONVEX_DEPLOYMENT", "local:clawhub"); + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "test"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + type Row = Record & { _id: string }; type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -104,6 +113,32 @@ describe("githubSkillSources.deleteForPublisherHandler", () => { vi.mocked(requirePublisherRole).mockResolvedValue(undefined as never); }); + it("rejects generic source removal without writes when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const { db, tables } = createDb({ + githubSkillSources: [ + { + _id: "githubSkillSources:generic", + repo: "openclaw/agent-skills", + ownerPublisherId: "publishers:openclaw", + createdAt: 1, + updatedAt: 2, + }, + ], + }); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + deleteForPublisherHandler({ db, scheduler } as never, { + ownerPublisherId: "publishers:openclaw" as never, + sourceId: "githubSkillSources:generic" as never, + }), + ).rejects.toThrow(/rollout is disabled/i); + + expect(tables.githubSkillSources).toHaveLength(1); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + it("deletes a source and removes only GitHub-backed skills from that source", async () => { const { db, tables } = createDb({ githubSkillSources: [ diff --git a/convex/githubSkillSources.ts b/convex/githubSkillSources.ts index f9526682..77f38dd6 100644 --- a/convex/githubSkillSources.ts +++ b/convex/githubSkillSources.ts @@ -13,6 +13,11 @@ import { isPublisherRoleAllowed, requirePublisherRole, } from "./lib/publishers"; +import { + assertGenericGitHubSkillSyncEnabled, + getRuntimeRolloutCapabilities, + isLegacyNvidiaSkillSource, +} from "./lib/rolloutCapabilities"; import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest"; const GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE = 25; @@ -102,7 +107,10 @@ export const listForPublisher = query({ .query("githubSkillSources") .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId)) .collect(); - const sortedSources = sources.sort((a, b) => b.updatedAt - a.updatedAt); + const visibleSources = getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled + ? sources + : sources.filter((source) => isLegacyNvidiaSkillSource(source.repo)); + const sortedSources = visibleSources.sort((a, b) => b.updatedAt - a.updatedAt); return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source))); }, }); @@ -144,7 +152,11 @@ export const listForManageableOfficialPublishers = query({ .collect(), ), ); - const sortedSources = sourceGroups.flat().sort((a, b) => b.updatedAt - a.updatedAt); + const sources = sourceGroups.flat(); + const visibleSources = getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled + ? sources + : sources.filter((source) => isLegacyNvidiaSkillSource(source.repo)); + const sortedSources = visibleSources.sort((a, b) => b.updatedAt - a.updatedAt); return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source))); }, }); @@ -168,6 +180,7 @@ export async function deleteForPublisherHandler( if (!source || source.ownerPublisherId !== args.ownerPublisherId) { throw new ConvexError("GitHub source not found."); } + assertGenericGitHubSkillSyncEnabled(source.repo); const now = args.now ?? Date.now(); const contents = await ctx.db diff --git a/convex/githubSkillSync.test.ts b/convex/githubSkillSync.test.ts index 80cf97cf..3cdd0b8b 100644 --- a/convex/githubSkillSync.test.ts +++ b/convex/githubSkillSync.test.ts @@ -1,7 +1,7 @@ import { getFunctionName } from "convex/server"; import { ConvexError } from "convex/values"; import { zipSync } from "fflate"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __test, applyGitHubSkillSourceSyncHandler, @@ -19,6 +19,15 @@ import { buildGitHubSkillSourceSnapshot } from "./lib/githubSkillSync"; import { buildSkillInstallResolution } from "./lib/installResolver"; import { Events } from "./lib/observabilityEvents"; +beforeEach(() => { + vi.stubEnv("CONVEX_DEPLOYMENT", "local:clawhub"); + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "test"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + type Row = Record & { _id: string }; function chainEq(constraints: Record) { @@ -308,6 +317,28 @@ describe("buildGitHubSkillSourceFetch", () => { }); describe("configurePublicGitHubSkillSourceHandler", () => { + it("fails closed before authentication, database, or GitHub work when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const runQuery = vi.fn(); + const runMutation = vi.fn(); + const fetchMock = vi.fn(); + + await expect( + configurePublicGitHubSkillSourceHandler( + { runQuery, runMutation, auth: { getUserIdentity: vi.fn() } } as never, + { + ownerPublisherId: "publishers:local" as never, + repo: "someoneelse/public-skills", + }, + fetchMock as never, + ), + ).rejects.toThrow(/rollout is disabled/i); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(runQuery).not.toHaveBeenCalled(); + expect(runMutation).not.toHaveBeenCalled(); + }); + it("configures any public GitHub repo for an official publisher the user can manage", async () => { const zip = zipSync({ "skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"), @@ -581,6 +612,62 @@ describe("configurePublicGitHubSkillSourceHandler", () => { }); describe("syncGitHubSkillSourcesHandler", () => { + it("does not enumerate or fetch generic sources while rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const runQuery = vi.fn(async (_ref, args: Record) => { + expect(args).toMatchObject({ legacyOnly: true }); + return { sources: [], continueCursor: null, isDone: true }; + }); + const runMutation = vi.fn(); + const fetchMock = vi.fn(); + + await expect( + syncGitHubSkillSourcesHandler({ runQuery, runMutation } as never, {}, fetchMock as never), + ).resolves.toMatchObject({ + ok: true, + synced: 0, + skipped: 0, + errors: 0, + isDone: true, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(runMutation).not.toHaveBeenCalled(); + }); + + it("lists only the legacy NVIDIA source when generic rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const { db } = createDb({ + githubSkillSources: [ + { + _id: "githubSkillSources:generic", + repo: "openclaw/agent-skills", + createdAt: 1, + updatedAt: 1, + }, + { + _id: "githubSkillSources:nvidia", + repo: "NVIDIA/skills", + createdAt: 2, + updatedAt: 2, + }, + ], + }); + + await expect( + listSourcesForSyncHandler({ db } as never, { batchSize: 20, legacyOnly: true }), + ).resolves.toEqual({ + sources: [ + expect.objectContaining({ + _id: "githubSkillSources:nvidia", + repo: "NVIDIA/skills", + }), + ], + continueCursor: null, + isDone: true, + }); + }); + it("pages configured sources for scheduled sync", async () => { const { db } = createDb({ githubSkillSources: Array.from({ length: 30 }, (_, index) => ({ @@ -708,6 +795,42 @@ describe("syncGitHubSkillSourcesHandler", () => { }); }); +describe("verifyGitHubSkillHandler rollout", () => { + it("does not fetch or enqueue a generic GitHub skill while rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const runQuery = vi.fn(async () => ({ + skill: { + _id: "skills:generic", + slug: "generic", + displayName: "Generic", + summary: "Generic skill", + githubPath: "skills/generic", + githubCurrentCommit: "a".repeat(40), + githubCurrentContentHash: "hash", + githubCurrentStatus: "present", + }, + source: { + _id: "githubSkillSources:generic", + repo: "openclaw/agent-skills", + defaultBranch: "main", + }, + })); + const runMutation = vi.fn(); + const fetchMock = vi.fn(); + + await expect( + verifyGitHubSkillHandler( + { runQuery, runMutation } as never, + { skillId: "skills:generic" as never, contentHash: "hash" }, + fetchMock as never, + ), + ).resolves.toEqual({ ok: true, skipped: "rollout-disabled" }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(runMutation).not.toHaveBeenCalled(); + }); +}); + describe("GitHub-backed skill source lifecycle", () => { it("records invalid GitHub-backed skills from the last sync", async () => { const longSlug = "x".repeat(97); diff --git a/convex/githubSkillSync.ts b/convex/githubSkillSync.ts index 50d2199d..e33ac3db 100644 --- a/convex/githubSkillSync.ts +++ b/convex/githubSkillSync.ts @@ -28,6 +28,11 @@ import { runStaticModerationScan } from "./lib/moderationEngine"; import { Events, logErrorEvent, logEvent } from "./lib/observabilityEvents"; import { isOfficialPublisher } from "./lib/officialPublishers"; import { requirePublisherRole } from "./lib/publishers"; +import { + assertGenericGitHubSkillSyncEnabled, + assertGitHubSkillSyncRuntimeEnabled, + getRuntimeRolloutCapabilities, +} from "./lib/rolloutCapabilities"; import { isMacJunkPath, parseFrontmatter } from "./lib/skills"; import { chunkSkillScanRequestFiles } from "./lib/skillScanRequestFiles"; import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest"; @@ -247,14 +252,26 @@ export const listSourcesForSyncInternal = internalQuery({ args: { cursor: v.optional(v.union(v.string(), v.null())), batchSize: v.optional(v.number()), + legacyOnly: v.optional(v.boolean()), }, handler: listSourcesForSyncHandler, }); export async function listSourcesForSyncHandler( ctx: QueryCtx, - args: { cursor?: string | null; batchSize?: number }, + args: { cursor?: string | null; batchSize?: number; legacyOnly?: boolean }, ): Promise { + if (args.legacyOnly) { + const source = await ctx.db + .query("githubSkillSources") + .withIndex("by_repo", (q) => q.eq("repo", "NVIDIA/skills")) + .unique(); + return { + sources: source ? [source] : [], + continueCursor: null, + isDone: true, + }; + } const batchSize = clampInt( args.batchSize ?? DEFAULT_SOURCE_SYNC_BATCH_SIZE, 1, @@ -926,6 +943,12 @@ export async function verifyGitHubSkillHandler( { skillId: args.skillId, contentHash: args.contentHash }, )) as GitHubSkillVerificationTarget | null; if (!target) return { ok: true as const, skipped: "stale-or-missing" as const }; + if ( + !getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled && + target.source.repo.trim().toLowerCase() !== "nvidia/skills" + ) { + return { ok: true as const, skipped: "rollout-disabled" as const }; + } const { snapshot, entries } = await fetchGitHubSkillSourceSnapshotWithEntries( { @@ -997,6 +1020,7 @@ export async function configurePublicGitHubSkillSourceHandler( fetcher: typeof fetch = fetch, authOverride?: { userId: Id<"users"> }, ): Promise { + assertGitHubSkillSyncRuntimeEnabled(); const actor = authOverride ?? (await requireUserFromAction(ctx)); const metadata = await fetchPublicGitHubRepoMetadata(args.repo, fetcher); const setup = (await ctx.runQuery( @@ -1108,6 +1132,7 @@ export const syncGitHubSkillSource: ReturnType = action({ assertAdmin(user); const repo = normalizeRepo(args.repo); + assertGenericGitHubSkillSyncEnabled(repo); const source = (await ctx.runQuery(internal.githubSkillSync.getSourceByRepoInternal, { repo, })) as SourceForSync | null; @@ -1156,10 +1181,12 @@ export async function syncGitHubSkillSourcesHandler( 1, MAX_SOURCE_SYNC_BATCH_SIZE, ); + const genericEnabled = getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled; logEvent(Events.GitHubSkillSourceSyncStarted, { startedAt, cursor: args.cursor ?? null }); const page = (await ctx.runQuery(internal.githubSkillSync.listSourcesForSyncInternal, { cursor: args.cursor ?? null, batchSize, + legacyOnly: !genericEnabled, })) as SourceForSyncPage; const sources = page.sources; const results: SyncOneResult[] = []; diff --git a/convex/httpApiV1/skillsShCatalogV1.test.ts b/convex/httpApiV1/skillsShCatalogV1.test.ts index 08bae6ba..21704148 100644 --- a/convex/httpApiV1/skillsShCatalogV1.test.ts +++ b/convex/httpApiV1/skillsShCatalogV1.test.ts @@ -30,12 +30,22 @@ vi.mock("./shared", async (importOriginal) => { const { requireAdminOrResponse, requireApiTokenUserOrResponse } = await import("./shared"); const { buildGitHubApiHeaders } = await import("../lib/githubAuth"); const { computeGitHubSkillFolderContentHash } = await import("../lib/githubSkillSync"); +const { applyRateLimit } = await import("../lib/httpRateLimit"); const { skillsShCatalogPublicV1Handler, skillsShCatalogTestV1Handler, verifyControlledCanaryGitHubSource, } = await import("./skillsShCatalogV1"); +beforeEach(() => { + vi.stubEnv("CLAWHUB_ENV", "test"); + vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } @@ -57,7 +67,22 @@ function artifact(externalId: string, content: string) { } describe("skills.sh catalog Test HTTP API", () => { + it("returns 404 before rate limiting or authentication while rollout is off", async () => { + vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off"); + + const response = await skillsShCatalogTestV1Handler( + {} as never, + new Request("https://academic-chihuahua-392.convex.site/api/v1/ops"), + ); + + expect(response.status).toBe(404); + expect(applyRateLimit).not.toHaveBeenCalled(); + expect(requireApiTokenUserOrResponse).not.toHaveBeenCalled(); + }); + beforeEach(() => { + vi.mocked(applyRateLimit).mockClear(); + vi.mocked(requireApiTokenUserOrResponse).mockClear(); vi.mocked(requireApiTokenUserOrResponse).mockResolvedValue({ ok: true, user: { handle: "catalog-operator" }, @@ -685,6 +710,21 @@ describe("skills.sh catalog Test HTTP API", () => { }); describe("skills.sh public HTTP API", () => { + it("returns 404 before rate limiting while rollout is off", async () => { + vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off"); + vi.mocked(applyRateLimit).mockClear(); + + const response = await skillsShCatalogPublicV1Handler( + {} as never, + new Request( + "https://academic-chihuahua-392.convex.site/api/v1/skills-sh/patrick-erichsen/skills/html", + ), + ); + + expect(response.status).toBe(404); + expect(applyRateLimit).not.toHaveBeenCalled(); + }); + const publicEntry = { ref: "skills-sh/patrick-erichsen/skills/html", route: "/skills-sh/patrick-erichsen/skills/html", diff --git a/convex/httpApiV1/skillsShCatalogV1.ts b/convex/httpApiV1/skillsShCatalogV1.ts index 42e06cce..5f93c1a2 100644 --- a/convex/httpApiV1/skillsShCatalogV1.ts +++ b/convex/httpApiV1/skillsShCatalogV1.ts @@ -4,6 +4,7 @@ import type { ActionCtx } from "../_generated/server"; import { buildGitHubApiHeaders } from "../lib/githubAuth"; import { computeGitHubSkillFolderContentHash } from "../lib/githubSkillSync"; import { applyRateLimit } from "../lib/httpRateLimit"; +import { getRuntimeRolloutCapabilities } from "../lib/rolloutCapabilities"; import { getSkillsShCatalogFixture, type SkillsShCatalogFixtureRow, @@ -402,6 +403,9 @@ async function storeArtifactFiles( } export async function skillsShCatalogTestV1Handler(ctx: ActionCtx, request: Request) { + if (!getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) { + return text("Not found", 404); + } const rate = await applyRateLimit(ctx, request, request.method === "GET" ? "read" : "write"); if (!rate.ok) return rate.response; const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); @@ -597,6 +601,9 @@ export async function skillsShCatalogTestV1Handler(ctx: ActionCtx, request: Requ } export async function skillsShCatalogPublicV1Handler(ctx: ActionCtx, request: Request) { + if (!getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) { + return text("Not found", 404); + } const rate = await applyRateLimit(ctx, request, "read"); if (!rate.ok) return rate.response; if (request.method !== "GET") return text("Not found", 404, rate.headers); diff --git a/convex/lib/rolloutCapabilities.ts b/convex/lib/rolloutCapabilities.ts new file mode 100644 index 00000000..ae3c7003 --- /dev/null +++ b/convex/lib/rolloutCapabilities.ts @@ -0,0 +1,36 @@ +import { getClawHubRolloutCapabilities, type ClawHubRolloutEnvironment } from "clawhub-schema"; +import { ConvexError } from "convex/values"; + +export const LEGACY_NVIDIA_SKILL_SOURCE = "nvidia/skills"; + +export function getRuntimeRolloutCapabilities(env: ClawHubRolloutEnvironment = process.env) { + return getClawHubRolloutCapabilities(env); +} + +export function assertSkillsShRuntimeEnabled(env: ClawHubRolloutEnvironment = process.env) { + const capabilities = getRuntimeRolloutCapabilities(env); + if (!capabilities.skillsSh.runtimeEnabled) { + throw new ConvexError("skills.sh catalog rollout is disabled"); + } + return capabilities; +} + +export function assertGitHubSkillSyncRuntimeEnabled(env: ClawHubRolloutEnvironment = process.env) { + const capabilities = getRuntimeRolloutCapabilities(env); + if (!capabilities.githubSkillSync.runtimeEnabled) { + throw new ConvexError("GitHub Skill Sync rollout is disabled"); + } + return capabilities; +} + +export function isLegacyNvidiaSkillSource(repo: string) { + return repo.trim().toLowerCase() === LEGACY_NVIDIA_SKILL_SOURCE; +} + +export function assertGenericGitHubSkillSyncEnabled( + repo: string, + env: ClawHubRolloutEnvironment = process.env, +) { + if (isLegacyNvidiaSkillSource(repo)) return getRuntimeRolloutCapabilities(env); + return assertGitHubSkillSyncRuntimeEnabled(env); +} diff --git a/convex/lib/skillsShCatalogEnvironment.test.ts b/convex/lib/skillsShCatalogEnvironment.test.ts index 91e9eb0e..fe9919b6 100644 --- a/convex/lib/skillsShCatalogEnvironment.test.ts +++ b/convex/lib/skillsShCatalogEnvironment.test.ts @@ -6,6 +6,7 @@ describe("skills.sh fixture environment policy", () => { it("allows only local development or the exact cron-disabled Test deployment", () => { expect( getSkillsShFixtureEnvironmentPolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "http://127.0.0.1:3210", }), ).toEqual({ allowed: true, environment: "local" }); @@ -15,11 +16,27 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }), ).toEqual({ allowed: true, environment: "test" }); }); + it("requires the top-level rollout mode in addition to Test environment markers", () => { + expect( + getSkillsShFixtureEnvironmentPolicy({ + CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", + CLAWHUB_DISABLE_CRONS: "1", + CLAWHUB_ENV: "test", + CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", + }), + ).toMatchObject({ + allowed: false, + environment: "test", + reason: "skills.sh catalog rollout is disabled", + }); + }); + it("rejects previews, production, and incomplete Test markers", () => { expect( getSkillsShFixtureEnvironmentPolicy({ @@ -32,6 +49,7 @@ describe("skills.sh fixture environment policy", () => { getSkillsShFixtureEnvironmentPolicy({ CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", }), ).toMatchObject({ allowed: false, environment: "test" }); @@ -40,12 +58,14 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "wry-manatee-359", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }), - ).toMatchObject({ allowed: false, environment: "test" }); + ).toMatchObject({ allowed: false, environment: "production" }); expect( getSkillsShFixtureEnvironmentPolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "production", CONVEX_DEPLOYMENT: "prod:wry-manatee-359", }), ).toMatchObject({ allowed: false, environment: "production" }); @@ -53,6 +73,7 @@ describe("skills.sh fixture environment policy", () => { expect( getSkillsShFixtureEnvironmentPolicy({ CLAWHUB_DEPLOYMENT_NAME: "wry-manatee-359", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "production", CONVEX_CLOUD_URL: "http://127.0.0.1:3210", }), ).toMatchObject({ allowed: false, environment: "production" }); @@ -62,6 +83,7 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_DEPLOYMENT: "prod:wry-manatee-359", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }), @@ -72,6 +94,7 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://preview-project.convex.cloud", }), ).toMatchObject({ allowed: false, environment: "test" }); @@ -81,6 +104,7 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CLAWHUB_PREVIEW: "1", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }), @@ -90,6 +114,7 @@ describe("skills.sh fixture environment policy", () => { it("does not treat CLI-only local deployment markers as runtime proof", () => { expect( getSkillsShFixtureEnvironmentPolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_DEPLOYMENT: "local:clawhub", }), ).toMatchObject({ allowed: false, environment: "unknown" }); @@ -101,6 +126,7 @@ describe("skills.sh fixture environment policy", () => { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", CONVEX_SITE_URL: "https://preview-project.convex.site", }), @@ -108,6 +134,7 @@ describe("skills.sh fixture environment policy", () => { expect( getSkillsShFixtureEnvironmentPolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "http://127.0.0.1:3210", CONVEX_SITE_URL: "https://academic-chihuahua-392.convex.site", }), diff --git a/convex/lib/skillsShCatalogEnvironment.ts b/convex/lib/skillsShCatalogEnvironment.ts index d0a4b184..1f1902ee 100644 --- a/convex/lib/skillsShCatalogEnvironment.ts +++ b/convex/lib/skillsShCatalogEnvironment.ts @@ -1,7 +1,10 @@ +import { getClawHubRolloutCapabilities } from "clawhub-schema"; + type SkillsShCatalogEnvironment = { CLAWHUB_DEPLOYMENT_NAME?: string; CLAWHUB_DISABLE_CRONS?: string; CLAWHUB_ENV?: string; + CLAWHUB_SKILLS_SH_ROLLOUT_MODE?: string; CLAWHUB_PREVIEW?: string; CONVEX_CLOUD_URL?: string; CONVEX_DEPLOYMENT?: string; @@ -56,6 +59,14 @@ export function getSkillsShFixtureEnvironmentPolicy( reason: "skills.sh catalog fixture work is disabled in Preview", }; } + const rollout = getClawHubRolloutCapabilities(env); + if (!rollout.skillsSh.runtimeEnabled) { + return { + allowed: false, + environment: rollout.environment === "local" ? "unknown" : rollout.environment, + reason: "skills.sh catalog rollout is disabled", + }; + } const deployment = env.CONVEX_DEPLOYMENT?.trim() || env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim() || ""; if (deployment.startsWith("prod:")) { diff --git a/convex/rolloutCapabilities.test.ts b/convex/rolloutCapabilities.test.ts new file mode 100644 index 00000000..5e6715e4 --- /dev/null +++ b/convex/rolloutCapabilities.test.ts @@ -0,0 +1,84 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; +import { getPublicCapabilitiesHandler } from "./rolloutCapabilities"; + +function createControlDb(control: Record | null) { + const unique = vi.fn(async () => control); + const withIndex = vi.fn((_name: string, build: (query: { eq: () => unknown }) => unknown) => { + build({ eq: () => undefined }); + return { unique }; + }); + return { + db: { + query: vi.fn(() => ({ withIndex })), + }, + unique, + }; +} + +describe("getPublicCapabilitiesHandler", () => { + it("returns a fully dark response without reading controls when runtime modes are off", async () => { + const { db, unique } = createControlDb({ + mode: "staging-live", + paused: false, + discoveryEnabled: true, + writesEnabled: true, + publicVisibilityEnabled: true, + scanPlanningEnabled: true, + scanAdmissionEnabled: true, + }); + + await expect(getPublicCapabilitiesHandler({ db } as never, {})).resolves.toEqual({ + environment: "unknown", + skillsSh: { + mode: "off", + runtimeEnabled: false, + discoveryEnabled: false, + writesEnabled: false, + publicCatalogEnabled: false, + scanPlanningEnabled: false, + scanAdmissionEnabled: false, + }, + githubSkillSync: { + mode: "off", + selfServiceEnabled: false, + }, + }); + expect(unique).not.toHaveBeenCalled(); + }); + + it("requires the skills.sh database controls in addition to Test runtime mode", async () => { + const { db } = createControlDb({ + mode: "staging-live", + paused: false, + discoveryEnabled: true, + writesEnabled: true, + publicVisibilityEnabled: false, + scanPlanningEnabled: true, + scanAdmissionEnabled: false, + }); + + await expect( + getPublicCapabilitiesHandler({ db } as never, { + CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + }), + ).resolves.toEqual({ + environment: "test", + skillsSh: { + mode: "test", + runtimeEnabled: true, + discoveryEnabled: true, + writesEnabled: true, + publicCatalogEnabled: false, + scanPlanningEnabled: true, + scanAdmissionEnabled: false, + }, + githubSkillSync: { + mode: "test", + selfServiceEnabled: true, + }, + }); + }); +}); diff --git a/convex/rolloutCapabilities.ts b/convex/rolloutCapabilities.ts new file mode 100644 index 00000000..1ef847fb --- /dev/null +++ b/convex/rolloutCapabilities.ts @@ -0,0 +1,45 @@ +import { getClawHubRolloutCapabilities, type ClawHubRolloutEnvironment } from "clawhub-schema"; +import type { QueryCtx } from "./_generated/server"; +import { query } from "./functions"; + +const CONTROL_KEY = "global"; + +export async function getPublicCapabilitiesHandler( + ctx: Pick, + env: ClawHubRolloutEnvironment = process.env, +) { + const runtime = getClawHubRolloutCapabilities(env); + const control = runtime.skillsSh.runtimeEnabled + ? await ctx.db + .query("skillsShCatalogControls") + .withIndex("by_key", (q) => q.eq("key", CONTROL_KEY)) + .unique() + : null; + const catalogActive = Boolean( + runtime.skillsSh.runtimeEnabled && control && control.mode !== "off" && !control.paused, + ); + return { + environment: runtime.environment, + skillsSh: { + mode: runtime.skillsSh.mode, + runtimeEnabled: runtime.skillsSh.runtimeEnabled, + discoveryEnabled: catalogActive && Boolean(control?.discoveryEnabled), + writesEnabled: catalogActive && Boolean(control?.writesEnabled), + publicCatalogEnabled: + catalogActive && + Boolean(control?.discoveryEnabled) && + Boolean(control?.publicVisibilityEnabled), + scanPlanningEnabled: catalogActive && Boolean(control?.scanPlanningEnabled), + scanAdmissionEnabled: catalogActive && Boolean(control?.scanAdmissionEnabled), + }, + githubSkillSync: { + mode: runtime.githubSkillSync.mode, + selfServiceEnabled: runtime.githubSkillSync.runtimeEnabled, + }, + }; +} + +export const getPublicCapabilities = query({ + args: {}, + handler: async (ctx) => await getPublicCapabilitiesHandler(ctx), +}); diff --git a/convex/schema.ts b/convex/schema.ts index 511f9f6b..d91d5649 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1863,6 +1863,7 @@ const securityScanJobs = defineTable({ skillVersionId: v.optional(v.id("skillVersions")), packageReleaseId: v.optional(v.id("packageReleases")), skillScanRequestId: v.optional(v.id("skillScanRequests")), + rolloutGate: v.optional(v.literal("github-skill-sync")), status: securityScanJobStatusValidator, source: securityScanJobSourceValidator, priority: v.number(), diff --git a/convex/securityScan.test.ts b/convex/securityScan.test.ts index 84eddde7..3d6d8b60 100644 --- a/convex/securityScan.test.ts +++ b/convex/securityScan.test.ts @@ -1,5 +1,5 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { appendGitHubSkillScanRequestFilesInternal, cancelQueuedVtUpdateJobsInternal, @@ -34,6 +34,7 @@ import { requestSkillRescanForUserInternal, requestSkillRescan, hydrateCodexScanJob, + listReadySourceJobsForClaimHandler, } from "./securityScan"; vi.mock("@convex-dev/auth/server", () => ({ @@ -1176,29 +1177,74 @@ function makeClaimCtx( } return a.createdAt - b.createdAt; }); + let rowFilter = (_job: ScanJob) => true; const take = vi.fn(async (limit: number) => select().slice(0, limit)); - return { + const filteredRows = () => select().filter(rowFilter); + const builder = { + filter: vi.fn( + ( + predicate: (q: { + field: (field: string) => { field: string }; + neq: ( + left: { field: string }, + right: unknown, + ) => { + field: string; + right: unknown; + }; + }) => { field: string; right: unknown }, + ) => { + const expression = predicate({ + field: (field) => ({ field }), + neq: (left, right) => ({ field: left.field, right }), + }); + rowFilter = (job) => + (job as unknown as Record)[expression.field] !== + expression.right; + return builder; + }, + ), take, - order: vi.fn(() => ({ take })), + order: vi.fn(() => ({ + paginate: vi.fn( + async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => { + const offset = cursor ? Number.parseInt(cursor, 10) : 0; + const rows = filteredRows(); + const nextOffset = Math.min(offset + numItems, rows.length); + return { + page: rows.slice(offset, nextOffset), + isDone: nextOffset >= rows.length, + continueCursor: String(nextOffset), + }; + }, + ), + take: vi.fn(async (limit: number) => filteredRows().slice(0, limit)), + })), }; + return builder; }, ), }; }); - return { - ctx: { - db: { - query, - patch, - get: vi.fn(async (id: string) => docs[id] ?? null), - insert: vi.fn(), - replace: vi.fn(), - delete: vi.fn(), - normalizeId: vi.fn(() => null), - system: {}, - }, + const ctx = { + db: { + query, + patch, + get: vi.fn(async (id: string) => docs[id] ?? jobs.find((job) => job._id === id) ?? null), + insert: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + normalizeId: vi.fn(() => null), + system: {}, }, + runQuery: vi.fn(async (_ref: unknown, args: unknown) => + listReadySourceJobsForClaimHandler(ctx as never, args as never), + ), + }; + + return { + ctx, patches, patch, query, @@ -1354,6 +1400,13 @@ function makeStoredScanReportCtx(options: { } describe("securityScan", () => { + beforeEach(() => { + vi.stubEnv("CLAWHUB_ENV", "local"); + vi.stubEnv("CONVEX_DEPLOYMENT", "local:clawhub-test"); + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "test"); + vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test"); + }); + afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); @@ -1710,6 +1763,40 @@ describe("securityScan", () => { ); }); + it("rejects generic GitHub-backed rescans before scheduling or writing when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const { ctx, inserts, scheduler } = makeRescanCtx({ + actorId: "users:moderator", + actorRole: "moderator", + docs: { + "skills:github": { + _id: "skills:github", + slug: "github-demo", + ownerUserId: "users:owner", + installKind: "github", + githubSourceId: "githubSkillSources:github", + githubPath: "skills/github-demo", + githubCurrentStatus: "present", + githubCurrentCommit: "a".repeat(40), + githubCurrentContentHash: "content-hash", + }, + "githubSkillSources:github": { + _id: "githubSkillSources:github", + repo: "acme/skills", + }, + }, + }); + + await expect( + requestSkillRescanHandler(ctx, { + skillId: "skills:github", + }), + ).rejects.toThrow("GitHub Skill Sync rollout is disabled"); + + expect(inserts).toEqual([]); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + it("does not schedule another GitHub verification action while the content scan is active", async () => { const { ctx, inserts, scheduler } = makeRescanCtx({ actorId: "users:moderator", @@ -3775,6 +3862,75 @@ describe("securityScan", () => { expect(claimed.map((job) => job._id)).toEqual(["securityScanJobs:publish"]); }); + it("skips queued generic GitHub scans while still claiming NVIDIA scans when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const genericJobs = Array.from({ length: 513 }, (_, index) => + makeScanJob({ + _id: `securityScanJobs:generic-${index}`, + source: "publish", + targetKind: "skillScanRequest", + skillVersionId: undefined, + skillScanRequestId: "skillScanRequests:generic", + createdAt: index + 1, + nextRunAt: index + 1, + }), + ); + const { ctx, patches } = makeClaimCtx( + [ + ...genericJobs, + makeScanJob({ + _id: "securityScanJobs:nvidia", + source: "publish", + targetKind: "skillScanRequest", + skillVersionId: undefined, + skillScanRequestId: "skillScanRequests:nvidia", + createdAt: 514, + nextRunAt: 514, + }), + ], + { + "skillScanRequests:generic": { + _id: "skillScanRequests:generic", + sourceKind: "github", + githubSkillScanId: "githubSkillScans:generic", + }, + "githubSkillScans:generic": { + _id: "githubSkillScans:generic", + githubSourceId: "githubSkillSources:generic", + }, + "githubSkillSources:generic": { + _id: "githubSkillSources:generic", + repo: "acme/skills", + }, + "skillScanRequests:nvidia": { + _id: "skillScanRequests:nvidia", + sourceKind: "github", + githubSkillScanId: "githubSkillScans:nvidia", + }, + "githubSkillScans:nvidia": { + _id: "githubSkillScans:nvidia", + githubSourceId: "githubSkillSources:nvidia", + }, + "githubSkillSources:nvidia": { + _id: "githubSkillSources:nvidia", + repo: "NVIDIA/skills", + }, + }, + ); + + const claimed = await claimQueuedJobsInternalHandler(ctx, { + workerId: "worker-1", + limit: 1, + leaseMs: 60_000, + }); + + expect(claimed.map((job) => job._id)).toEqual(["securityScanJobs:nvidia"]); + expect(patches.map((entry) => entry.id)).toEqual([ + "securityScanJobs:nvidia", + "skillScanRequests:nvidia", + ]); + }); + it("lets the catalog lane claim only the lowest-priority catalog source", async () => { vi.stubEnv("CLAWHUB_ENV", "test"); vi.stubEnv("CLAWHUB_DISABLE_CRONS", "1"); @@ -5252,6 +5408,129 @@ describe("securityScan", () => { }, ); + it("does not prepare generic GitHub scan state when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const insert = vi.fn(); + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === "skills:1") { + return { + _id: "skills:1", + installKind: "github", + githubSourceId: "githubSkillSources:generic", + githubPath: "skills/demo", + githubCurrentStatus: "present", + githubCurrentCommit: "a".repeat(40), + githubCurrentContentHash: "content-hash", + ownerUserId: "users:1", + slug: "demo", + displayName: "Demo", + }; + } + if (id === "githubSkillSources:generic") { + return { _id: id, repo: "acme/skills" }; + } + return null; + }), + query: vi.fn(), + insert, + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + normalizeId: vi.fn(() => null), + system: {}, + }, + }; + + await expect( + prepareGitHubSkillScanRequestInternalHandler(ctx as never, { + skillId: "skills:1", + contentHash: "content-hash", + commit: "a".repeat(40), + parsed: { frontmatter: {} }, + staticScan: { + status: "clean", + reasonCodes: [], + findings: [], + summary: "No static findings.", + engineVersion: "test", + checkedAt: 2, + }, + }), + ).resolves.toMatchObject({ + ok: true, + skipped: "rollout-disabled", + }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("rejects appending and finalizing stale generic GitHub requests when rollout is off", async () => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const docs = new Map>([ + [ + "skillScanRequests:github", + { + _id: "skillScanRequests:github", + sourceKind: "github", + githubSkillScanId: "githubSkillScans:github", + status: "queued", + files: [], + }, + ], + [ + "githubSkillScans:github", + { + _id: "githubSkillScans:github", + githubSourceId: "githubSkillSources:generic", + status: "pending", + skillScanRequestId: "skillScanRequests:github", + }, + ], + [ + "githubSkillSources:generic", + { + _id: "githubSkillSources:generic", + repo: "acme/skills", + }, + ], + ]); + const ctx = { + db: { + get: vi.fn(async (id: string) => docs.get(id) ?? null), + query: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + normalizeId: vi.fn(() => null), + system: {}, + }, + }; + + await expect( + appendGitHubSkillScanRequestFilesInternalHandler(ctx as never, { + requestId: "skillScanRequests:github", + chunkIndex: 0, + files: [ + { + path: "SKILL.md", + size: 10, + storageId: "storage:1", + sha256: "sha256", + }, + ], + }), + ).rejects.toThrow("GitHub Skill Sync rollout is disabled"); + await expect( + finalizeGitHubSkillScanRequestInternalHandler(ctx as never, { + requestId: "skillScanRequests:github", + }), + ).rejects.toThrow("GitHub Skill Sync rollout is disabled"); + expect(ctx.db.insert).not.toHaveBeenCalled(); + expect(ctx.db.patch).not.toHaveBeenCalled(); + }); + it("lets forced GitHub-backed rescans recover incomplete pending requests without jobs", async () => { const now = 1_781_570_600_000; vi.useFakeTimers(); diff --git a/convex/securityScan.ts b/convex/securityScan.ts index ede08760..27772876 100644 --- a/convex/securityScan.ts +++ b/convex/securityScan.ts @@ -9,6 +9,10 @@ import { Events, logEvent } from "./lib/observabilityEvents"; import { normalizePackageName } from "./lib/packageRegistry"; import { normalizePackageScanStatus } from "./lib/packageSecurity"; import { assertCanManageOwnedResource } from "./lib/publishers"; +import { + getRuntimeRolloutCapabilities, + isLegacyNvidiaSkillSource, +} from "./lib/rolloutCapabilities"; import { sourceSkillVersionFiles } from "./lib/skillCards"; import { getSkillBySlugForPublisher, @@ -61,6 +65,24 @@ const SKILL_SCAN_ASYNC_NOTE = "Scans are asynchronous and may take time to compl const finalLlmAnalysisStatuses = new Set(["clean", "suspicious", "malicious"]); const artifactBackedLlmAnalysisStatuses = new Set(["clean", "benign", "suspicious", "malicious"]); +async function isGitHubSkillScanAllowed( + ctx: Pick, + githubSourceId: Id<"githubSkillSources">, +) { + if (getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled) return true; + const source = await ctx.db.get(githubSourceId); + return Boolean(source && isLegacyNvidiaSkillSource(source.repo)); +} + +async function assertGitHubSkillScanAllowed( + ctx: Pick, + githubSourceId: Id<"githubSkillSources">, +) { + if (!(await isGitHubSkillScanAllowed(ctx, githubSourceId))) { + throw new ConvexError("GitHub Skill Sync rollout is disabled"); + } +} + type CancelSkipReason = | "not-queued" | "not-vt-update" @@ -352,6 +374,7 @@ const internalRefs = internal as unknown as { getCodexScanQueueHealthInternal: unknown; getSkillScanRequestForUserInternal: unknown; getJobTargetInternal: unknown; + listReadySourceJobsForClaimInternal: unknown; recordGitHubSkillScanResultInternal: unknown; completeCatalogSkillScanJobInternal: unknown; recordSkillScanRequestFailedInternal: unknown; @@ -818,6 +841,7 @@ async function requestSkillRescanForActor( ) { throw new ConvexError("GitHub-backed skill content is not available"); } + await assertGitHubSkillScanAllowed(ctx, args.skill.githubSourceId); const now = Date.now(); const { scan, activeJob, actionPending } = await getGitHubSkillScanState( ctx, @@ -1262,10 +1286,19 @@ async function enqueueSkillScanRequestJob( ) { const request = await ctx.db.get(requestId); if (!request) throw new ConvexError("Scan request not found"); + let rolloutGate: "github-skill-sync" | undefined; + if (request.sourceKind === "github" && request.githubSkillScanId) { + const scan = await ctx.db.get(request.githubSkillScanId); + const source = scan ? await ctx.db.get(scan.githubSourceId) : null; + if (source && !isLegacyNvidiaSkillSource(source.repo)) { + rolloutGate = "github-skill-sync"; + } + } const now = Date.now(); const jobId = await ctx.db.insert("securityScanJobs", { targetKind: "skillScanRequest", skillScanRequestId: request._id, + rolloutGate, status: "queued", source: options?.source ?? "manual", priority: options?.priority ?? 100, @@ -1343,6 +1376,9 @@ export const prepareGitHubSkillScanRequestInternal = internalMutation({ ) { return { ok: true as const, skipped: "stale-or-missing" as const }; } + if (!(await isGitHubSkillScanAllowed(ctx, skill.githubSourceId))) { + return { ok: true as const, skipped: "rollout-disabled" as const }; + } const existing = await ctx.db .query("githubSkillScans") .withIndex("by_skill_and_content_hash", (q) => @@ -1477,6 +1513,7 @@ export const appendGitHubSkillScanRequestFilesInternal = internalMutation({ if (!scan || scan.status !== "pending" || scan.skillScanRequestId !== request._id) { throw new ConvexError("GitHub scan request is no longer current"); } + await assertGitHubSkillScanAllowed(ctx, scan.githubSourceId); const existing = await ctx.db .query("skillScanRequestFileChunks") .withIndex("by_skill_scan_request_id_and_chunk_index", (q) => @@ -1522,6 +1559,11 @@ export const finalizeGitHubSkillScanRequestInternal = internalMutation({ if (!request || request.sourceKind !== "github" || !request.githubSkillScanId) { throw new ConvexError("GitHub scan request not found"); } + const scan = await ctx.db.get(request.githubSkillScanId); + if (!scan) { + throw new ConvexError("GitHub scan request is no longer current"); + } + await assertGitHubSkillScanAllowed(ctx, scan.githubSourceId); if (request.securityScanJobId) { const job = await ctx.db.get(request.securityScanJobId); if (job && (job.status === "queued" || job.status === "running")) { @@ -1535,7 +1577,6 @@ export const finalizeGitHubSkillScanRequestInternal = internalMutation({ } throw new ConvexError("GitHub scan request was already finalized"); } - const scan = await ctx.db.get(request.githubSkillScanId); const skill = scan ? await ctx.db.get(scan.skillId) : null; if ( !scan || @@ -2696,6 +2737,47 @@ export const clearQueuedBackfillJobsForLocalDev = internalMutation({ }, }); +type ReadySourceJobsForClaimPage = { + page: Doc<"securityScanJobs">[]; + isDone: boolean; + continueCursor: string; +}; + +export async function listReadySourceJobsForClaimHandler( + ctx: QueryCtx, + args: { + source: SecurityScanJobSource; + now: number; + cursor: string | null; + numItems: number; + excludeGitHubSkillSync: boolean; + }, +): Promise { + const query = ctx.db + .query("securityScanJobs") + .withIndex("by_status_source_next_run_at", (q) => + q.eq("status", "queued").eq("source", args.source).lte("nextRunAt", args.now), + ); + const eligibleQuery = args.excludeGitHubSkillSync + ? query.filter((q) => q.neq(q.field("rolloutGate"), "github-skill-sync")) + : query; + return await eligibleQuery.order("asc").paginate({ + cursor: args.cursor, + numItems: args.numItems, + }); +} + +export const listReadySourceJobsForClaimInternal = internalQuery({ + args: { + source: jobSourceValidator, + now: v.number(), + cursor: v.union(v.string(), v.null()), + numItems: v.number(), + excludeGitHubSkillSync: v.boolean(), + }, + handler: listReadySourceJobsForClaimHandler, +}); + export const claimQueuedJobsInternal = internalMutation({ args: { workerId: v.string(), @@ -2746,6 +2828,20 @@ export const claimQueuedJobsInternal = internalMutation({ ready.push(job); } }; + const githubSkillSyncEnabled = getRuntimeRolloutCapabilities().githubSkillSync.runtimeEnabled; + const isJobRolloutClaimable = async (job: Doc<"securityScanJobs">) => { + if ( + githubSkillSyncEnabled || + job.targetKind !== "skillScanRequest" || + !job.skillScanRequestId + ) { + return true; + } + const request = await ctx.db.get(job.skillScanRequestId); + if (request?.sourceKind !== "github" || !request.githubSkillScanId) return true; + const scan = await ctx.db.get(request.githubSkillScanId); + return scan ? await isGitHubSkillScanAllowed(ctx, scan.githubSourceId) : false; + }; const takeReadySourceJobs = async (source: SecurityScanJobSource) => { if (remainingCapacity() === 0) return []; let takeLimit = remainingCapacity(); @@ -2764,13 +2860,29 @@ export const claimQueuedJobsInternal = internalMutation({ // or canceled jobs cannot hide later runnable backlog after the cap is lowered. takeLimit = MAX_CODEX_SCAN_CLAIM_LIMIT; } - return await ctx.db - .query("securityScanJobs") - .withIndex("by_status_source_next_run_at", (q) => - q.eq("status", "queued").eq("source", source).lte("nextRunAt", now), - ) - .order("asc") - .take(takeLimit); + const eligible: Doc<"securityScanJobs">[] = []; + let cursor: string | null = null; + do { + const page: ReadySourceJobsForClaimPage = await runQueryRef( + ctx, + internalRefs.securityScan.listReadySourceJobsForClaimInternal, + { + source, + now, + cursor, + numItems: githubSkillSyncEnabled + ? Math.min(takeLimit, MAX_CODEX_SCAN_CLAIM_LIMIT) + : MAX_CODEX_SCAN_CLAIM_LIMIT, + excludeGitHubSkillSync: !githubSkillSyncEnabled, + }, + ); + for (const job of page.page) { + if (await isJobRolloutClaimable(job)) eligible.push(job); + if (eligible.length >= takeLimit) return eligible; + } + cursor = page.isDone ? null : page.continueCursor; + } while (cursor); + return eligible; }; if (args.lane === "catalog") { @@ -2801,8 +2913,18 @@ export const claimQueuedJobsInternal = internalMutation({ const claimed = []; let catalogClaims = 0; - for (const job of ready) { + for (const selectedJob of ready) { if (claimed.length >= capacity) break; + const job = await ctx.db.get(selectedJob._id); + if ( + !job || + job.status !== "queued" || + job.source !== selectedJob.source || + job.nextRunAt > now + ) { + continue; + } + if (!(await isJobRolloutClaimable(job))) continue; let catalogAttemptId: Id<"skillsShCatalogScanAttempts"> | null = null; if (job.source === "skills-sh-catalog-test") { if (!job.skillScanRequestId) { diff --git a/convex/skillsShCatalog.test.ts b/convex/skillsShCatalog.test.ts index 654dd5f7..0b4993b4 100644 --- a/convex/skillsShCatalog.test.ts +++ b/convex/skillsShCatalog.test.ts @@ -10,6 +10,7 @@ import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); const LOCAL_ENV = { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "http://127.0.0.1:3210", }; @@ -17,6 +18,7 @@ const TEST_ENV = { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }; @@ -55,6 +57,23 @@ type RunSummary = Pick< }; function useEnvironment(env: Record) { + for (const name of [ + "CLAWHUB_DEPLOYMENT_NAME", + "CLAWHUB_DISABLE_CRONS", + "CLAWHUB_ENV", + "CLAWHUB_PREVIEW", + "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", + "CONVEX_CLOUD_URL", + "CONVEX_DEPLOYMENT", + "CONVEX_SITE_URL", + "DEV_AUTH_CONVEX_DEPLOYMENT", + "VERCEL_ENV", + "VERCEL_TARGET_ENV", + "VITE_CLAWHUB_DEPLOY_ENV", + "VITE_CONVEX_URL", + ]) { + vi.stubEnv(name, ""); + } for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); } diff --git a/convex/skillsShCatalog.ts b/convex/skillsShCatalog.ts index 923dc583..21056b4c 100644 --- a/convex/skillsShCatalog.ts +++ b/convex/skillsShCatalog.ts @@ -4,6 +4,7 @@ import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; import { internalAction, internalMutation, internalQuery, query } from "./functions"; +import { getRuntimeRolloutCapabilities } from "./lib/rolloutCapabilities"; import { assertSkillsShCatalogControlMutationAllowed, assertSkillsShFixtureEnvironmentAllowed, @@ -2535,8 +2536,7 @@ export const getPublicEntry = query({ slug: v.string(), }, handler: async (ctx, args) => { - const environment = getSkillsShFixtureEnvironmentPolicy(); - if (!environment.allowed) return null; + if (!getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) return null; const externalId = `${args.owner.trim().toLowerCase()}/${args.repo .trim() .toLowerCase()}/${args.slug.trim().toLowerCase()}`; @@ -2551,6 +2551,7 @@ export const getPublicEntry = query({ !control || control.mode !== "staging-live" || control.paused || + !control.discoveryEnabled || !control.publicVisibilityEnabled || !entry?.publicVisible || (entry.scanStatus !== "clean" && entry.scanStatus !== "suspicious") diff --git a/convex/skillsShCatalogCanary.test.ts b/convex/skillsShCatalogCanary.test.ts index 8586df24..01dbfe6a 100644 --- a/convex/skillsShCatalogCanary.test.ts +++ b/convex/skillsShCatalogCanary.test.ts @@ -10,6 +10,7 @@ import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); const LOCAL_ENV = { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "http://127.0.0.1:3210", }; @@ -17,6 +18,7 @@ const TEST_ENV = { CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", CLAWHUB_DISABLE_CRONS: "1", CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", CONVEX_CLOUD_URL: "https://academic-chihuahua-392.convex.cloud", }; @@ -58,6 +60,23 @@ const SOURCE_VERIFICATION = { type CatalogTest = ReturnType; function useEnvironment(env: Record) { + for (const name of [ + "CLAWHUB_DEPLOYMENT_NAME", + "CLAWHUB_DISABLE_CRONS", + "CLAWHUB_ENV", + "CLAWHUB_PREVIEW", + "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", + "CONVEX_CLOUD_URL", + "CONVEX_DEPLOYMENT", + "CONVEX_SITE_URL", + "DEV_AUTH_CONVEX_DEPLOYMENT", + "VERCEL_ENV", + "VERCEL_TARGET_ENV", + "VITE_CLAWHUB_DEPLOY_ENV", + "VITE_CONVEX_URL", + ]) { + vi.stubEnv(name, ""); + } for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); } @@ -466,6 +485,29 @@ describe("skills.sh controlled hidden metadata canary", () => { }, ); + it("hides a published entry when database discovery is disabled", async () => { + useEnvironment(TEST_ENV); + const t = convexTest(schema, modules); + const attempt = await prepareScannedCanary(t); + await completeScannedCanary(t, attempt, "clean"); + await t.run(async (ctx) => { + const control = await ctx.db + .query("skillsShCatalogControls") + .withIndex("by_key", (q) => q.eq("key", "global")) + .unique(); + if (!control) throw new Error("Missing skills.sh catalog control"); + await ctx.db.patch(control._id, { discoveryEnabled: false }); + }); + + await expect( + t.query(api.skillsShCatalog.getPublicEntry, { + owner: "patrick-erichsen", + repo: "skills", + slug: "html", + }), + ).resolves.toBeNull(); + }); + it("omits verification artifacts when the scan request no longer matches the approved attempt", async () => { useEnvironment(TEST_ENV); const t = convexTest(schema, modules); diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index daba3ca2..099df538 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -9,6 +9,7 @@ export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; export * from "./promotionsFeed.js"; +export * from "./rolloutCapabilities.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index 34f9019c..be1a5b59 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -8,6 +8,7 @@ export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; export * from "./promotionsFeed.js"; +export * from "./rolloutCapabilities.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index 2e8222e1..efe98db6 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/rolloutCapabilities.d.ts b/packages/schema/dist/rolloutCapabilities.d.ts new file mode 100644 index 00000000..548c5879 --- /dev/null +++ b/packages/schema/dist/rolloutCapabilities.d.ts @@ -0,0 +1,31 @@ +export declare const CLAWHUB_SKILLS_SH_ROLLOUT_MODE = "CLAWHUB_SKILLS_SH_ROLLOUT_MODE"; +export declare const CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE = "CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE"; +export type ClawHubRolloutMode = "off" | "test" | "production"; +export type ClawHubRuntimeEnvironment = "local" | "test" | "preview" | "production" | "unknown"; +export type ClawHubRolloutEnvironment = { + CLAWHUB_DEPLOYMENT_NAME?: string; + CLAWHUB_ENV?: string; + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE?: string; + CLAWHUB_PREVIEW?: string; + CLAWHUB_SKILLS_SH_ROLLOUT_MODE?: string; + CONVEX_CLOUD_URL?: string; + CONVEX_DEPLOYMENT?: string; + CONVEX_SITE_URL?: string; + DEV_AUTH_CONVEX_DEPLOYMENT?: string; + VERCEL_ENV?: string; + VERCEL_TARGET_ENV?: string; + VITE_CLAWHUB_DEPLOY_ENV?: string; + VITE_CONVEX_URL?: string; +}; +export type ClawHubRolloutCapability = { + mode: ClawHubRolloutMode; + runtimeEnabled: boolean; + reason: "enabled" | "mode-off" | "environment-mismatch"; +}; +export declare function parseRolloutMode(value: string | undefined): ClawHubRolloutMode; +export declare function getClawHubRuntimeEnvironment(env: ClawHubRolloutEnvironment): ClawHubRuntimeEnvironment; +export declare function getClawHubRolloutCapabilities(env: ClawHubRolloutEnvironment): { + environment: ClawHubRuntimeEnvironment; + skillsSh: ClawHubRolloutCapability; + githubSkillSync: ClawHubRolloutCapability; +}; diff --git a/packages/schema/dist/rolloutCapabilities.js b/packages/schema/dist/rolloutCapabilities.js new file mode 100644 index 00000000..cc35fd30 --- /dev/null +++ b/packages/schema/dist/rolloutCapabilities.js @@ -0,0 +1,100 @@ +export const CLAWHUB_SKILLS_SH_ROLLOUT_MODE = "CLAWHUB_SKILLS_SH_ROLLOUT_MODE"; +export const CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE = "CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE"; +const TEST_DEPLOYMENT = "academic-chihuahua-392"; +function normalized(value) { + return value?.trim().toLowerCase() ?? ""; +} +function runtimeMarker(env) { + return (normalized(env.CLAWHUB_ENV) || + normalized(env.VITE_CLAWHUB_DEPLOY_ENV) || + normalized(env.VERCEL_TARGET_ENV) || + normalized(env.VERCEL_ENV)); +} +function deploymentName(env) { + const configured = normalized(env.CLAWHUB_DEPLOYMENT_NAME); + if (configured) + return configured; + const deployment = normalized(env.CONVEX_DEPLOYMENT || env.DEV_AUTH_CONVEX_DEPLOYMENT); + const separator = deployment.indexOf(":"); + return separator >= 0 ? deployment.slice(separator + 1) : deployment; +} +function isLocalUrl(value) { + if (!value) + return false; + try { + const hostname = new URL(value).hostname; + return ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"].includes(hostname); + } + catch { + return false; + } +} +export function parseRolloutMode(value) { + const mode = normalized(value); + if (mode === "test" || mode === "production") + return mode; + return "off"; +} +export function getClawHubRuntimeEnvironment(env) { + const deployment = normalized(env.CONVEX_DEPLOYMENT || env.DEV_AUTH_CONVEX_DEPLOYMENT); + const name = deploymentName(env); + const vercelEnvironment = normalized(env.VERCEL_ENV); + const vercelTargetEnvironment = normalized(env.VERCEL_TARGET_ENV); + const permanentTestTarget = vercelTargetEnvironment === "test" && + (name === TEST_DEPLOYMENT || + normalized(env.CLAWHUB_ENV) === "test" || + normalized(env.VITE_CLAWHUB_DEPLOY_ENV) === "test"); + if (deployment.startsWith("prod:")) + return "production"; + if (env.CLAWHUB_PREVIEW === "1" || + vercelTargetEnvironment === "preview" || + (vercelEnvironment === "preview" && !permanentTestTarget)) { + return "preview"; + } + if (name === TEST_DEPLOYMENT) + return "test"; + if (normalized(env.CLAWHUB_DEPLOYMENT_NAME)) { + return "production"; + } + if (vercelTargetEnvironment === "production" || vercelEnvironment === "production") { + return "production"; + } + const marker = runtimeMarker(env); + if (marker === "test") + return "test"; + if (marker === "production") + return "production"; + if (marker === "preview") + return "preview"; + if (marker === "local" || marker === "development") + return "local"; + if (deployment.startsWith("local:") || deployment.startsWith("dev:")) + return "local"; + const urls = [env.CONVEX_CLOUD_URL, env.CONVEX_SITE_URL, env.VITE_CONVEX_URL].filter((value) => Boolean(value?.trim())); + if (urls.some((value) => value.includes(TEST_DEPLOYMENT))) + return "test"; + if (urls.length > 0 && urls.every(isLocalUrl)) + return "local"; + return "unknown"; +} +function resolveCapability(mode, environment) { + if (mode === "off") { + return { mode, runtimeEnabled: false, reason: "mode-off" }; + } + const runtimeEnabled = (mode === "test" && (environment === "test" || environment === "local")) || + (mode === "production" && environment === "production"); + return { + mode, + runtimeEnabled, + reason: runtimeEnabled ? "enabled" : "environment-mismatch", + }; +} +export function getClawHubRolloutCapabilities(env) { + const environment = getClawHubRuntimeEnvironment(env); + return { + environment, + skillsSh: resolveCapability(parseRolloutMode(env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE), environment), + githubSkillSync: resolveCapability(parseRolloutMode(env.CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE), environment), + }; +} +//# sourceMappingURL=rolloutCapabilities.js.map \ No newline at end of file diff --git a/packages/schema/dist/rolloutCapabilities.js.map b/packages/schema/dist/rolloutCapabilities.js.map new file mode 100644 index 00000000..5ce94867 --- /dev/null +++ b/packages/schema/dist/rolloutCapabilities.js.map @@ -0,0 +1 @@ +{"version":3,"file":"rolloutCapabilities.js","sourceRoot":"","sources":["../src/rolloutCapabilities.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,8BAA8B,GAAG,gCAAgC,CAAC;AAC/E,MAAM,CAAC,MAAM,sCAAsC,GAAG,wCAAwC,CAAC;AA2B/F,MAAM,eAAe,GAAG,wBAAwB,CAAC;AAEjD,SAAS,UAAU,CAAC,KAAyB;IAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;AAC3C,CAAC;AAED,SAAS,aAAa,CAAC,GAA8B;IACnD,OAAO,CACL,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC;QAC3B,UAAU,CAAC,GAAG,CAAC,uBAAuB,CAAC;QACvC,UAAU,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACjC,UAAU,CAAC,GAAG,CAAC,UAAU,CAAC,CAC3B,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CAAC,GAA8B;IACpD,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IAC3D,IAAI,UAAU;QAAE,OAAO,UAAU,CAAC;IAClC,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,iBAAiB,IAAI,GAAG,CAAC,0BAA0B,CAAC,CAAC;IACvF,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC1C,OAAO,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;AACvE,CAAC;AAED,SAAS,UAAU,CAAC,KAAyB;IAC3C,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC;QACzC,OAAO,CAAC,WAAW,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC3E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAyB;IACxD,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC/B,IAAI,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC;IAC1D,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,4BAA4B,CAC1C,GAA8B;IAE9B,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,iBAAiB,IAAI,GAAG,CAAC,0BAA0B,CAAC,CAAC;IACvF,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;IACjC,MAAM,iBAAiB,GAAG,UAAU,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IACrD,MAAM,uBAAuB,GAAG,UAAU,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAClE,MAAM,mBAAmB,GACvB,uBAAuB,KAAK,MAAM;QAClC,CAAC,IAAI,KAAK,eAAe;YACvB,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,MAAM;YACtC,UAAU,CAAC,GAAG,CAAC,uBAAuB,CAAC,KAAK,MAAM,CAAC,CAAC;IACxD,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,YAAY,CAAC;IACxD,IACE,GAAG,CAAC,eAAe,KAAK,GAAG;QAC3B,uBAAuB,KAAK,SAAS;QACrC,CAAC,iBAAiB,KAAK,SAAS,IAAI,CAAC,mBAAmB,CAAC,EACzD,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,IAAI,KAAK,eAAe;QAAE,OAAO,MAAM,CAAC;IAC5C,IAAI,UAAU,CAAC,GAAG,CAAC,uBAAuB,CAAC,EAAE,CAAC;QAC5C,OAAO,YAAY,CAAC;IACtB,CAAC;IACD,IAAI,uBAAuB,KAAK,YAAY,IAAI,iBAAiB,KAAK,YAAY,EAAE,CAAC;QACnF,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,MAAM,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACrC,IAAI,MAAM,KAAK,YAAY;QAAE,OAAO,YAAY,CAAC;IACjD,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC3C,IAAI,MAAM,KAAK,OAAO,IAAI,MAAM,KAAK,aAAa;QAAE,OAAO,OAAO,CAAC;IAEnE,IAAI,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,UAAU,CAAC,UAAU,CAAC,MAAM,CAAC;QAAE,OAAO,OAAO,CAAC;IAErF,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,GAAG,CAAC,eAAe,EAAE,GAAG,CAAC,eAAe,CAAC,CAAC,MAAM,CAClF,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CACnD,CAAC;IACF,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;QAAE,OAAO,MAAM,CAAC;IACzE,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC;QAAE,OAAO,OAAO,CAAC;IAC9D,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,iBAAiB,CACxB,IAAwB,EACxB,WAAsC;IAEtC,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;QACnB,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC7D,CAAC;IACD,MAAM,cAAc,GAClB,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,WAAW,KAAK,MAAM,IAAI,WAAW,KAAK,OAAO,CAAC,CAAC;QACxE,CAAC,IAAI,KAAK,YAAY,IAAI,WAAW,KAAK,YAAY,CAAC,CAAC;IAC1D,OAAO;QACL,IAAI;QACJ,cAAc;QACd,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,sBAAsB;KAC5D,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,6BAA6B,CAAC,GAA8B;IAC1E,MAAM,WAAW,GAAG,4BAA4B,CAAC,GAAG,CAAC,CAAC;IACtD,OAAO;QACL,WAAW;QACX,QAAQ,EAAE,iBAAiB,CAAC,gBAAgB,CAAC,GAAG,CAAC,8BAA8B,CAAC,EAAE,WAAW,CAAC;QAC9F,eAAe,EAAE,iBAAiB,CAChC,gBAAgB,CAAC,GAAG,CAAC,sCAAsC,CAAC,EAC5D,WAAW,CACZ;KACF,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index daba3ca2..099df538 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,6 +9,7 @@ export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; export * from "./promotionsFeed.js"; +export * from "./rolloutCapabilities.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; diff --git a/packages/schema/src/rolloutCapabilities.test.ts b/packages/schema/src/rolloutCapabilities.test.ts new file mode 100644 index 00000000..f528162a --- /dev/null +++ b/packages/schema/src/rolloutCapabilities.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { + getClawHubRolloutCapabilities, + getClawHubRuntimeEnvironment, + parseRolloutMode, +} from "./rolloutCapabilities.js"; + +describe("rollout capabilities", () => { + it("defaults missing and invalid modes to off", () => { + expect(parseRolloutMode(undefined)).toBe("off"); + expect(parseRolloutMode("")).toBe("off"); + expect(parseRolloutMode("enabled")).toBe("off"); + }); + + it("detects explicit Test and production runtimes", () => { + expect( + getClawHubRuntimeEnvironment({ + CLAWHUB_ENV: "test", + CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", + }), + ).toBe("test"); + expect( + getClawHubRuntimeEnvironment({ + CONVEX_DEPLOYMENT: "prod:wry-manatee-359", + }), + ).toBe("production"); + }); + + it("allows test mode only in local and Test runtimes", () => { + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "off", + }), + ).toMatchObject({ + environment: "test", + skillsSh: { mode: "test", runtimeEnabled: true }, + githubSkillSync: { mode: "off", runtimeEnabled: false }, + }); + expect( + getClawHubRolloutCapabilities({ + CONVEX_DEPLOYMENT: "local:clawhub", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + }).githubSkillSync, + ).toMatchObject({ mode: "test", runtimeEnabled: true }); + }); + + it("fails closed when test mode is configured in production", () => { + expect( + getClawHubRolloutCapabilities({ + CONVEX_DEPLOYMENT: "prod:wry-manatee-359", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + }), + ).toMatchObject({ + environment: "production", + skillsSh: { + mode: "test", + runtimeEnabled: false, + reason: "environment-mismatch", + }, + githubSkillSync: { + mode: "test", + runtimeEnabled: false, + reason: "environment-mismatch", + }, + }); + }); + + it("lets Preview evidence override inherited Test markers", () => { + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_ENV: "test", + CLAWHUB_PREVIEW: "1", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + }), + ).toMatchObject({ + environment: "preview", + skillsSh: { mode: "test", runtimeEnabled: false }, + githubSkillSync: { mode: "test", runtimeEnabled: false }, + }); + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_ENV: "test", + VERCEL_ENV: "preview", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + }).skillsSh, + ).toMatchObject({ mode: "test", runtimeEnabled: false }); + }); + + it("recognizes the permanent Test target inside a Vercel preview deployment", () => { + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_ENV: "test", + VERCEL_ENV: "preview", + VERCEL_TARGET_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + }), + ).toMatchObject({ + environment: "test", + skillsSh: { mode: "test", runtimeEnabled: true }, + }); + }); + + it("lets a production deployment override a conflicting Test marker", () => { + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_DEPLOYMENT_NAME: "academic-chihuahua-392", + CLAWHUB_ENV: "test", + CONVEX_DEPLOYMENT: "prod:wry-manatee-359", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + }), + ).toMatchObject({ + environment: "production", + skillsSh: { + mode: "test", + runtimeEnabled: false, + reason: "environment-mismatch", + }, + githubSkillSync: { + mode: "test", + runtimeEnabled: false, + reason: "environment-mismatch", + }, + }); + }); + + it("allows production mode only in production", () => { + expect( + getClawHubRolloutCapabilities({ + CONVEX_DEPLOYMENT: "prod:wry-manatee-359", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "production", + }).skillsSh, + ).toMatchObject({ mode: "production", runtimeEnabled: true }); + expect( + getClawHubRolloutCapabilities({ + CLAWHUB_ENV: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "production", + }).skillsSh, + ).toMatchObject({ + mode: "production", + runtimeEnabled: false, + reason: "environment-mismatch", + }); + }); +}); diff --git a/packages/schema/src/rolloutCapabilities.ts b/packages/schema/src/rolloutCapabilities.ts new file mode 100644 index 00000000..164d9d47 --- /dev/null +++ b/packages/schema/src/rolloutCapabilities.ts @@ -0,0 +1,139 @@ +export const CLAWHUB_SKILLS_SH_ROLLOUT_MODE = "CLAWHUB_SKILLS_SH_ROLLOUT_MODE"; +export const CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE = "CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE"; + +export type ClawHubRolloutMode = "off" | "test" | "production"; +export type ClawHubRuntimeEnvironment = "local" | "test" | "preview" | "production" | "unknown"; + +export type ClawHubRolloutEnvironment = { + CLAWHUB_DEPLOYMENT_NAME?: string; + CLAWHUB_ENV?: string; + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE?: string; + CLAWHUB_PREVIEW?: string; + CLAWHUB_SKILLS_SH_ROLLOUT_MODE?: string; + CONVEX_CLOUD_URL?: string; + CONVEX_DEPLOYMENT?: string; + CONVEX_SITE_URL?: string; + DEV_AUTH_CONVEX_DEPLOYMENT?: string; + VERCEL_ENV?: string; + VERCEL_TARGET_ENV?: string; + VITE_CLAWHUB_DEPLOY_ENV?: string; + VITE_CONVEX_URL?: string; +}; + +export type ClawHubRolloutCapability = { + mode: ClawHubRolloutMode; + runtimeEnabled: boolean; + reason: "enabled" | "mode-off" | "environment-mismatch"; +}; + +const TEST_DEPLOYMENT = "academic-chihuahua-392"; + +function normalized(value: string | undefined) { + return value?.trim().toLowerCase() ?? ""; +} + +function runtimeMarker(env: ClawHubRolloutEnvironment) { + return ( + normalized(env.CLAWHUB_ENV) || + normalized(env.VITE_CLAWHUB_DEPLOY_ENV) || + normalized(env.VERCEL_TARGET_ENV) || + normalized(env.VERCEL_ENV) + ); +} + +function deploymentName(env: ClawHubRolloutEnvironment) { + const configured = normalized(env.CLAWHUB_DEPLOYMENT_NAME); + if (configured) return configured; + const deployment = normalized(env.CONVEX_DEPLOYMENT || env.DEV_AUTH_CONVEX_DEPLOYMENT); + const separator = deployment.indexOf(":"); + return separator >= 0 ? deployment.slice(separator + 1) : deployment; +} + +function isLocalUrl(value: string | undefined) { + if (!value) return false; + try { + const hostname = new URL(value).hostname; + return ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"].includes(hostname); + } catch { + return false; + } +} + +export function parseRolloutMode(value: string | undefined): ClawHubRolloutMode { + const mode = normalized(value); + if (mode === "test" || mode === "production") return mode; + return "off"; +} + +export function getClawHubRuntimeEnvironment( + env: ClawHubRolloutEnvironment, +): ClawHubRuntimeEnvironment { + const deployment = normalized(env.CONVEX_DEPLOYMENT || env.DEV_AUTH_CONVEX_DEPLOYMENT); + const name = deploymentName(env); + const vercelEnvironment = normalized(env.VERCEL_ENV); + const vercelTargetEnvironment = normalized(env.VERCEL_TARGET_ENV); + const permanentTestTarget = + vercelTargetEnvironment === "test" && + (name === TEST_DEPLOYMENT || + normalized(env.CLAWHUB_ENV) === "test" || + normalized(env.VITE_CLAWHUB_DEPLOY_ENV) === "test"); + if (deployment.startsWith("prod:")) return "production"; + if ( + env.CLAWHUB_PREVIEW === "1" || + vercelTargetEnvironment === "preview" || + (vercelEnvironment === "preview" && !permanentTestTarget) + ) { + return "preview"; + } + if (name === TEST_DEPLOYMENT) return "test"; + if (normalized(env.CLAWHUB_DEPLOYMENT_NAME)) { + return "production"; + } + if (vercelTargetEnvironment === "production" || vercelEnvironment === "production") { + return "production"; + } + + const marker = runtimeMarker(env); + if (marker === "test") return "test"; + if (marker === "production") return "production"; + if (marker === "preview") return "preview"; + if (marker === "local" || marker === "development") return "local"; + + if (deployment.startsWith("local:") || deployment.startsWith("dev:")) return "local"; + + const urls = [env.CONVEX_CLOUD_URL, env.CONVEX_SITE_URL, env.VITE_CONVEX_URL].filter( + (value): value is string => Boolean(value?.trim()), + ); + if (urls.some((value) => value.includes(TEST_DEPLOYMENT))) return "test"; + if (urls.length > 0 && urls.every(isLocalUrl)) return "local"; + return "unknown"; +} + +function resolveCapability( + mode: ClawHubRolloutMode, + environment: ClawHubRuntimeEnvironment, +): ClawHubRolloutCapability { + if (mode === "off") { + return { mode, runtimeEnabled: false, reason: "mode-off" }; + } + const runtimeEnabled = + (mode === "test" && (environment === "test" || environment === "local")) || + (mode === "production" && environment === "production"); + return { + mode, + runtimeEnabled, + reason: runtimeEnabled ? "enabled" : "environment-mismatch", + }; +} + +export function getClawHubRolloutCapabilities(env: ClawHubRolloutEnvironment) { + const environment = getClawHubRuntimeEnvironment(env); + return { + environment, + skillsSh: resolveCapability(parseRolloutMode(env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE), environment), + githubSkillSync: resolveCapability( + parseRolloutMode(env.CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE), + environment, + ), + }; +} diff --git a/scripts/vercel-build-frontend.test.ts b/scripts/vercel-build-frontend.test.ts index 721822bb..b26653b8 100644 --- a/scripts/vercel-build-frontend.test.ts +++ b/scripts/vercel-build-frontend.test.ts @@ -24,10 +24,35 @@ describe("Vercel frontend build environment", () => { expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("production"); }); + it.each(["test", "production"])( + "rejects %s rollout modes in an ordinary production build", + (mode) => { + expect(() => + resolveFrontendBuildEnv({ + VERCEL_ENV: "production", + VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: mode, + }), + ).toThrow(/explicit rollout activation/i); + }, + ); + + it("treats malformed production rollout modes as off", () => { + expect( + resolveFrontendBuildEnv({ + VERCEL_ENV: "production", + VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "enabled", + }).VITE_CLAWHUB_DEPLOY_ENV, + ).toBe("production"); + }); + it("preserves the permanent backend URLs for the custom test environment", () => { const env = resolveFrontendBuildEnv({ VERCEL_ENV: "preview", VERCEL_TARGET_ENV: "test", + CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE: "test", + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VITE_CONVEX_URL: "https://academic-chihuahua-392.convex.cloud", VITE_CONVEX_SITE_URL: "https://academic-chihuahua-392.convex.site", }); diff --git a/scripts/vercel-build-frontend.ts b/scripts/vercel-build-frontend.ts index 20481d3d..24c4371e 100644 --- a/scripts/vercel-build-frontend.ts +++ b/scripts/vercel-build-frontend.ts @@ -1,10 +1,22 @@ #!/usr/bin/env bun import { spawnSync } from "node:child_process"; +import { parseRolloutMode } from "clawhub-schema"; import { resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl"; export function resolveFrontendBuildEnv(env: NodeJS.ProcessEnv) { const targetEnvironment = env.VERCEL_TARGET_ENV?.trim() || env.VERCEL_ENV?.trim(); + if (targetEnvironment === "production") { + const activeMode = [ + env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE, + env.CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE, + ].find((value) => parseRolloutMode(value) !== "off"); + if (activeMode) { + throw new Error( + "Production skills rollout requires a separately authorized explicit rollout activation", + ); + } + } const convexSiteUrl = resolveConvexSiteUrl({ CONVEX_URL: env.CONVEX_URL, VITE_CONVEX_SITE_URL: targetEnvironment === "preview" ? undefined : env.VITE_CONVEX_SITE_URL, diff --git a/server/skillsShCatalogSource.test.ts b/server/skillsShCatalogSource.test.ts index 4ad324d2..96261eae 100644 --- a/server/skillsShCatalogSource.test.ts +++ b/server/skillsShCatalogSource.test.ts @@ -104,6 +104,21 @@ describe("skills.sh Vercel source boundary", () => { it("requires the Test build, Preview runtime, baked backend, and explicit enable", () => { expect( getSkillsShCatalogTestSourcePolicy({ + VERCEL_ENV: "preview", + VERCEL_TARGET_ENV: "test", + VITE_CLAWHUB_DEPLOY_ENV: "test", + VITE_CONVEX_URL: "https://academic-chihuahua-392.convex.cloud", + CLAWHUB_SKILLS_SH_TEST_LIVE_FETCH_ENABLED: "1", + }), + ).toMatchObject({ + allowed: false, + environment: "test", + reason: "skills.sh catalog rollout is disabled", + }); + + expect( + getSkillsShCatalogTestSourcePolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", CLAWHUB_SKILLS_SH_TEST_LIVE_FETCH_ENABLED: "1", }), @@ -111,6 +126,7 @@ describe("skills.sh Vercel source boundary", () => { expect( getSkillsShCatalogTestSourcePolicy({ + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", VERCEL_TARGET_ENV: "test", VITE_CLAWHUB_DEPLOY_ENV: "test", @@ -288,7 +304,9 @@ describe("skills.sh Vercel source boundary", () => { }); const options = { env: { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", + VERCEL_TARGET_ENV: "test", VITE_CLAWHUB_DEPLOY_ENV: "test", VITE_CONVEX_URL: "https://academic-chihuahua-392.convex.cloud", CLAWHUB_SKILLS_SH_TEST_LIVE_FETCH_ENABLED: "1", @@ -385,6 +403,7 @@ describe("skills.sh Vercel source boundary", () => { await expect( fetchSkillsShCatalogTestPage({ env: { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", VERCEL_TARGET_ENV: "test", VITE_CLAWHUB_DEPLOY_ENV: "test", @@ -411,6 +430,7 @@ describe("skills.sh Vercel source boundary", () => { await expect( fetchSkillsShCatalogTestPage({ env: { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", VERCEL_TARGET_ENV: "test", VITE_CLAWHUB_DEPLOY_ENV: "test", @@ -465,7 +485,9 @@ describe("skills.sh Vercel source boundary", () => { ); }); const env = { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "test", VERCEL_ENV: "preview", + VERCEL_TARGET_ENV: "test", VITE_CLAWHUB_DEPLOY_ENV: "test", VITE_CONVEX_URL: "https://academic-chihuahua-392.convex.cloud", CLAWHUB_SKILLS_SH_TEST_LIVE_FETCH_ENABLED: "1", diff --git a/server/skillsShCatalogSource.ts b/server/skillsShCatalogSource.ts index b2ad062c..c4f59810 100644 --- a/server/skillsShCatalogSource.ts +++ b/server/skillsShCatalogSource.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { getVercelOidcToken, verifyVercelOidcToken, type VercelOidcPayload } from "@vercel/oidc"; +import { getClawHubRolloutCapabilities } from "clawhub-schema"; const SKILLS_SH_API_BASE = "https://skills.sh/api/v1"; const MAX_SOURCE_PAGE_SIZE = 500; @@ -10,6 +11,7 @@ const CLAWHUB_VERCEL_PROJECT_ID = "prj_UVAJPNPYrBwTEkPJwkpEySsge8Mc"; const CLAWHUB_TEST_CONVEX_URL = "https://academic-chihuahua-392.convex.cloud"; export type SkillsShCatalogSourceEnv = { + CLAWHUB_SKILLS_SH_ROLLOUT_MODE?: string; CLAWHUB_SKILLS_SH_TEST_LIVE_FETCH_ENABLED?: string; VERCEL_ENV?: string; VERCEL_OIDC_TOKEN?: string; @@ -157,6 +159,14 @@ export async function fetchSkillsShCatalogDetail( } export function getSkillsShCatalogTestSourcePolicy(env: SkillsShCatalogSourceEnv = process.env) { + const rollout = getClawHubRolloutCapabilities(env); + if (!rollout.skillsSh.runtimeEnabled) { + return { + allowed: false as const, + environment: rollout.environment, + reason: "skills.sh catalog rollout is disabled", + }; + } if (env.VITE_CLAWHUB_DEPLOY_ENV !== "test") { return { allowed: false as const, diff --git a/src/__tests__/deploy-test-workflow.test.ts b/src/__tests__/deploy-test-workflow.test.ts index 64dc2b1d..f82cea8e 100644 --- a/src/__tests__/deploy-test-workflow.test.ts +++ b/src/__tests__/deploy-test-workflow.test.ts @@ -71,10 +71,12 @@ describe("Test deploy workflow", () => { }); expect(steps.filter((step) => step.env?.CONVEX_DEPLOY_KEY).map((step) => step.name)).toEqual([ "Check Test configuration", + "Enable Test rollout modes", "Stamp Convex build SHA", "Stamp Convex deploy time", "Deploy Convex Test", "Verify Convex contract", + "Verify Test rollout capabilities", "Apply additive Test fixtures", ]); expect(steps.filter((step) => step.env?.VERCEL_TOKEN).map((step) => step.name)).toEqual([ @@ -112,9 +114,27 @@ describe("Test deploy workflow", () => { expect(deployStep?.run).toContain("--target=preview"); expect(deployStep?.run).toContain('--scope "$VERCEL_SCOPE"'); expect(deployStep?.run).toContain("--build-env CONVEX_DEPLOY_KEY="); + expect(deployStep?.run).toContain("--build-env CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test"); + expect(deployStep?.run).toContain("--build-env CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE=test"); + expect(deployStep?.run).toContain("--env CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test"); + expect(deployStep?.run).toContain("--env CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE=test"); expect(aliasStep?.run).toContain("vercel@50.44.0 alias set"); expect(aliasStep?.run).toContain('"$DEPLOYMENT_URL"'); expect(aliasStep?.run).not.toContain("${{ steps.vercel.outputs.deployment_url }}"); expect(aliasStep?.run).toContain('--scope "$VERCEL_SCOPE"'); }); + + it("activates and reads back both rollout modes only in permanent Test", async () => { + const workflow = await readWorkflow(); + const steps = workflow.jobs?.["deploy-test"]?.steps ?? []; + const enable = steps.find((step) => step.name === "Enable Test rollout modes"); + const verify = steps.find((step) => step.name === "Verify Test rollout capabilities"); + + expect(enable?.run).toContain("CLAWHUB_SKILLS_SH_ROLLOUT_MODE test"); + expect(enable?.run).toContain("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE test"); + expect(verify?.run).toContain("rolloutCapabilities:getPublicCapabilities"); + expect(verify?.run).toContain('.environment == "test"'); + expect(verify?.run).toContain(".skillsSh.runtimeEnabled == true"); + expect(verify?.run).toContain(".githubSkillSync.selfServiceEnabled == true"); + }); }); diff --git a/src/__tests__/deploy-workflow.test.ts b/src/__tests__/deploy-workflow.test.ts index 2ce789d4..d3a7036a 100644 --- a/src/__tests__/deploy-workflow.test.ts +++ b/src/__tests__/deploy-workflow.test.ts @@ -51,17 +51,36 @@ describe("production deploy workflow", () => { expect(deployJob?.env).toEqual({ PLAYWRIGHT_BASE_URL: "https://clawhub.ai" }); expect(convexSecretSteps).toEqual([ "Check deploy configuration", + "Require dark rollout modes", "Stamp Convex build SHA", "Stamp Convex deploy time", "Deploy Convex", "Publish promotions feed snapshot", "Verify Convex contract", + "Verify dark rollout capabilities", ]); expect(authSecretSteps).toEqual(["Write authenticated storage state"]); expect(tagJob?.permissions).toEqual({ contents: "write" }); expect(tagJob?.needs).toEqual(["validate-deploy-request", "deploy-production"]); }); + it("refuses production deploys unless both rollout modes are off and reads them back dark", async () => { + const workflow = parseYaml(await readFile(".github/workflows/deploy.yml", "utf8")) as { + jobs?: Record; + }; + const steps = workflow.jobs?.["deploy-production"]?.steps ?? []; + const requireDark = steps.find((step) => step.name === "Require dark rollout modes"); + const verifyDark = steps.find((step) => step.name === "Verify dark rollout capabilities"); + + expect(requireDark?.run).toContain("CLAWHUB_SKILLS_SH_ROLLOUT_MODE"); + expect(requireDark?.run).toContain("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE"); + expect(requireDark?.run).toContain('""|off'); + expect(verifyDark?.run).toContain("rolloutCapabilities:getPublicCapabilities"); + expect(verifyDark?.run).toContain('.environment == "production"'); + expect(verifyDark?.run).toContain(".skillsSh.runtimeEnabled == false"); + expect(verifyDark?.run).toContain(".githubSkillSync.selfServiceEnabled == false"); + }); + it("publishes the initial promotions snapshot after backend deploy", async () => { const workflow = parseYaml(await readFile(".github/workflows/deploy.yml", "utf8")) as { jobs?: Record; diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx index 80b9befc..e44e68db 100644 --- a/src/routes/-settings.test.tsx +++ b/src/routes/-settings.test.tsx @@ -138,6 +138,7 @@ function mockSignedInSettings({ memberships = [orgMembership], members = orgMembers, githubSources = [], + githubSkillSyncEnabled = true, pendingInvites = [], myInvites = [], githubOrgMemberships = { @@ -205,6 +206,7 @@ function mockSignedInSettings({ }>; updatedAt: number; }>; + githubSkillSyncEnabled?: boolean; } = {}) { useAuthStatusMock.mockReturnValue({ isAuthenticated: true, @@ -219,6 +221,24 @@ function mockSignedInSettings({ if (queryName === "tokens:listMine") return []; if (queryName === "publishers:listMine") return memberships; if (queryName === "githubOrgMemberships:listMine") return githubOrgMemberships; + if (queryName === "rolloutCapabilities:getPublicCapabilities") { + return { + environment: "test", + skillsSh: { + mode: "test", + runtimeEnabled: true, + discoveryEnabled: false, + writesEnabled: false, + publicCatalogEnabled: false, + scanPlanningEnabled: false, + scanAdmissionEnabled: false, + }, + githubSkillSync: { + mode: githubSkillSyncEnabled ? "test" : "off", + selfServiceEnabled: githubSkillSyncEnabled, + }, + }; + } if (queryName === "publishers:getDeletionInventory") { return deletionInventoryLoading ? undefined : []; } @@ -796,6 +816,22 @@ describe("Settings", () => { expect(screen.queryByPlaceholderText("Enter a public repo")).toBeNull(); }); + it("does not expose GitHub Skill Sync when the backend capability is disabled", () => { + mockSignedInSettings({ + search: { view: "githubSources" }, + memberships: [orgMembership], + githubSkillSyncEnabled: false, + }); + + render(); + + expect(screen.queryByRole("button", { name: "GitHub Skill Sync" })).toBeNull(); + expect( + screen.getByRole("button", { name: "Account & Preferences" }).getAttribute("aria-current"), + ).toBe("true"); + expect(screen.queryByRole("heading", { name: "GitHub Skill Sync" })).toBeNull(); + }); + it("shows create organization mutation errors to the user", async () => { const createOrg = vi .fn() diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 0839b1aa..68a6a397 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -289,6 +289,7 @@ export function Settings() { api.githubOrgMemberships.listMine, shouldLoadAccountScopedQueries ? {} : "skip", ) as GitHubOrgMembershipsResult | undefined; + const rolloutCapabilities = useQuery(api.rolloutCapabilities.getPublicCapabilities, {}); const createOrg = useMutation(api.publishers.createOrg); const deleteOrg = useMutation(api.publishers.deleteOrg); const createOrgImageUpload = useMutation(api.publishers.createImageUpload); @@ -342,7 +343,9 @@ export function Settings() { (entry) => entry.publisher.official === true, ); const publisherMembershipsLoaded = publisherMemberships !== undefined; - const canConfigureGitHubSources = officialGitHubSourcePublishers.length > 0; + const canConfigureGitHubSources = + rolloutCapabilities?.githubSkillSync.selfServiceEnabled === true && + officialGitHubSourcePublishers.length > 0; const effectiveActiveView = activeView === "githubSources" && publisherMembershipsLoaded && !canConfigureGitHubSources ? "account"