mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
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:
@@ -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" \
|
||||
|
||||
@@ -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'
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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:")) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user