feat: keep external skill rollouts production-dark (#3236)

* feat: add fail-closed skill rollout gates

* fix: preserve scan queue pagination semantics
This commit is contained in:
Patrick Erichsen
2026-07-23 08:36:41 -07:00
committed by GitHub
parent 688329b343
commit fe8eff20ee
37 changed files with 1584 additions and 34 deletions
+26
View File
@@ -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" \
+38
View File
@@ -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'
+4
View File
@@ -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;
+36 -1
View File
@@ -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<string, unknown> & { _id: string };
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -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: [
+15 -2
View File
@@ -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
+124 -1
View File
@@ -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<string, unknown> & { _id: string };
function chainEq(constraints: Record<string, unknown>) {
@@ -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<string, unknown>) => {
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);
+28 -1
View File
@@ -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<SourceForSyncPage> {
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<SyncOneResult> {
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<typeof action> = 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[] = [];
@@ -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",
+7
View File
@@ -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);
+36
View File
@@ -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);
}
+28 -1
View File
@@ -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",
}),
+11
View File
@@ -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:")) {
+84
View File
@@ -0,0 +1,84 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { getPublicCapabilitiesHandler } from "./rolloutCapabilities";
function createControlDb(control: Record<string, unknown> | 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,
},
});
});
});
+45
View File
@@ -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<QueryCtx, "db">,
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),
});
+1
View File
@@ -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(),
+294 -15
View File
@@ -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<string, unknown>)[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<string, Record<string, unknown>>([
[
"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();
+131 -9
View File
@@ -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<MutationCtx, "db">,
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<MutationCtx, "db">,
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<ReadySourceJobsForClaimPage> {
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<ReadySourceJobsForClaimPage>(
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) {
+19
View File
@@ -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<string, string>) {
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);
}
+3 -2
View File
@@ -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")
+42
View File
@@ -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<typeof convexTest>;
function useEnvironment(env: Record<string, string>) {
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);
+1
View File
@@ -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";
+1
View File
@@ -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";
+1 -1
View File
@@ -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"}
{"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"}
+31
View File
@@ -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;
};
+100
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"rolloutCapabilities.js","sourceRoot":"","sources":["../src/rolloutCapabilities.ts"],"names":[],"mappings":"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"}
+1
View File
@@ -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";
@@ -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",
});
});
});
+139
View File
@@ -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,
),
};
}
+25
View File
@@ -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",
});
+12
View File
@@ -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,
+22
View File
@@ -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",
+10
View File
@@ -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,
@@ -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");
});
});
+19
View File
@@ -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<string, WorkflowJob>;
};
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<string, WorkflowJob>;
+36
View File
@@ -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(<Settings />);
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()
+4 -1
View File
@@ -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"