diff --git a/cli/internal/agentpluginscli/skills_full_flow_e2e_test.go b/cli/internal/agentpluginscli/skills_full_flow_e2e_test.go index 1f1f3fc76..5a9f0ac3d 100644 --- a/cli/internal/agentpluginscli/skills_full_flow_e2e_test.go +++ b/cli/internal/agentpluginscli/skills_full_flow_e2e_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" + "slices" "strings" "testing" "time" @@ -40,6 +42,8 @@ func skillsFullFlowFixture(t *testing.T) (savedJourney, string, []byte) { } savedJourneyGit(t, repo, "init", "-b", "main") savedJourneyGit(t, repo, "add", ".") + // Immutable Git executable metadata is independent of host filesystem bits. + savedJourneyGit(t, repo, "update-index", "--chmod=+x", "engineering/review/scripts/check.sh", "docs/review/scripts/check.sh") savedJourneyGit(t, repo, "commit", "-m", "test: disposable raw Skills end-to-end fixture") journey := newSavedJourney(t, repo) journey.cli.app.SourceAcquirer = skillsource.Acquirer{Acquirer: journey.source.Acquirer} @@ -65,8 +69,11 @@ func TestSkillsFullCLIEndToEnd(t *testing.T) { if err != nil || string(got) != string(raw) { t.Fatalf("raw Skill bytes changed: %q %v", got, err) } + if !slices.Contains(pin.ExecutableFiles, "skills/review/scripts/check.sh") || !slices.Contains(snapshot.Metadata.ExecutableFiles, "skills/review/scripts/check.sh") { + t.Fatalf("resource executable metadata missing: lock=%v store=%v", pin.ExecutableFiles, snapshot.Metadata.ExecutableFiles) + } script, err := os.Stat(filepath.Join(snapshot.Root, "skills", "review", "scripts", "check.sh")) - if err != nil || script.Mode()&0100 == 0 { + if err != nil || (runtime.GOOS != "windows" && script.Mode()&0100 == 0) { t.Fatalf("resource executable mode missing: %v %v", script, err) } state := journey.state(t) diff --git a/cmd/skills-catalog-api/main.go b/cmd/skills-catalog-api/main.go new file mode 100644 index 000000000..0ae92b9fd --- /dev/null +++ b/cmd/skills-catalog-api/main.go @@ -0,0 +1,63 @@ +// skills-catalog-api serves a bounded, cached anonymous skills catalog. +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/777genius/plugin-kit-ai/internal/skillscatalog" +) + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} +func run() error { + listen := flag.String("listen", env("SKILLS_CATALOG_LISTEN", "127.0.0.1:13120"), "HTTP listen address") + cacheDir := flag.String("cache-dir", env("SKILLS_CATALOG_CACHE_DIR", ".cache/skills-catalog"), "Private persistent cache directory") + origins := flag.String("origins", env("SKILLS_CATALOG_ORIGINS", "https://universal-agent-plugins.com,https://www.universal-agent-plugins.com"), "Comma-separated exact CORS origins; configure localhost explicitly for development") + flag.Parse() + service, err := skillscatalog.NewService(skillscatalog.NewSkillsSH(), *cacheDir) + if err != nil { + return err + } + configured := []string{} + for _, origin := range strings.Split(*origins, ",") { + if origin = strings.TrimSpace(origin); origin != "" { + configured = append(configured, origin) + } + } + handler, err := skillscatalog.NewHandler(service, configured) + if err != nil { + return err + } + server := &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + }() + log.Printf("Skills catalog listening on %s", *listen) + if err = server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/deploy/skills-catalog-api/skills-catalog.caddy b/deploy/skills-catalog-api/skills-catalog.caddy new file mode 100644 index 000000000..6f77b4b6f --- /dev/null +++ b/deploy/skills-catalog-api/skills-catalog.caddy @@ -0,0 +1,12 @@ +# Imported by /etc/caddy/Caddyfile through /etc/caddy/projects/*.caddy. +# The apex and www website continue to use their existing GitHub Pages hosting. +api.universal-agent-plugins.com { + reverse_proxy 127.0.0.1:13120 { + header_up -Authorization + header_up -Cookie + transport http { + dial_timeout 2s + response_header_timeout 12s + } + } +} diff --git a/deploy/skills-catalog-api/uap-skills-catalog.service b/deploy/skills-catalog-api/uap-skills-catalog.service new file mode 100644 index 000000000..3810ae89a --- /dev/null +++ b/deploy/skills-catalog-api/uap-skills-catalog.service @@ -0,0 +1,53 @@ +[Unit] +Description=Universal Agent Plugins public skills catalog +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +DynamicUser=yes +User=uap-skills-catalog +WorkingDirectory=/srv/platform/projects/universal-agent-plugins/current +ExecStart=/srv/platform/projects/universal-agent-plugins/current/skills-catalog-api +Environment=SKILLS_CATALOG_LISTEN=127.0.0.1:13120 +Environment=SKILLS_CATALOG_CACHE_DIR=/var/lib/uap-skills-catalog +Environment=SKILLS_CATALOG_ORIGINS=https://universal-agent-plugins.com,https://www.universal-agent-plugins.com +Environment=GOMEMLIMIT=192MiB +Environment=GOMAXPROCS=2 +StateDirectory=uap-skills-catalog +StateDirectoryMode=0700 +UMask=0077 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillSignal=SIGTERM + +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +MemoryHigh=192M +MemoryMax=256M +TasksMax=64 +LimitNOFILE=1024 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=uap-skills-catalog + +[Install] +WantedBy=multi-user.target diff --git a/docs/SKILLS_CATALOG_OPERATIONS.md b/docs/SKILLS_CATALOG_OPERATIONS.md new file mode 100644 index 000000000..cf0e3b1ab --- /dev/null +++ b/docs/SKILLS_CATALOG_OPERATIONS.md @@ -0,0 +1,209 @@ +# Skills catalog operations + +This is a deployment recipe, not evidence that deployment has occurred. The +service is separate from the static GitHub Pages landing site. Only +`api.universal-agent-plugins.com` routes to this service; the apex and `www` +remain on GitHub Pages. The API contract is +[skills-catalog.openapi.yaml](contracts/skills-catalog.openapi.yaml). + +Regenerate the landing DTOs after changing the contract: + +```sh +npm exec --yes --package=openapi-typescript@7.13.0 -- openapi-typescript docs/contracts/skills-catalog.openapi.yaml -o landing/types/skillsCatalog.generated.ts +``` + +The generator is pinned for reproducibility and is a development tool only. +The provider adapter uses anonymous skills.sh website/CLI endpoints, which are +undocumented and may change. Its interface and cached last good results contain +that dependency; the authenticated `/api/v1` service is not used. + +## Destination and owned paths + +Deployment inventory supplied for this service: + +- SSH identity/address: `@` from the private trusted host inventory. +- Hostname: expected value from the trusted host inventory. +- Machine ID: expected value from the trusted host inventory. +- Binary: `/srv/platform/projects/universal-agent-plugins/releases//skills-catalog-api`. +- Active release: `/srv/platform/projects/universal-agent-plugins/current` symlink. +- Unit: `/etc/systemd/system/uap-skills-catalog.service`. +- Route: `/etc/caddy/projects/skills-catalog.caddy`. +- Private persistent state: `/var/lib/uap-skills-catalog`. + +Verify the configured SSH host key through the trusted host inventory, then +check the hostname and `/etc/machine-id` before remote changes. Determine the +actual architecture with `uname -m`; map `x86_64` to Go `amd64` and `aarch64` to +`arm64`. Stop on an unexpected identity or architecture. No existing UAP +service or release root is assumed. Do not replace another project's files. +Caddy already imports `/etc/caddy/projects/*.caddy`; do not rewrite its shared +configuration or open a new public port. DNS provisioning is separate: the +new API hostname must resolve to this server, including checking any existing +AAAA record. Do not alter the website's DNS records. + +## Build and record the exact artifact + +Build from the reviewed release commit, with the relevant checks passing on +that exact source. Use a job-owned build directory, outside the source tree. +Set `CATALOG_GOARCH` to the architecture verified above. Record the full commit, +Go version, architecture, binary SHA-256, and focused test result with the +release evidence. The hash is specific to the actual binary, not the branch +name or this template. These commands run on the build host: + +```sh +set -eu +CATALOG_SHA=$(git rev-parse HEAD) +CATALOG_GOARCH=amd64 +CATALOG_BUILD_DIR=$(mktemp -d) +go version +go test ./internal/skillscatalog ./cmd/skills-catalog-api +CGO_ENABLED=0 GOOS=linux GOARCH="$CATALOG_GOARCH" go build -trimpath -buildvcs=false -o "$CATALOG_BUILD_DIR/skills-catalog-api" ./cmd/skills-catalog-api +shasum -a 256 "$CATALOG_BUILD_DIR/skills-catalog-api" +file "$CATALOG_BUILD_DIR/skills-catalog-api" +``` + +`CATALOG_GOARCH=amd64` is an example, not an assertion about the destination. +Do not label an artifact with `CATALOG_SHA` if the source has uncommitted edits. +No Node runtime, Docker, database, tokens, or application secrets are needed +on the destination. HTTPS access to `www.skills.sh`, working DNS, and the +system CA trust store are required. Never expose an upstream-origin override +or caller-supplied proxy URL. + +## Stage and activate + +Use a verified transfer into a unique operator-owned staging directory. Compare +its SHA-256 on the destination with the recorded build hash before installation. +Run the following activation steps in a root shell on the verified destination; +`CATALOG_STAGING` must contain the reviewed binary and the two templates from +`deploy/skills-catalog-api/`. Set `CATALOG_SHA` to the full reviewed commit. +Do not overwrite an existing release directory: compare and reuse an identical +artifact or stop to resolve the mismatch. Before running these commands, record +the previous `current` symlink target and save the prior versions of this +service's unit and route in the release evidence directory. Record absent files +and targets explicitly on first install. Check the staged unit's intended diff +before replacing an existing unit. + +```sh +set -eu +CATALOG_ROOT=/srv/platform/projects/universal-agent-plugins +CATALOG_RELEASE="$CATALOG_ROOT/releases/$CATALOG_SHA" +test ! -e "$CATALOG_RELEASE" +install -d -o root -g root -m 0755 "$CATALOG_ROOT" "$CATALOG_ROOT/releases" "$CATALOG_RELEASE" +install -o root -g root -m 0755 "$CATALOG_STAGING/skills-catalog-api" "$CATALOG_RELEASE/skills-catalog-api" +sha256sum "$CATALOG_RELEASE/skills-catalog-api" +``` + +Verify that every parent of the release binary is root owned and not writable +by the service. The binary is installed before switching `current`; unit +verification follows the switch so its executable exists on first install. + +Activate using a fresh temporary symlink name on the same filesystem, then +rename it over `current` atomically. This example uses a release-specific name +that must be absent before creation: + +```sh +set -eu +CATALOG_NEXT="$CATALOG_ROOT/.current-$CATALOG_SHA" +test ! -e "$CATALOG_NEXT" +test ! -L "$CATALOG_NEXT" +ln -s "$CATALOG_RELEASE" "$CATALOG_NEXT" +if test -e "$CATALOG_ROOT/current" && ! test -L "$CATALOG_ROOT/current"; then + exit 1 +fi +mv -Tf "$CATALOG_NEXT" "$CATALOG_ROOT/current" +install -o root -g root -m 0644 "$CATALOG_STAGING/uap-skills-catalog.service" /etc/systemd/system/uap-skills-catalog.service +systemd-analyze verify /etc/systemd/system/uap-skills-catalog.service +systemctl daemon-reload +systemctl enable uap-skills-catalog.service +systemctl restart uap-skills-catalog.service +systemctl is-active uap-skills-catalog.service +curl --retry 5 --retry-delay 1 --retry-connrefused --fail --silent --show-error http://127.0.0.1:13120/healthz +curl --fail --silent --show-error 'http://127.0.0.1:13120/v1/skills?view=popular&page=0' +``` + +If unit verification fails, stop before daemon reload or restart and restore +the recorded prior unit/current target. A health response proves the process is +serving. Before routing public traffic, check that a catalog request produces valid items or a truthful structured 503; +a 503 with no last good cache is not a successful catalog qualification. +The service listens only on loopback; do not add a firewall rule for port 13120. + +## Publish the dedicated route + +After backend checks and DNS verification, install only this route. Validate +the complete existing Caddy configuration, including imports, before reloading: + +```sh +set -eu +install -o root -g root -m 0644 "$CATALOG_STAGING/skills-catalog.caddy" /etc/caddy/projects/skills-catalog.caddy +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +systemctl reload caddy +curl --fail --silent --show-error https://api.universal-agent-plugins.com/healthz +curl --fail --silent --show-error --dump-header - -H 'Origin: https://universal-agent-plugins.com' 'https://api.universal-agent-plugins.com/v1/skills?view=popular&page=0' +``` + +Run the reload only if validation succeeds. On validation failure, restore this +route's prior bytes or remove this newly introduced file, then validate again; +do not reload the failed candidate. Verify the system's Caddy service reloads +`/etc/caddy/Caddyfile` rather than a different config. Public acceptance also +checks HTTPS, exact `Access-Control-Allow-Origin`, `cache.status`, `fetchedAt`, +all three views, and a two-character-or-longer search. Search omits a complete +`total` and returns a bounded result set. A disallowed Origin receives 403; +there is no credentialed CORS. Do not log full query responses or client headers +as routine operational telemetry. + +Caddy's [validation command](https://caddyserver.com/docs/command-line#caddy-validate) +checks loading and provisioning, a stronger check than merely adapting the file. + +## Persistence, limits, and normal operations + +The unit uses `DynamicUser` and `StateDirectory=uap-skills-catalog` with mode +0700. Systemd preserves the state directory across stops and restarts and manages +its ownership. With `DynamicUser`, the host may expose it through a symlink into +`/var/lib/private`; do not manually chown, move, replace, or clear it. See the +[systemd directory contract](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#StateDirectory=). + +After one successful browse, record its `fetchedAt`, restart only this service, +and request the same page within its 30-minute freshness window. The timestamp +should remain unchanged and the page should still be fresh. This verifies reuse +of persisted cache without blocking the real upstream or changing shared network +rules. Upstream outage behavior is covered by the focused local tests; do not +fault-inject on the production host. Search freshness lasts ten minutes. Valid +stale data remains usable up to seven days from its last successful fetch; +malformed, timed-out, rate-limited, or failed refreshes do not overwrite it. With +no usable cache, the API returns structured 503 and a retry delay. + +The cache holds at most 128 records with at most 2 MiB per response. Fetches have +bounded concurrency and rate, and upstream 429 retry delays apply. No cron or +refresh daemon is required. The unit's Go memory target is 192 MiB, with a +256 MiB cgroup hard limit, and task/file-descriptor limits. The memory target is +soft; the [cgroup limit](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#MemoryMax=) +can terminate the service if exceeded. Check normal browse/search traffic and +journal evidence before changing these limits. + +```sh +set -eu +systemctl status --no-pager uap-skills-catalog.service +journalctl -u uap-skills-catalog.service -n 50 --no-pager +systemctl show uap-skills-catalog.service -p MemoryCurrent -p MemoryPeak -p NRestarts -p ActiveState +``` + +After the bounded restart budget is exhausted, diagnose the failure before using +`systemctl reset-failed uap-skills-catalog.service` and restarting. Keep previous +release binaries and deployment evidence for rollback. Do not run +`systemctl clean` or remove the state directory during a normal update. + +## Rollback this service only + +For an update rollback, verify the prior release's recorded SHA-256, restore +this service's previous unit if it changed, atomically repoint `current` to the +recorded prior release, run `systemctl daemon-reload`, and restart only +`uap-skills-catalog.service`. Check loopback health and catalog behavior. Leave +the persistent cache intact. Restore this service's prior route if changed, +validate the full Caddy configuration, and reload Caddy only on success. + +For first-install removal, remove only +`/etc/caddy/projects/skills-catalog.caddy`, validate the full Caddy configuration, +and reload Caddy only if it succeeds. Then disable and stop only +`uap-skills-catalog.service`. Preserve its binary, unit, state, and evidence until +the rollback is confirmed. Do not remove the release root, cache, shared Caddy +files, other services, website DNS, or GitHub Pages configuration. Removing the +API DNS record is a separate explicit action, not part of this template. diff --git a/docs/contracts/skills-catalog.openapi.yaml b/docs/contracts/skills-catalog.openapi.yaml new file mode 100644 index 000000000..d75b3af9a --- /dev/null +++ b/docs/contracts/skills-catalog.openapi.yaml @@ -0,0 +1,188 @@ +openapi: 3.0.3 +info: + title: Public skills catalog + version: 1.0.0 + description: >- + Anonymous, read-only catalog with a replaceable provider contract. Current + provider is skills.sh. Listings are unverified hints, not installation, + repository-folder, commit, declared-name, trust or security evidence. + Browse is zero-based, fixed at up to 200 upstream results per page. Search + returns at most 100 matches without usable pagination or complete totals. + Fresh TTL is 30 minutes for browse and 10 minutes for search. During upstream + failures, schema-valid cached results may be served for at most seven days + from fetchedAt, including after restart. A valid empty response is a success. +servers: + - url: https://api.universal-agent-plugins.com +paths: + /healthz: + get: + operationId: health + responses: + '200': + description: Process is serving; independent of upstream availability. + content: + application/json: + schema: + type: object + required: [status] + properties: + status: {type: string, enum: [ok]} + /v1/skills: + get: + operationId: listSkills + description: >- + Only listed query parameters are accepted, once each. CORS permits exact + configured origins without credentials. No caller authentication, cookies, + or upstream response headers are forwarded. Search always uses the + canonical popular view and page zero. Global bounded upstream concurrency + and rate limits apply; cache misses can return 503 with Retry-After. + parameters: + - in: query + name: view + schema: {$ref: '#/components/schemas/CatalogView'} + - in: query + name: page + description: Zero-based browse page. Search requires zero. + schema: {type: integer, minimum: 0, maximum: 10000, default: 0} + - in: query + name: q + description: Search text, trimmed; at least two Unicode characters, at most 200 UTF-8 bytes, no control characters. + schema: {type: string, minLength: 2, maxLength: 200} + - in: query + name: limit + description: Search only. Browse always uses the provider's fixed page size. + schema: {type: integer, minimum: 1, maximum: 100, default: 100} + responses: + '200': + description: Schema-valid fresh or stale listing. Inspect cache.status and fetchedAt. + content: + application/json: + schema: {$ref: '#/components/schemas/SkillsPage'} + examples: + browse: + value: + items: + - id: skills-sh:vercel-labs/skills:find-skills + providerId: skills-sh + providerUrl: https://www.skills.sh/vercel-labs/skills/find-skills + title: find-skills + skillNameHint: find-skills + source: + kind: github + repository: vercel-labs/skills + url: https://github.com/vercel-labs/skills + metrics: {installs: 1234, window: all-time} + availability: unverified + total: 1 + hasMore: false + page: 0 + view: popular + cache: {status: fresh, fetchedAt: '2026-10-24T00:00:00Z'} + search: + value: + items: [] + hasMore: false + page: 0 + query: find skills + view: popular + cache: {status: fresh, fetchedAt: '2026-10-24T00:00:00Z'} + warnings: [search_results_limited] + '400': + description: Invalid, duplicate or unknown query parameter. + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + '403': + description: Browser origin or preflight is not allowed. + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + '503': + description: Provider unavailable and no cache entry within seven days. + headers: + Retry-After: + description: Minimum seconds before another request is useful. + schema: {type: integer, minimum: 1} + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + example: + error: {code: provider_unavailable, message: Skills provider is temporarily unavailable} + retryAfter: 30 +components: + schemas: + CatalogView: + type: string + enum: [popular, trending, hot] + default: popular + SkillSource: + type: object + required: [kind, url] + properties: + kind: {type: string, enum: [github, external]} + repository: + type: string + description: Present only for a validated two-segment GitHub owner/repository locator. No folder or revision is inferred. + url: {type: string, format: uri} + SkillMetrics: + type: object + required: [installs, window] + properties: + installs: {type: integer, format: int64, minimum: 0} + window: + type: string + enum: [all-time, 24h, 1h] + description: Provider ranking window; not an independently verified measurement. + SkillListing: + type: object + required: [id, providerId, providerUrl, title, skillNameHint, source, metrics, availability] + properties: + id: {type: string, description: Opaque provider-namespaced identifier.} + providerId: {type: string} + providerUrl: {type: string, format: uri} + title: {type: string} + skillNameHint: + type: string + description: Exact raw upstream name, including Unicode and case, as an unverified CLI selection hint. Never a declared or validated installer name; the listing slug identifies only the provider detail page. + source: {$ref: '#/components/schemas/SkillSource'} + metrics: {$ref: '#/components/schemas/SkillMetrics'} + availability: {type: string, enum: [unverified, unsupported-source]} + CatalogCache: + type: object + required: [status, fetchedAt] + properties: + status: {type: string, enum: [fresh, stale]} + fetchedAt: {type: string, format: date-time, description: Timestamp of the last schema-valid upstream success, never the failed refresh.} + SkillsPage: + type: object + required: [items, hasMore, page, view, cache] + properties: + items: + type: array + maxItems: 200 + items: {$ref: '#/components/schemas/SkillListing'} + total: + type: integer + format: int64 + minimum: 0 + description: Browse provider total only. Omitted for bounded search results. + hasMore: {type: boolean} + page: {type: integer, minimum: 0} + query: {type: string} + view: {$ref: '#/components/schemas/CatalogView'} + cache: {$ref: '#/components/schemas/CatalogCache'} + warnings: + type: array + items: {type: string} + description: Includes search_results_limited, provider_unavailable for stale results, or cache_persistence_unavailable. + CatalogError: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + retryAfter: {type: integer, minimum: 1, description: Seconds before retry, when available.} diff --git a/docs/plans/skills-catalog/https-skill-source-research.md b/docs/plans/skills-catalog/https-skill-source-research.md new file mode 100644 index 000000000..6373e9e8d --- /dev/null +++ b/docs/plans/skills-catalog/https-skill-source-research.md @@ -0,0 +1,62 @@ +# HTTPS Skill sources: research checkpoint + +Research only, 2026-10-24. The current published `skills add` accepts GitHub raw +Skills. This checkpoint does not expose unsupported installation commands. + +## Findings + +- In the live first popular page, 29 of 200 listings use website sources: + 27 from open.feishu.cn and two from uizze.sh. +- Feishu serves 28 entries at + `https://open.feishu.cn/.well-known/skills/index.json`; its preferred + agent-skills endpoint currently returns HTML rather than an index. +- Uizze serves a valid files-list index at + `https://uizze.sh/.well-known/agent-skills/index.json`. +- Agent Skills discovery is still a proposal, not part of the accepted format + specification: [proposal #255](https://github.com/agentskills/agentskills/issues/255), + [open spec PR #254](https://github.com/agentskills/agentskills/pull/254). + Pin the accepted dialects rather than treating future schema changes as compatible. +- Vercel's implementation at + [a80e4c9](https://github.com/vercel-labs/skills/blob/a80e4c9d12d607631249d4d7d059f835d4b2f13a/src/providers/wellknown.ts) + supports the preferred endpoint, legacy fallback, files-list entries and the + proposed 0.2 artifact model (`skill-md` or `archive`, URL and SHA-256 digest). +- UAP [PR #473](https://github.com/777genius/universal-agent-plugins/pull/473) + adds native Git/SSH and HTTPS archive acquisition for complete `plugin.json` + packages. Its raw Skills integration explicitly remains GitHub-only. That PR + is unreleased, held unmerged and under follow-up review; do not merge it as + part of the catalog work or advertise its transports as released. + +## Recommended extension + +Add an HTTPS discovery source to `agentplugins skills add --skill `. +The catalog is a discovery provider; the installer acquires directly from the +publisher through an independent Skill-source adapter. + +1. Preserve the publisher origin/base path in the catalog DTO. Do not derive an + install URL from the provider's listing-page slug or invent a GitHub mirror. +2. Discover the preferred index, then the legacy endpoint when unavailable or + invalid. An explicit path scope must never silently expand to the origin's + whole catalog. Select exactly one declared Skill name; refuse duplicates. +3. Acquire the entire declared files list for legacy entries. For the proposed + artifact format, acquire the declared single file or full archive and verify + its digest. Never crawl HTML or guess resource files from Markdown links. +4. Feed the sealed raw tree through existing Skill validation, normalization, + security assessment, shared store and agent projection. Reuse reviewed bounded + archive/download services from #473 when available; do not reimplement them. +5. Give HTTPS sources their own saved source variant. Keep existing Git pins and + source keys unchanged. Lock origin, selected name, resolved index/artifact + coordinates, content/file-set identity, raw tree digest, normalized digest and + normalizer version. A mutable website does not provide an immutable Git ref. +6. Update explicitly re-resolves the name and creates a new pin. Restore/repair + uses the recorded bytes/digests, cached or exported snapshots, and refuses + changed or missing historical content. Do not promise cold restoration when + neither the publisher nor a retained snapshot can supply the old bytes. +7. The discovery proxy must not become an arbitrary URL downloader. Publisher + acquisition belongs to the CLI and retains its network, extraction, + cancellation and path-safety policies. + +Acceptance focuses on both live publisher dialects, exact-name selection, +complete resources, digest/path rejection, and saved add/update/exact restore. +Enable website install commands only after the released CLI supports this mode. +Until then show a truthful unsupported-source state, without claiming that a +GitHub repository is intrinsically required by the Skill format. diff --git a/install/integrationctl/agentplugins/adapters/skillsource/normalize.go b/install/integrationctl/agentplugins/adapters/skillsource/normalize.go index 2269aa2da..927e824ba 100644 --- a/install/integrationctl/agentplugins/adapters/skillsource/normalize.go +++ b/install/integrationctl/agentplugins/adapters/skillsource/normalize.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "strings" @@ -61,7 +62,7 @@ func Normalize(ctx context.Context, raw domain.PackageSnapshot, expectedName, st if err = writeSyntheticManifest(temp, stableSourceKey); err != nil { return domain.PackageSnapshot{}, err } - artifact, err := builder.Snapshot(ctx, temp, raw.Source) + artifact, err := snapshotNormalizedTree(ctx, builder, temp, verified, skill.Name) if err != nil { return domain.PackageSnapshot{}, err } @@ -138,3 +139,13 @@ func writeSyntheticManifest(root, stableSourceKey string) error { } return nil } + +// snapshotNormalizedTree seals the portable package after raw content is copied. +// Executable identity belongs to the immutable Git inventory, not host mode bits. +func snapshotNormalizedTree(ctx context.Context, builder packagedigest.Builder, root string, raw domain.PackageSnapshot, skillName string) (domain.PackageSnapshot, error) { + executables := make([]string, 0, len(raw.ExecutableFiles)) + for _, relative := range raw.ExecutableFiles { + executables = append(executables, path.Join("skills", skillName, relative)) + } + return builder.SnapshotWithExecutables(ctx, root, raw.Source, executables) +} diff --git a/install/integrationctl/agentplugins/adapters/skillsource/skillsource_test.go b/install/integrationctl/agentplugins/adapters/skillsource/skillsource_test.go index 9b936139e..75f8035f5 100644 --- a/install/integrationctl/agentplugins/adapters/skillsource/skillsource_test.go +++ b/install/integrationctl/agentplugins/adapters/skillsource/skillsource_test.go @@ -386,3 +386,49 @@ func TestRootRawSkillRejectsGitlinksBeforeSnapshot(t *testing.T) { t.Fatal(err) } } + +// Windows lacks POSIX executable bits. Exercise the real final-sealing boundary +// with non-executable host files so portable Git metadata remains authoritative. +func TestNormalizedTreeSealingUsesPortableExecutableInventory(t *testing.T) { + for _, withExecutable := range []bool{false, true} { + t.Run(fmt.Sprint(withExecutable), func(t *testing.T) { + root := t.TempDir() + put(t, root, "skills/review/SKILL.md", document("review"), 0600) + raw := domain.PackageSnapshot{Source: domain.SourceIdentity{Repository: "example/fixture"}} + expected := []string{} + if withExecutable { + put(t, root, "skills/review/scripts/run", "#!/bin/sh\nprintf resource-only\n", 0600) + raw.ExecutableFiles = []string{"scripts/run"} + expected = []string{"skills/review/scripts/run"} + info, err := os.Stat(filepath.Join(root, "skills/review/scripts/run")) + if err != nil || info.Mode()&0111 != 0 { + t.Fatalf("fixture must have no host executable bits: %v %v", info, err) + } + } + if err := writeSyntheticManifest(root, "github-skill:example/fixture:review:review"); err != nil { + t.Fatal(err) + } + builder := packagedigest.Builder{TempRoot: t.TempDir()} + got, err := snapshotNormalizedTree(context.Background(), builder, root, raw, "review") + if err != nil { + t.Fatal(err) + } + defer func() { _ = packagedigest.Remove(got) }() + if !slices.Equal(got.ExecutableFiles, expected) { + t.Fatalf("portable executable metadata lost: got %v want %v", got.ExecutableFiles, expected) + } + reference, err := builder.SnapshotWithExecutables(context.Background(), root, raw.Source, expected) + if err != nil { + t.Fatal(err) + } + defer func() { _ = packagedigest.Remove(reference) }() + if got.DigestAlgorithm != domain.TreeDigestAlgorithm || got.TreeDigest != reference.TreeDigest { + t.Fatalf("portable tree identity differs: got %s want %s", got.TreeDigest, reference.TreeDigest) + } + body, err := os.ReadFile(filepath.Join(got.Root, "skills/review/SKILL.md")) + if err != nil || string(body) != document("review") { + t.Fatal("sealing changed raw document", err) + } + }) + } +} diff --git a/internal/skillscatalog/cache.go b/internal/skillscatalog/cache.go new file mode 100644 index 000000000..e9048ae48 --- /dev/null +++ b/internal/skillscatalog/cache.go @@ -0,0 +1,101 @@ +package skillscatalog + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +func cacheFilename(name string) bool { + if len(name) != 69 || !strings.HasSuffix(name, ".json") { + return false + } + _, err := hex.DecodeString(name[:64]) + return err == nil +} +func (s *Service) load() error { + files, err := os.ReadDir(s.dir) + if err != nil { + return err + } + // This directory contains only our hex-key records; other files are untouched. + for _, file := range files { + if !cacheFilename(file.Name()) { + continue + } + if e, ok := s.loadEntry(file); ok { + s.entries[e.Key] = e + } + } + s.trimLocked() + return nil +} +func (s *Service) loadEntry(file os.DirEntry) (entry, bool) { + path := filepath.Join(s.dir, file.Name()) + info, err := file.Info() + if err != nil || !info.Mode().IsRegular() { + return entry{}, false + } + if info.Size() > maxBodyBytes { + _ = os.Remove(path) + return entry{}, false + } + b, err := os.ReadFile(path) + if err != nil { + return entry{}, false + } + var e entry + if !validStoredJSON(b) || json.Unmarshal(b, &e) != nil || !s.validEntry(e, file.Name()[:64]) { + _ = os.Remove(path) + return entry{}, false + } + return e, true +} +func (s *Service) validEntry(e entry, key string) bool { + return e.Key == key && e.Key == e.Request.key(s.provider.ID()) && validRequest(e.Request) && validPage(e.Result, e.Request, s.provider.ID()) && s.usable(e.Result, s.now()) +} +func (s *Service) persist(e entry) bool { + b, err := json.Marshal(e) + if err != nil || len(b) > maxBodyBytes { + return false + } + f, err := os.CreateTemp(s.dir, ".catalog-*") + if err != nil { + return false + } + name := f.Name() + // Cleanup errors cannot invalidate a successful rename; all records remain + // independently bounded and validated when read. + defer func() { _ = os.Remove(name) }() + if _, err = f.Write(b); err != nil { + _ = f.Close() + return false + } + if err = f.Sync(); err != nil { + _ = f.Close() + return false + } + if f.Close() != nil { + return false + } + return os.Rename(name, filepath.Join(s.dir, e.Key+".json")) == nil +} +func (s *Service) trimLocked() { + if len(s.entries) <= maxEntries { + return + } + keys := make([]string, 0, len(s.entries)) + for key := range s.entries { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return s.entries[keys[i]].Result.Cache.FetchedAt.Before(s.entries[keys[j]].Result.Cache.FetchedAt) + }) + for _, key := range keys[:len(keys)-maxEntries] { + delete(s.entries, key) + _ = os.Remove(filepath.Join(s.dir, key+".json")) + } +} diff --git a/internal/skillscatalog/catalog.go b/internal/skillscatalog/catalog.go new file mode 100644 index 000000000..fd6785c06 --- /dev/null +++ b/internal/skillscatalog/catalog.go @@ -0,0 +1,63 @@ +// Package skillscatalog owns the public, provider-independent listing contract. +package skillscatalog + +import ( + "context" + "time" +) + +type View string + +const ( + Popular View = "popular" + Trending View = "trending" + Hot View = "hot" +) + +func (v View) Valid() bool { return v == Popular || v == Trending || v == Hot } + +type Source struct { + Kind string `json:"kind"` + Repository string `json:"repository,omitempty"` + URL string `json:"url"` +} +type Metrics struct { + Installs int64 `json:"installs"` + Window string `json:"window"` +} +type SkillListing struct { + ID string `json:"id"` + ProviderID string `json:"providerId"` + ProviderURL string `json:"providerUrl"` + Title string `json:"title"` + SkillNameHint string `json:"skillNameHint"` + Source Source `json:"source"` + Metrics Metrics `json:"metrics"` + Availability string `json:"availability"` +} +type CacheInfo struct { + Status string `json:"status"` + FetchedAt time.Time `json:"fetchedAt"` +} +type Page struct { + Items []SkillListing `json:"items"` + Total *int64 `json:"total,omitempty"` + HasMore bool `json:"hasMore"` + Page int `json:"page"` + Query string `json:"query,omitempty"` + View View `json:"view"` + Cache CacheInfo `json:"cache"` + Warnings []string `json:"warnings,omitempty"` +} + +// Provider returns validated listings. Search returns a bounded result set, not +// a complete census; it must omit Total unless the provider proves completeness. +type Provider interface { + ID() string + Browse(context.Context, View, int) (Page, error) + Search(context.Context, string, int) (Page, error) +} + +type ProviderError struct{ RetryAfter time.Duration } + +func (*ProviderError) Error() string { return "skills provider unavailable" } diff --git a/internal/skillscatalog/catalog_test.go b/internal/skillscatalog/catalog_test.go new file mode 100644 index 000000000..9cd68e74b --- /dev/null +++ b/internal/skillscatalog/catalog_test.go @@ -0,0 +1,617 @@ +package skillscatalog + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const sampleSkill = `{"source":"vercel-labs/skills","skillId":"find-skills","name":"Find skills","installs":1234,"newField":"allowed"}` + +func browseJSON(skill string) string { + return `{"skills":[` + skill + `],"total":1,"hasMore":false,"page":0}` +} +func upstream(t *testing.T, handler http.HandlerFunc) *SkillsSH { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return newSkillsSH(srv.URL, srv.Client().Transport) +} +func TestProviderNormalizationAndSafeLocators(t *testing.T) { + cases := []struct { + source string + github bool + }{ + {"vercel-labs/skills", true}, {"owner/repo/tree/main", false}, {"https://github.com/owner/repo", false}, {"owner/..", false}, {"owner/repo.git", false}, {"owner/repo#frag", false}, {"evil:payload", false}, + } + for _, tc := range cases { + t.Run(tc.source, func(t *testing.T) { + skill := strings.Replace(sampleSkill, "vercel-labs/skills", tc.source, 1) + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/skills/trending/0" { + t.Errorf("path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("credentials forwarded") + } + _, _ = w.Write([]byte(browseJSON(skill))) + }) + page, err := p.Browse(context.Background(), Trending, 0) + if err != nil { + t.Fatal(err) + } + i := page.Items[0] + if i.Metrics.Window != "24h" || i.Metrics.Installs != 1234 || i.SkillNameHint != "Find skills" { + t.Fatalf("wrong normalized listing: %+v", i) + } + if tc.github { + if i.Source.URL != "https://github.com/vercel-labs/skills" || i.Source.Repository != "vercel-labs/skills" || i.Availability != "unverified" { + t.Fatal(i) + } + } else { + if i.Source.Kind != "external" || i.Source.Repository != "" || i.Availability != "unsupported-source" || !strings.HasPrefix(i.Source.URL, skillsOrigin+"/") { + t.Fatal(i) + } + } + }) + } +} +func TestMalformedAndBoundedUpstreamNeverBecomeEmptySuccess(t *testing.T) { + cases := []string{`{}`, `{"skills":null,"total":0,"hasMore":false,"page":0}`, `{"skills":[],"total":0,"page":0}`, browseJSON(`{"source":"owner/repo","skillId":"x","name":"x"}`), browseJSON(strings.Replace(sampleSkill, `"installs":1234`, `"installs":-1`, 1)), browseJSON(strings.Replace(sampleSkill, `"name":"Find skills"`, `"name":"bad\nname"`, 1)), browseJSON(strings.Replace(sampleSkill, `"source":"vercel-labs/skills"`, `"source":"evil\nsource"`, 1)), strings.Repeat(" ", maxBodyBytes+1)} + for n, body := range cases { + t.Run(string(rune('a'+n)), func(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + _, err = s.Get(context.Background(), Request{View: Popular}) + var pe *ProviderError + if !errors.As(err, &pe) { + t.Fatalf("malformed success: %v", err) + } + }) + } +} +func TestRedirectAnd429Boundaries(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetHits.Add(1) })) + defer target.Close() + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }) + if _, err := p.Browse(context.Background(), Popular, 0); err == nil || targetHits.Load() != 0 { + t.Fatal("followed upstream redirect") + } + var hits atomic.Int32 + p = upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.Header().Set("Retry-After", "120") + w.WriteHeader(429) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + for n := range 2 { + _, err = s.Get(context.Background(), Request{View: Popular, Page: n}) + var pe *ProviderError + if !errors.As(err, &pe) || pe.RetryAfter < time.Second*119 { + t.Fatalf("retry not respected: %v", err) + } + } + if hits.Load() != 1 { + t.Fatal("429 cooldown did not apply across keys") + } +} +func TestCacheOutageRestartExpiryAndEmptyResults(t *testing.T) { + var outage atomic.Bool + var empty atomic.Bool + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if outage.Load() { + w.WriteHeader(503) + return + } + body := browseJSON(sampleSkill) + if empty.Load() { + body = `{"skills":[],"total":0,"hasMore":false,"page":0}` + } + _, _ = w.Write([]byte(body)) + }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + start := time.Now().UTC() + now := start + s.now = func() time.Time { return now } + s.tokenAt = start + req := Request{View: Popular} + fresh, err := s.Get(context.Background(), req) + if err != nil || fresh.Cache.Status != "fresh" || len(fresh.Items) != 1 { + t.Fatal(fresh, err) + } + outage.Store(true) + now = start.Add(31 * time.Minute) + stale, err := s.Get(context.Background(), req) + if err != nil || stale.Cache.Status != "stale" || !stale.Cache.FetchedAt.Equal(start) || len(stale.Items) != 1 { + t.Fatal(stale, err) + } + prior := hits.Load() + if _, err = s.Get(context.Background(), req); err != nil || hits.Load() != prior { + t.Fatal("failure cooldown missing") + } + restarted, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + restarted.now = func() time.Time { return now } + restarted.tokenAt = now + stale, err = restarted.Get(context.Background(), req) + if err != nil || stale.Cache.Status != "stale" || len(stale.Items) != 1 { + t.Fatal(stale, err) + } + now = start.Add(7*24*time.Hour + time.Second) + if _, err = restarted.Get(context.Background(), req); err == nil { + t.Fatal("served cache past seven days") + } + outage.Store(false) + empty.Store(true) + now = now.Add(time.Minute) + result, err := restarted.Get(context.Background(), req) + if err != nil || len(result.Items) != 0 || result.Items == nil || result.Total == nil || *result.Total != 0 || result.Cache.Status != "fresh" { + t.Fatal(result, err) + } + data, err := os.ReadFile(filepath.Join(dir, req.key(p.ID())+".json")) + if err != nil { + t.Fatal(err) + } + var stored entry + if json.Unmarshal(data, &stored) != nil || len(stored.Result.Items) != 0 || !stored.Result.Cache.FetchedAt.Equal(now) { + t.Fatal("valid empty response did not replace old cache") + } +} +func TestInvalidRefreshPreservesLastGoodRecord(t *testing.T) { + var bad atomic.Bool + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + body := browseJSON(sampleSkill) + if bad.Load() { + body = `{"skills":[]}` + } + _, _ = w.Write([]byte(body)) + }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + now := time.Now() + s.now = func() time.Time { return now } + s.tokenAt = now + req := Request{View: Popular} + if _, err = s.Get(context.Background(), req); err != nil { + t.Fatal(err) + } + filename := filepath.Join(dir, req.key(p.ID())+".json") + before, _ := os.ReadFile(filename) + bad.Store(true) + now = now.Add(time.Hour) + page, err := s.Get(context.Background(), req) + if err != nil || page.Cache.Status != "stale" { + t.Fatal(page, err) + } + after, _ := os.ReadFile(filename) + if string(before) != string(after) { + t.Fatal("invalid response replaced good persisted cache") + } +} +func TestHTTPValidationSearchAndCORS(t *testing.T) { + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { + t.Error("credentials forwarded") + } + if strings.HasPrefix(r.URL.Path, "/api/search") { + if r.URL.Query().Get("limit") != "100" || r.URL.Query().Get("q") != "find skills" { + t.Errorf("search query %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"skills":[` + sampleSkill + `],"count":1}`)) + return + } + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, []string{"https://universal-agent-plugins.com", "http://localhost:3000"}) + if err != nil { + t.Fatal(err) + } + invalid := []string{"view=unknown", "page=-1", "page=10001", "page=1&page=2", "q=x", "q=", "q=find&page=1", "q=find&limit=101", "limit=10", "url=https://evil", "q=%ff", "q=%0atest"} + for _, q := range invalid { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, testRequest(t, "GET", "/v1/skills?"+q)) + if rr.Code != 400 { + t.Fatalf("query %s: %d %s", q, rr.Code, rr.Body.String()) + } + } + if hits.Load() != 0 { + t.Fatal("invalid query hit provider") + } + rr := httptest.NewRecorder() + r := testRequest(t, "GET", "/v1/skills?q=find+skills&view=hot") + r.Header.Set("Origin", "https://universal-agent-plugins.com") + r.Header.Set("Cookie", "private=secret") + r.Header.Set("Authorization", "Bearer private") + h.ServeHTTP(rr, r) + var page Page + if rr.Code != 200 || json.Unmarshal(rr.Body.Bytes(), &page) != nil || page.Total != nil || page.HasMore || page.Query != "find skills" || page.View != Popular || len(page.Warnings) != 1 { + t.Fatal(rr.Code, rr.Body.String()) + } + if rr.Header().Get("Access-Control-Allow-Origin") != "https://universal-agent-plugins.com" || rr.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Fatal(rr.Header()) + } + for _, origin := range []string{"https://evil.test", "https://universal-agent-plugins.com.evil.test", "null"} { + rr = httptest.NewRecorder() + r = testRequest(t, "GET", "/v1/skills") + r.Header.Set("Origin", origin) + h.ServeHTTP(rr, r) + if rr.Code != 403 || rr.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal(rr.Code, rr.Header()) + } + } + rr = httptest.NewRecorder() + r = testRequest(t, "OPTIONS", "/v1/skills") + r.Header.Set("Origin", "http://localhost:3000") + r.Header.Set("Access-Control-Request-Method", "GET") + h.ServeHTTP(rr, r) + if rr.Code != 204 { + t.Fatal(rr.Code) + } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, testRequest(t, "GET", "/healthz")) + if rr.Code != 200 { + t.Fatal(rr.Code) + } + if _, err = NewHandler(s, []string{"https://example.com/path"}); err == nil { + t.Fatal("accepted non-origin CORS value") + } +} +func TestBoundedConcurrentMissesAndCoalescing(t *testing.T) { + var hits atomic.Int32 + entered := make(chan struct{}, 4) + release := make(chan struct{}) + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + entered <- struct{}{} + <-release + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for n := range 4 { + wg.Add(1) + go func(page int) { + defer wg.Done() + _, _ = s.Get(context.Background(), Request{View: Popular, Page: page}) + }(n) + } + for range 4 { + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("miss did not start") + } + } + if _, err = s.Get(context.Background(), Request{View: Hot}); err == nil { + t.Fatal("fifth concurrent miss accepted") + } + wg.Add(1) + go func() { defer wg.Done(); _, _ = s.Get(context.Background(), Request{View: Popular}) }() + close(release) + wg.Wait() + if hits.Load() != 4 { + t.Fatalf("duplicate refresh: %d", hits.Load()) + } +} + +type pageProvider struct{} + +func (pageProvider) ID() string { return "test-provider" } +func (pageProvider) Browse(_ context.Context, v View, n int) (Page, error) { + total := int64(0) + return Page{Items: []SkillListing{}, Total: &total, View: v, Page: n}, nil +} +func (pageProvider) Search(_ context.Context, q string, n int) (Page, error) { + return Page{Items: []SkillListing{}, View: Popular, Query: q}, nil +} +func TestPersistentCacheBoundAndCorruptRecordRejection(t *testing.T) { + dir := t.TempDir() + s, err := NewService(pageProvider{}, dir) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + s.now = func() time.Time { return now } + s.tokenAt = now + for n := range 130 { + now = now.Add(time.Second) + if _, err = s.Get(context.Background(), Request{View: Popular, Page: n}); err != nil { + t.Fatal(err) + } + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 128 || len(s.entries) != 128 { + t.Fatalf("unbounded cache: disk %d memory %d err %v", len(files), len(s.entries), err) + } + // Persisted records are loaded with real wall time, so set each test timestamp + // to a past instant before restart while preserving their actual schema. + for _, file := range files { + path := filepath.Join(dir, file.Name()) + data, _ := os.ReadFile(path) + var e entry + _ = json.Unmarshal(data, &e) + e.Result.Cache.FetchedAt = time.Now().Add(-time.Hour) + data, _ = json.Marshal(e) + if err = os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } + corrupt := filepath.Join(dir, files[0].Name()) + data, _ := os.ReadFile(corrupt) + data = []byte(strings.Replace(string(data), `"hasMore":false,`, "", 1)) + if err = os.WriteFile(corrupt, data, 0600); err != nil { + t.Fatal(err) + } + loaded, err := NewService(pageProvider{}, dir) + if err != nil { + t.Fatal(err) + } + if len(loaded.entries) != 127 { + t.Fatalf("accepted missing required cache field; entries %d", len(loaded.entries)) + } + if _, err = os.Stat(corrupt); !errors.Is(err, os.ErrNotExist) { + t.Fatal("corrupt record retained") + } +} +func TestSearchCacheFreshnessAndCanonicalKeys(t *testing.T) { + s, err := NewService(pageProvider{}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Now() + s.now = func() time.Time { return now } + s.tokenAt = now + req := Request{View: Popular, Query: "find", Limit: 100} + first, err := s.Get(context.Background(), req) + if err != nil { + t.Fatal(err) + } + now = now.Add(9 * time.Minute) + cached, err := s.Get(context.Background(), req) + if err != nil || !cached.Cache.FetchedAt.Equal(first.Cache.FetchedAt) { + t.Fatal("search missed fresh cache") + } + now = now.Add(2 * time.Minute) + refreshed, err := s.Get(context.Background(), req) + if err != nil || !refreshed.Cache.FetchedAt.Equal(now) { + t.Fatal("search TTL exceeded ten minutes") + } + other := req + other.Limit = 50 + if other.key("test-provider") == req.key("test-provider") || req.key("another-provider") == req.key("test-provider") { + t.Fatal("cache key collision across request/provider") + } +} + +func TestHTTPUnavailableIsStructuredAndDoesNotLeakUpstream(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Set-Cookie", "upstream-secret=private") + w.Header().Set("X-Internal", "sensitive") + w.WriteHeader(500) + _, _ = w.Write([]byte("secret upstream failure detail")) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, testRequest(t, "GET", "/v1/skills")) + var envelope errorEnvelope + if rr.Code != 503 || json.Unmarshal(rr.Body.Bytes(), &envelope) != nil || envelope.Error.Code != "provider_unavailable" || envelope.RetryAfter < 1 || rr.Header().Get("Retry-After") == "" { + t.Fatal(rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "secret") || rr.Header().Get("Set-Cookie") != "" || rr.Header().Get("X-Internal") != "" { + t.Fatal("leaked upstream details") + } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, testRequest(t, "GET", "/healthz")) + if rr.Code != 200 { + t.Fatal("upstream outage broke health") + } +} + +func TestTimeoutDoesNotPersistPartialResponse(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + p.client.Timeout = 20 * time.Millisecond + if _, err = s.Get(context.Background(), Request{View: Popular}); err == nil { + t.Fatal("timeout became successful result") + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 0 { + t.Fatal("timeout persisted a cache entry", err) + } +} + +func TestExactRawNamesAndDomainProviderLinks(t *testing.T) { + cases := []struct{ source, slug, name, providerURL string }{ + {"pluviobyte/rnskill", "ra", "ra-人话", skillsOrigin + "/pluviobyte/rnskill/ra"}, + {"owner/repo", "ai-video-prompt", "AI_video-prompt", skillsOrigin + "/owner/repo/ai-video-prompt"}, + {"Open.Feishu.Cn", "Agent-Skill", "Agent Skill", skillsOrigin + "/site/open.feishu.cn/agent-skill"}, + {"https://unknown.invalid/path", "slug", "raw name", skillsOrigin + "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + installs := int64(1) + skills := []upstreamSkill{{Source: &tc.source, SkillID: &tc.slug, Name: &tc.name, Installs: &installs}} + page, err := normalize(upstreamPage{Skills: &skills}, Popular) + if err != nil { + t.Fatal(err) + } + item := page.Items[0] + if item.SkillNameHint != tc.name || item.ProviderURL != tc.providerURL { + t.Fatalf("name/detail locator changed: %+v", item) + } + total := int64(1) + page.Total = &total + page.Cache = CacheInfo{Status: "fresh", FetchedAt: time.Now()} + if !validPage(page, Request{View: Popular}, "skills-sh") { + t.Fatal("valid raw upstream name rejected by persisted schema") + } + }) + } +} + +func TestSharedRefreshSurvivesFirstCallerCancellation(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + close(entered) + select { + case <-release: + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + case <-r.Context().Done(): + return + } + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + firstDone := make(chan error, 1) + go func() { _, err := s.Get(ctx, Request{View: Popular}); firstDone <- err }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("refresh did not start") + } + cancel() + // The first browser may abort while a debounced search/browse request shares + // this refresh. Its cancellation must neither cancel the fetch nor cache a failure. + close(release) + page, err := s.Get(context.Background(), Request{View: Popular}) + if err != nil || len(page.Items) != 1 || page.Cache.Status != "fresh" { + t.Fatalf("caller abort poisoned shared refresh: %+v %v", page, err) + } + if err = <-firstDone; err != nil { + t.Fatalf("shared fetch inherited canceled browser context: %v", err) + } + if hits.Load() != 1 { + t.Fatalf("shared refresh hit upstream %d times", hits.Load()) + } +} + +// http.NewRequestWithContext preserves the module's Go 1.22 API floor; +// httptest.NewRequestWithContext was only introduced in Go 1.23. +func testRequest(t *testing.T, method, target string) *http.Request { + t.Helper() + request, err := http.NewRequestWithContext(context.Background(), method, target, http.NoBody) + if err != nil { + t.Fatal(err) + } + return request +} + +func TestProviderCooldownCapsHugeDelayAndNeverShortens(t *testing.T) { + entered := make(chan struct{}, 2) + firstRelease, secondRelease := make(chan struct{}), make(chan struct{}) + var firstOnce, secondOnce sync.Once + releaseFirst := func() { firstOnce.Do(func() { close(firstRelease) }) } + releaseSecond := func() { secondOnce.Do(func() { close(secondRelease) }) } + defer releaseFirst() + defer releaseSecond() + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + entered <- struct{}{} + switch r.URL.Path { + case "/api/skills/all-time/0": + <-firstRelease + w.Header().Set("Retry-After", "9223372036") + case "/api/skills/all-time/1": + <-secondRelease + w.Header().Set("Retry-After", "15") + default: + t.Error("request escaped the provider cooldown") + } + w.WriteHeader(http.StatusTooManyRequests) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Now() + s.now = func() time.Time { return now } + s.tokenAt = now + first, second := make(chan error, 1), make(chan error, 1) + go func() { _, err := s.Get(context.Background(), Request{View: Popular}); first <- err }() + go func() { _, err := s.Get(context.Background(), Request{View: Popular, Page: 1}); second <- err }() + for range 2 { + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("concurrent requests did not reach provider") + } + } + releaseFirst() + firstErr := <-first + releaseSecond() + secondErr := <-second + var providerErr *ProviderError + if !errors.As(firstErr, &providerErr) || providerErr.RetryAfter != time.Hour { + t.Fatalf("huge cooldown was not capped: %+v", providerErr) + } + if !errors.As(secondErr, &providerErr) || providerErr.RetryAfter != 15*time.Second { + t.Fatalf("short positive retry changed: %+v", providerErr) + } + if !s.providerRetry.Equal(now.Add(time.Hour)) { + t.Fatalf("concurrent smaller delay shortened deadline: %s", s.providerRetry) + } + for page, delay := range []time.Duration{time.Hour, 15 * time.Second} { + key := (Request{View: Popular, Page: page}).key(p.ID()) + if !s.failures[key].until.Equal(now.Add(delay)) { + t.Fatalf("incorrect failure-key deadline for page %d", page) + } + } + _, err = s.Get(context.Background(), Request{View: Popular, Page: 2}) + if !errors.As(err, &providerErr) || providerErr.RetryAfter != time.Hour || hits.Load() != 2 { + t.Fatalf("admission ignored global deadline: %v, upstream hits %d", err, hits.Load()) + } +} diff --git a/internal/skillscatalog/http.go b/internal/skillscatalog/http.go new file mode 100644 index 000000000..8232cdbb5 --- /dev/null +++ b/internal/skillscatalog/http.go @@ -0,0 +1,122 @@ +package skillscatalog + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "time" +) + +type errorEnvelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + RetryAfter int `json:"retryAfter,omitempty"` +} +type catalogHandler struct { + service *Service + origins map[string]bool +} + +// NewHandler permits only exact configured browser origins; CORS is not an +// authentication boundary. Upstream concurrency/rate controls also apply to +// clients that make anonymous requests without an Origin header. +func NewHandler(service *Service, origins []string) (http.Handler, error) { + h := &catalogHandler{service: service, origins: map[string]bool{}} + for _, origin := range origins { + if !validOrigin(origin) { + return nil, errors.New("invalid allowed origin") + } + h.origins[origin] = true + } + return h, nil +} +func validOrigin(origin string) bool { + u, err := url.Parse(origin) + if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" + return u.Scheme == "https" || (u.Scheme == "http" && local) +} +func (h *catalogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + // Health remains available even during upstream failures or CORS rejection. + if r.URL.Path == "/healthz" && r.Method == http.MethodGet { + writeJSON(w, 200, map[string]string{"status": "ok"}) + return + } + if r.URL.Path != "/v1/skills" { + writeError(w, 404, "not_found", "Endpoint not found", 0) + return + } + if !h.allowOrigin(w, r) { + return + } + if r.Method == http.MethodOptions { + writePreflight(w, r) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET, OPTIONS") + writeError(w, 405, "method_not_allowed", "Only GET is allowed", 0) + return + } + req, err := parseRequest(r.URL.RawQuery) + if err != nil { + writeError(w, 400, "invalid_request", err.Error(), 0) + return + } + page, err := h.service.Get(r.Context(), req) + if err != nil { + writeUnavailable(w, err) + return + } + writeJSON(w, 200, page) +} +func (h *catalogHandler) allowOrigin(w http.ResponseWriter, r *http.Request) bool { + w.Header().Add("Vary", "Origin") + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if !h.origins[origin] { + writeError(w, 403, "origin_not_allowed", "Origin is not allowed", 0) + return false + } + w.Header().Set("Access-Control-Allow-Origin", origin) + return true +} +func writePreflight(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == "" || r.Header.Get("Access-Control-Request-Method") != "GET" || r.Header.Get("Access-Control-Request-Headers") != "" { + writeError(w, 403, "invalid_preflight", "Only anonymous GET is allowed", 0) + return + } + w.Header().Set("Access-Control-Allow-Methods", "GET") + w.WriteHeader(http.StatusNoContent) +} +func writeUnavailable(w http.ResponseWriter, err error) { + retry := 30 * time.Second + var pe *ProviderError + if errors.As(err, &pe) && pe.RetryAfter > 0 { + retry = pe.RetryAfter + } + seconds := max(1, int((retry+time.Second-1)/time.Second)) + w.Header().Set("Retry-After", strconv.Itoa(seconds)) + writeError(w, 503, "provider_unavailable", "Skills provider is temporarily unavailable", seconds) +} +func writeJSON(w http.ResponseWriter, status int, value any) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +func writeError(w http.ResponseWriter, status int, code, message string, retry int) { + e := errorEnvelope{RetryAfter: retry} + e.Error.Code = code + e.Error.Message = message + writeJSON(w, status, e) +} diff --git a/internal/skillscatalog/refresh.go b/internal/skillscatalog/refresh.go new file mode 100644 index 000000000..de09da061 --- /dev/null +++ b/internal/skillscatalog/refresh.go @@ -0,0 +1,73 @@ +package skillscatalog + +import ( + "context" + "errors" + "time" +) + +func (s *Service) fetch(r Request) (Page, error) { + // A refresh is shared across callers and bounded independently of the + // initiating browser, whose abort must not poison this key for everyone. + ctx, cancel := context.WithTimeout(context.Background(), 9*time.Second) + defer cancel() + if r.Query == "" { + return s.provider.Browse(ctx, r.View, r.Page) + } + return s.provider.Search(ctx, r.Query, r.Limit) +} +func (s *Service) refresh(key string, r Request, done chan struct{}) (Page, error) { + result, err := s.fetch(r) + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + result.Cache = CacheInfo{Status: "fresh", FetchedAt: now} + if err == nil && !validPage(result, r, s.provider.ID()) { + err = &ProviderError{} + } + if err == nil { + s.entries[key] = entry{Key: key, Request: r, Result: result} + delete(s.failures, key) + // Persistence failure never replaces a good on-disk record with bad bytes. + if !s.persist(s.entries[key]) { + result.Warnings = append(result.Warnings, "cache_persistence_unavailable") + } + s.trimLocked() + } else { + err = s.recordFailureLocked(key, now, err) + } + delete(s.pending, key) + close(done) + <-s.slots + return result, err +} +func (s *Service) recordFailureLocked(key string, now time.Time, err error) error { + retry := 30 * time.Second + var providerErr *ProviderError + if errors.As(err, &providerErr) && providerErr.RetryAfter > 0 { + retry = min(providerErr.RetryAfter, time.Hour) + if deadline := now.Add(retry); deadline.After(s.providerRetry) { + s.providerRetry = deadline + } + } + s.trimFailuresLocked(now) + s.failures[key] = failure{until: now.Add(retry)} + return &ProviderError{RetryAfter: retry} +} +func (s *Service) trimFailuresLocked(now time.Time) { + if len(s.failures) < maxEntries { + return + } + for key, f := range s.failures { + if !now.Before(f.until) { + delete(s.failures, key) + } + } + if len(s.failures) < maxEntries { + return + } + for key := range s.failures { + delete(s.failures, key) + return + } +} diff --git a/internal/skillscatalog/request.go b/internal/skillscatalog/request.go new file mode 100644 index 000000000..578bb1835 --- /dev/null +++ b/internal/skillscatalog/request.go @@ -0,0 +1,77 @@ +package skillscatalog + +import ( + "errors" + "net/url" + "strconv" + "strings" + "unicode/utf8" +) + +func invalidRequest(message string) error { return errors.New(message) } +func requestValues(raw string) (url.Values, error) { + if len(raw) > 2048 { + return nil, invalidRequest("Query string is too long") + } + values, err := url.ParseQuery(raw) + if err != nil { + return nil, invalidRequest("Invalid query string") + } + for key, items := range values { + if len(items) != 1 { + return nil, invalidRequest("Duplicate query parameters are not allowed") + } + if key != "view" && key != "page" && key != "q" && key != "limit" { + return nil, invalidRequest("Unknown query parameter") + } + } + return values, nil +} +func parseRequest(raw string) (Request, error) { + q, err := requestValues(raw) + if err != nil { + return Request{}, err + } + r := Request{View: Popular} + if q.Has("view") { + r.View = View(q.Get("view")) + if !r.View.Valid() { + return Request{}, invalidRequest("view must be popular, trending or hot") + } + } + if q.Has("page") { + r.Page, err = strconv.Atoi(q.Get("page")) + if err != nil || r.Page < 0 || r.Page > 10000 { + return Request{}, invalidRequest("page must be between 0 and 10000") + } + } + if q.Has("q") { + return searchRequest(r, q) + } + if q.Has("limit") { + return Request{}, invalidRequest("limit is only supported with q") + } + return r, nil +} +func searchRequest(r Request, q url.Values) (Request, error) { + if !safeText(q.Get("q"), 200) { + return Request{}, invalidRequest("q contains unsupported characters") + } + r.Query = strings.TrimSpace(q.Get("q")) + if !utf8.ValidString(r.Query) || utf8.RuneCountInString(r.Query) < 2 || !safeText(r.Query, 200) { + return Request{}, invalidRequest("q must contain 2 or more characters, up to 200 bytes") + } + if r.Page != 0 { + return Request{}, invalidRequest("Search supports only page 0") + } + r.View = Popular + r.Limit = 100 + if q.Has("limit") { + var err error + r.Limit, err = strconv.Atoi(q.Get("limit")) + if err != nil || r.Limit < 1 || r.Limit > 100 { + return Request{}, invalidRequest("limit must be between 1 and 100") + } + } + return r, nil +} diff --git a/internal/skillscatalog/service.go b/internal/skillscatalog/service.go new file mode 100644 index 000000000..f635f5ad1 --- /dev/null +++ b/internal/skillscatalog/service.go @@ -0,0 +1,158 @@ +package skillscatalog + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +const maxEntries = 128 +const maxStale = 7 * 24 * time.Hour + +type Request struct { + View View `json:"view"` + Page int `json:"page"` + Query string `json:"query"` + Limit int `json:"limit"` +} + +func (r Request) key(provider string) string { + operation := "browse" + if r.Query != "" { + operation = "search" + } + b, _ := json.Marshal([]any{provider, operation, r.View, r.Page, r.Query, r.Limit}) + digest := sha256.Sum256(b) + return hex.EncodeToString(digest[:]) +} + +type entry struct { + Key string `json:"key"` + Request Request `json:"request"` + Result Page `json:"result"` +} +type failure struct{ until time.Time } +type Service struct { + provider Provider + dir string + now func() time.Time + mu sync.Mutex + entries map[string]entry + failures map[string]failure + pending map[string]chan struct{} + slots chan struct{} + tokens float64 + tokenAt time.Time + providerRetry time.Time +} + +// NewService loads only bounded, validated cache records belonging to this +// provider. A cache directory is private service state, not a content endpoint. +func NewService(provider Provider, dir string) (*Service, error) { + if provider == nil || !safeText(provider.ID(), 100) { + return nil, fmt.Errorf("provider required") + } + if dir == "" { + return nil, fmt.Errorf("cache directory required") + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + s := &Service{provider: provider, dir: dir, now: time.Now, entries: map[string]entry{}, failures: map[string]failure{}, pending: map[string]chan struct{}{}, slots: make(chan struct{}, 4), tokens: 4, tokenAt: time.Now()} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} +func (s *Service) usable(p Page, now time.Time) bool { + age := now.Sub(p.Cache.FetchedAt) + return age >= 0 && age <= maxStale +} +func (s *Service) fresh(e entry, exists bool, r Request, now time.Time) bool { + ttl := 30 * time.Minute + if r.Query != "" { + ttl = 10 * time.Minute + } + return exists && s.usable(e.Result, now) && now.Sub(e.Result.Cache.FetchedAt) < ttl +} +func (s *Service) unavailable(e entry, exists bool, now time.Time, err error) (Page, error) { + if exists && s.usable(e.Result, now) { + p := e.Result + p.Cache.Status = "stale" + p.Warnings = append(append([]string{}, p.Warnings...), "provider_unavailable") + return p, nil + } + return Page{}, err +} +func (s *Service) Get(ctx context.Context, r Request) (Page, error) { + if !validRequest(r) { + return Page{}, fmt.Errorf("invalid catalog request") + } + key := r.key(s.provider.ID()) + for { + s.mu.Lock() + now := s.now() + e, exists := s.entries[key] + if s.fresh(e, exists, r, now) { + p := e.Result + p.Cache.Status = "fresh" + s.mu.Unlock() + return p, nil + } + if waiting, ok := s.pending[key]; ok { + s.mu.Unlock() + if err := waitForRefresh(ctx, waiting); err != nil { + return Page{}, err + } + continue + } + if retry := s.admitLocked(key, now); retry > 0 { + s.mu.Unlock() + return s.unavailable(e, exists, now, &ProviderError{RetryAfter: retry}) + } + done := make(chan struct{}) + s.pending[key] = done + s.mu.Unlock() + result, err := s.refresh(key, r, done) + if err == nil { + return result, nil + } + return s.unavailable(e, exists, result.Cache.FetchedAt, err) + } +} +func waitForRefresh(ctx context.Context, waiting <-chan struct{}) error { + select { + case <-waiting: + return nil + case <-ctx.Done(): + return &ProviderError{} + } +} + +// admitLocked bounds upstream demand without adding a per-client state table. +func (s *Service) admitLocked(key string, now time.Time) time.Duration { + until := s.failures[key].until + if s.providerRetry.After(until) { + until = s.providerRetry + } + if now.Before(until) { + return until.Sub(now) + } + s.tokens = min(4, s.tokens+now.Sub(s.tokenAt).Seconds()*2) + s.tokenAt = now + if s.tokens < 1 { + return time.Second + } + select { + case s.slots <- struct{}{}: + default: + return time.Second + } + s.tokens-- + return 0 +} diff --git a/internal/skillscatalog/skillssh.go b/internal/skillscatalog/skillssh.go new file mode 100644 index 000000000..ab4c8c422 --- /dev/null +++ b/internal/skillscatalog/skillssh.go @@ -0,0 +1,167 @@ +package skillscatalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +const maxBodyBytes = 2 << 20 +const skillsOrigin = "https://www.skills.sh" + +var sourceDomain = regexp.MustCompile(`(?i)^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$`) +var githubOwner = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) +var githubRepository = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}$`) + +type SkillsSH struct { + client *http.Client + origin string +} + +// NewSkillsSH pins the anonymous upstream origin. Redirects are disabled so +// neither a redirect nor caller headers can turn this into an arbitrary proxy. +func NewSkillsSH() *SkillsSH { return newSkillsSH(skillsOrigin, http.DefaultTransport) } +func newSkillsSH(origin string, transport http.RoundTripper) *SkillsSH { + return &SkillsSH{origin: origin, client: &http.Client{Timeout: 8 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} +func (*SkillsSH) ID() string { return "skills-sh" } + +type upstreamSkill struct { + Source *string `json:"source"` + SkillID *string `json:"skillId"` + Name *string `json:"name"` + Installs *int64 `json:"installs"` +} +type upstreamPage struct { + Skills *[]upstreamSkill `json:"skills"` + Total *int64 `json:"total"` + HasMore *bool `json:"hasMore"` + Page *int `json:"page"` + Count *int64 `json:"count"` +} + +func (p *SkillsSH) Browse(ctx context.Context, view View, page int) (Page, error) { + route := map[View]string{Popular: "all-time", Trending: "trending", Hot: "hot"}[view] + if route == "" || page < 0 { + return Page{}, &ProviderError{} + } + raw, err := p.fetch(ctx, "/api/skills/"+route+"/"+strconv.Itoa(page)) + if err != nil { + return Page{}, err + } + if raw.Total == nil || *raw.Total < 0 || raw.HasMore == nil || raw.Page == nil || *raw.Page != page { + return Page{}, &ProviderError{} + } + result, err := normalize(raw, view) + if err != nil { + return Page{}, err + } + if int64(len(result.Items)) > *raw.Total || len(result.Items) > 200 { + return Page{}, &ProviderError{} + } + result.Total, result.HasMore, result.Page = raw.Total, *raw.HasMore, page + return result, nil +} +func (p *SkillsSH) Search(ctx context.Context, q string, limit int) (Page, error) { + raw, err := p.fetch(ctx, "/api/search?"+url.Values{"q": {q}, "limit": {strconv.Itoa(limit)}}.Encode()) + if err != nil { + return Page{}, err + } + if raw.Count == nil || *raw.Count < 0 || raw.Skills == nil || int64(len(*raw.Skills)) > *raw.Count || len(*raw.Skills) > limit { + return Page{}, &ProviderError{} + } + result, err := normalize(raw, Popular) + result.Query = q + result.Warnings = []string{"search_results_limited"} + return result, err +} +func (p *SkillsSH) fetch(ctx context.Context, path string) (upstreamPage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.origin+path, http.NoBody) + if err != nil { + return upstreamPage{}, &ProviderError{} + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return upstreamPage{}, &ProviderError{} + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + retry := time.Duration(0) + if resp.StatusCode == 429 { + retry = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + } + return upstreamPage{}, &ProviderError{RetryAfter: retry} + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1)) + if err != nil || len(data) > maxBodyBytes || !utf8.Valid(data) { + return upstreamPage{}, &ProviderError{} + } + var raw upstreamPage + if json.Unmarshal(data, &raw) != nil || raw.Skills == nil { + return upstreamPage{}, &ProviderError{} + } + return raw, nil +} +func parseRetryAfter(s string, now time.Time) time.Duration { + if seconds, err := strconv.Atoi(s); err == nil && seconds > 0 { + return time.Duration(min(seconds, int((time.Duration(1<<63-1)-time.Second)/time.Second))) * time.Second + } + if date, err := http.ParseTime(s); err == nil && date.After(now) { + return date.Sub(now) + } + return 30 * time.Second +} +func safeText(s string, maxLen int) bool { + return strings.TrimSpace(s) != "" && len(s) <= maxLen && !strings.ContainsFunc(s, unicode.IsControl) +} +func repository(source string) bool { + parts := strings.Split(source, "/") + if len(parts) != 2 { + return false + } + return githubOwner.MatchString(parts[0]) && githubRepository.MatchString(parts[1]) && parts[1] != "." && parts[1] != ".." && !strings.HasSuffix(parts[1], ".git") +} +func normalize(raw upstreamPage, view View) (Page, error) { + result := Page{Items: make([]SkillListing, 0), View: view} + if raw.Skills == nil { + return Page{}, &ProviderError{} + } + window := map[View]string{Popular: "all-time", Trending: "24h", Hot: "1h"}[view] + seen := map[string]bool{} + for _, s := range *raw.Skills { + if s.Source == nil || s.SkillID == nil || s.Name == nil || s.Installs == nil || *s.Installs < 0 || !safeText(*s.Source, 256) || !safeText(*s.SkillID, 256) || !safeText(*s.Name, 512) { + return Page{}, &ProviderError{} + } + source, skillID := *s.Source, *s.SkillID + // The provider slug identifies the listing page; the raw name is the + // CLI selection hint. Preserve it exactly, leaving admissibility to CLI. + hint := *s.Name + providerURL := skillsOrigin + "/" + if repository(source) { + providerURL = skillsOrigin + "/" + source + "/" + url.PathEscape(skillID) + } else if !strings.Contains(source, "/") && sourceDomain.MatchString(source) { + providerURL = skillsOrigin + "/site/" + strings.ToLower(source) + "/" + url.PathEscape(strings.ToLower(skillID)) + } + item := SkillListing{ID: fmt.Sprintf("skills-sh:%s:%s", source, skillID), ProviderID: "skills-sh", ProviderURL: providerURL, Title: *s.Name, SkillNameHint: hint, Source: Source{Kind: "external", URL: providerURL}, Metrics: Metrics{Installs: *s.Installs, Window: window}, Availability: "unsupported-source"} + if repository(source) { + item.Source = Source{Kind: "github", Repository: source, URL: "https://github.com/" + source} + item.Availability = "unverified" + } + if seen[item.ID] { + return Page{}, &ProviderError{} + } + seen[item.ID] = true + result.Items = append(result.Items, item) + } + return result, nil +} diff --git a/internal/skillscatalog/validation.go b/internal/skillscatalog/validation.go new file mode 100644 index 000000000..348a26f62 --- /dev/null +++ b/internal/skillscatalog/validation.go @@ -0,0 +1,124 @@ +package skillscatalog + +import ( + "encoding/json" + "net/url" + "strings" +) + +func validRequest(r Request) bool { + if !r.View.Valid() || r.Page < 0 || r.Page > 10000 { + return false + } + if r.Query == "" { + return r.Limit == 0 + } + return r.Page == 0 && r.View == Popular && len([]rune(r.Query)) >= 2 && len(r.Query) <= 200 && safeText(r.Query, 200) && r.Limit > 0 && r.Limit <= 100 +} +func validPage(p Page, r Request, provider string) bool { + if !validPageMetadata(p, r) { + return false + } + seen := map[string]bool{} + for _, item := range p.Items { + if !validListing(item, provider) || seen[item.ID] { + return false + } + seen[item.ID] = true + } + return true +} +func validPageMetadata(p Page, r Request) bool { + if p.Items == nil || p.View != r.View || p.Page != r.Page || p.Query != r.Query || p.Cache.FetchedAt.IsZero() { + return false + } + if p.Cache.Status != "fresh" && p.Cache.Status != "stale" { + return false + } + if p.Total != nil && *p.Total < 0 { + return false + } + if r.Query != "" { + return len(p.Items) <= r.Limit && p.Total == nil && !p.HasMore + } + return len(p.Items) <= 200 && p.Total != nil && *p.Total >= int64(len(p.Items)) +} +func validListing(i SkillListing, provider string) bool { + if i.ProviderID != provider || !safeText(i.ID, 1024) || !strings.HasPrefix(i.ID, provider+":") { + return false + } + if !safeText(i.Title, 512) || !safeText(i.SkillNameHint, 512) || i.Metrics.Installs < 0 { + return false + } + if i.Metrics.Window != "all-time" && i.Metrics.Window != "24h" && i.Metrics.Window != "1h" { + return false + } + return safeHTTPSURL(i.ProviderURL) && validSource(i.Source, i.Availability) +} +func validSource(source Source, availability string) bool { + switch source.Kind { + case "github": + return repository(source.Repository) && source.URL == "https://github.com/"+source.Repository && availability == "unverified" + case "external": + return source.Repository == "" && safeHTTPSURL(source.URL) && availability == "unsupported-source" + default: + return false + } +} + +// Required presence is checked before decoding disk records: Go zero values +// alone cannot distinguish a missing false/zero field from a valid value. +func requiredObject(raw json.RawMessage, names ...string) (map[string]json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil, false + } + for _, name := range names { + value, ok := fields[name] + if !ok || string(value) == "null" { + return nil, false + } + } + return fields, true +} +func validStoredJSON(b []byte) bool { + root, ok := requiredObject(b, "key", "request", "result") + if !ok { + return false + } + if _, ok = requiredObject(root["request"], "view", "page", "query", "limit"); !ok { + return false + } + page, ok := requiredObject(root["result"], "items", "hasMore", "page", "view", "cache") + if !ok { + return false + } + if _, ok = requiredObject(page["cache"], "status", "fetchedAt"); !ok { + return false + } + var items []json.RawMessage + if json.Unmarshal(page["items"], &items) != nil { + return false + } + for _, raw := range items { + if !validStoredListing(raw) { + return false + } + } + return true +} +func validStoredListing(raw json.RawMessage) bool { + item, ok := requiredObject(raw, "id", "providerId", "providerUrl", "title", "skillNameHint", "source", "metrics", "availability") + if !ok { + return false + } + if _, ok = requiredObject(item["source"], "kind", "url"); !ok { + return false + } + _, ok = requiredObject(item["metrics"], "installs", "window") + return ok +} +func safeHTTPSURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && safeText(raw, 2048) +} diff --git a/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md b/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md new file mode 100644 index 000000000..c8f6f56d9 --- /dev/null +++ b/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md @@ -0,0 +1,24 @@ +--- +version: 1 +slug: "landing-pages-plugins-index-vue" +primary_target: "landing/pages/plugins/index.vue" +related_targets: ["landing/components/registry/PluginCatalog.vue"] +--- + +# Catalog discovery extension + +Mode: Operate. Target: existing home catalog and /plugins/. + +## Direction contract + +THESIS: Find an agent capability, then choose how to install it. Plugins and Skills are distinct modes of the same catalog; installation controls appear only after intent. + +OWN-WORLD: Preserve the incumbent registry typography, light/dark surface tokens, accent, control geometry and existing agent multiselect. Extend shared components rather than copying card or input styles. No new decorative hero. + +STORY: A visitor switches to Skills, searches or browses real skills.sh entries, inspects a GitHub source, clicks Install, chooses agents and copies the exact command. The CLI resolves and reviews actual source contents. + +FIRST VIEWPORT: Two prominent URL-addressable Plugins/Skills tabs above the existing full-width search and compact view selector; real result cards below. No empty basket, agent panel or terminal instructions competing with browsing. Selected installation opens within its card using the existing controls. + +FORM: Precisely specified extension of an established catalog. No new visual-world or concept-seed round is applicable. Desktop-first; keyboard access, visible focus, honest loading, stale, empty and error states. + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/landing/DESIGN.md b/landing/DESIGN.md new file mode 100644 index 000000000..fe79be04b --- /dev/null +++ b/landing/DESIGN.md @@ -0,0 +1,41 @@ +# Catalog design + +The Plugins and Skills modes extend the existing registry visual system: Inter +for interface copy, JetBrains Mono for commands, shared light/dark surface and +foreground tokens, and the existing cyan/purple accents. This change does not +introduce a separate brand or asset set. + +Both modes use `CatalogSearchField` and `CatalogCard`. Skills installation uses +the existing `AppMultiSelect`, persisted installation preferences, and +`CommandSnippet`. Skills remain a separate data model from reviewed packages. +The mode is reflected in `?tab=skills`; search and sorting survive navigation. + +The catalog heading combines a localized shared prefix with the Plugins/Skills +segmented switch as its final phrase. It is the directory's unique h1 and the +home catalog's h2. One introductory sentence follows; child catalogs do not +repeat a heading or intro. Collapsed Skill cards are compact; agent selection and commands appear after Install. +Agent choices come from the generated client capability manifest. + +Catalog translations belong to the feature's compiled Vue I18n resources. +One local composer serves the catalog and its Skill cards; shared controls fall +back to global labels. Directory SEO remains global. Detail and download pages +do not serialize catalog-only copy into their HTML payloads. +Catalog-specific styles also load with the catalog component instead of the +global registry stylesheet, keeping unrelated pages within their HTML budget. + +Desktop is the acceptance target. Review includes real skills.sh data, search, +installation choices, copy, navigation, pagination, and outage/empty states. +Cards link only to their validated GitHub repository roots. Provider links and +listing notices are absent. The collection count describes the current view, +and pagination remains below the cards. The CLI validates the repository and +exact declared Skill name. + +Impeccable's final detector identified two inherited patterns in unrelated +registry styles (gradient text and a workflow accent stripe). They are outside +this catalog extension. The independent finish review requested removal of +duplicate introductory copy and excess collapsed-card spacing; both are fixed. +No raster assets were added; raster provenance is therefore not applicable. + +The React CLI picker remains unchanged. A later migration can share these Vue +components through a small static entry point while retaining its local server +and installation contract. diff --git a/landing/PRODUCT.md b/landing/PRODUCT.md new file mode 100644 index 000000000..e7ba7207f --- /dev/null +++ b/landing/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + + + +## Platform + +web + +## Users + +People using coding agents who want to find a useful plugin or Agent Skill and install it without learning repository layouts or installer internals. + +## Product Purpose + +Universal Agent Plugins offers one catalog and installer journey for plugins and Skills. Success is finding an item, choosing relevant agents when installing, and running the exact command shown. + +## Operating Context + +The public Nuxt site is generated statically. The CLI installs public GitHub Skills through an existing scanner, explicit review and a saved, versioned environment. The Skills discovery API is a separate service on the owner's Hetzner production host. The owner supplied universal-agent-plugins.com; the proposed API subdomain is api.universal-agent-plugins.com. + +## Capabilities and Constraints + +- Plugins and Skills are distinct catalog modes with URL-addressable selection. +- Reuse the site's existing controls, agent selector, command presentation and visual language. +- Skills discovery initially uses skills.sh through a replaceable provider contract and last-known-good caching. +- Show installation choices after the user chooses to install an item. Do not put implementation details into the visitor flow. +- Preserve GitHub source identity; do not invent a skill folder from a registry slug. The CLI checks the selected source before installing. +- Desktop is the current priority. Replacing the React CLI picker with shared landing components is a future phase, not this catalog extension. +- Preserve plugin installation behavior, existing availability boundaries and supported-client metadata. + +## Product Principles + +- Let people browse before asking where to install. +- Keep equivalent controls and actions consistent across plugins and Skills. +- Preserve a useful cached catalog during upstream outages and show its age honestly. +- Use real source metadata and real installation commands; do not imply that a listing establishes compatibility or verification. diff --git a/landing/components/registry/AppMultiSelect.vue b/landing/components/registry/AppMultiSelect.vue index 51ab07859..456f24a28 100644 --- a/landing/components/registry/AppMultiSelect.vue +++ b/landing/components/registry/AppMultiSelect.vue @@ -22,6 +22,7 @@ const props = defineProps<{ label: string; autoOption?: AutoDetectOption; autoSelected?: boolean; + automaticHelp?: string; }>(); const emit = defineEmits<{ @@ -107,7 +108,7 @@ function toggle(value: string) { {{ option.label }}{{ option.description }}

{{ - autoSelected ? t('registryUi.multi.automaticHelp') : t('registryUi.multi.manualHelp') + autoSelected + ? (automaticHelp ?? t('registryUi.multi.automaticHelp')) + : t('registryUi.multi.manualHelp') }}

diff --git a/landing/components/registry/CatalogCard.vue b/landing/components/registry/CatalogCard.vue new file mode 100644 index 000000000..71154dabd --- /dev/null +++ b/landing/components/registry/CatalogCard.vue @@ -0,0 +1,9 @@ + + + diff --git a/landing/components/registry/CatalogSearchField.vue b/landing/components/registry/CatalogSearchField.vue new file mode 100644 index 000000000..5d99766cd --- /dev/null +++ b/landing/components/registry/CatalogSearchField.vue @@ -0,0 +1,30 @@ + + + diff --git a/landing/components/registry/PluginCatalog.vue b/landing/components/registry/PluginCatalog.vue index 15908d740..b3a47a617 100644 --- a/landing/components/registry/PluginCatalog.vue +++ b/landing/components/registry/PluginCatalog.vue @@ -14,7 +14,12 @@ import { canonicalPath } from '~/utils/seo'; const { t, n } = useI18n(); const props = withDefaults( - defineProps<{ plugins: RegistryPlugin[]; heading?: string; intro?: string }>(), + defineProps<{ + plugins: RegistryPlugin[]; + heading?: string; + intro?: string; + hideHeading?: boolean; + }>(), { heading: undefined, intro: undefined, @@ -242,8 +247,12 @@ watch([query, category, component, source, trust, client, authentication, owner] diff --git a/landing/scripts/check-i18n.mjs b/landing/scripts/check-i18n.mjs index ab40a8f1a..b56beef55 100644 --- a/landing/scripts/check-i18n.mjs +++ b/landing/scripts/check-i18n.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import assert from 'node:assert/strict'; import { createI18n } from 'vue-i18n'; +import { loadI18nMessages } from './load-i18n-messages.ts'; import { slavicPluralRule } from '../utils/slavicPluralRule.ts'; import { publishedLocales } from '../data/i18n.ts'; import { productRoutes } from '../data/routes.ts'; @@ -245,11 +246,11 @@ export function activeMessages(message) { export async function checkI18n() { const read = async (locale, folder) => JSON.parse(await fs.readFile(new URL(`../${folder}/${locale}.json`, import.meta.url), 'utf8')); - const reference = await read('en', 'locales'); + const reference = loadI18nMessages('en'); const en = activeMessages(reference); const download = await read('en', 'content/download'); for (const locale of publishedLocales) { - const messages = activeMessages(await read(locale, 'locales')); + const messages = activeMessages(loadI18nMessages(locale)); validateMessages(en, messages, locale); assertTranslatedCopy(en, messages, locale); const overlay = await read(locale, 'content/download'); diff --git a/landing/scripts/load-i18n-messages.ts b/landing/scripts/load-i18n-messages.ts new file mode 100644 index 000000000..329698601 --- /dev/null +++ b/landing/scripts/load-i18n-messages.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; + +type GlobalMessages = typeof import('../locales/en.json'); +type CatalogMessages = typeof import('../features/skills-catalog/locales/en.json'); + +/** Release tooling validates every owned feature without adding it to global app messages. */ +export function loadI18nMessages(locale: string): GlobalMessages & CatalogMessages { + const read = (folder: string): Messages => + JSON.parse( + readFileSync(new URL(`../${folder}/${locale}.json`, import.meta.url), 'utf8'), + ) as Messages; + const base = read('locales'); + const catalog = read('features/skills-catalog/locales'); + return { + ...base, + registryUi: { + ...base.registryUi, + ...catalog.registryUi, + directoryPage: { ...base.registryUi.directoryPage, ...catalog.registryUi.directoryPage }, + }, + }; +} diff --git a/landing/tests/browser/community-install.spec.ts b/landing/tests/browser/community-install.spec.ts index c6cefc9c3..187cbdb5c 100644 --- a/landing/tests/browser/community-install.spec.ts +++ b/landing/tests/browser/community-install.spec.ts @@ -22,7 +22,7 @@ test('community installer is centered, full-width, and readable at desktop/table expect(Math.abs(grid.width - box.width)).toBeLessThanOrEqual(1); expect(Math.abs(box.x + box.width / 2 - width / 2)).toBeLessThanOrEqual(1); const label = panel.locator('.app-multiselect__value > span:last-child'); - await expect(label).toHaveText('All installed agents'); + await expect(label).toHaveText('Choose agents in terminal'); expect(await label.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( true, diff --git a/landing/tests/browser/hero-layout.spec.ts b/landing/tests/browser/hero-layout.spec.ts index 114da7183..c3cc66e6e 100644 --- a/landing/tests/browser/hero-layout.spec.ts +++ b/landing/tests/browser/hero-layout.spec.ts @@ -4,7 +4,8 @@ import { expect, test, type Page } from '@playwright/test'; import { clientLandingPages } from '../../data/clients'; import { hydrated, setSignedMirrorTestTime, wording } from './i18n-state.helpers'; -const exactCommand = 'npx -y universal-agent-plugins add context7 --target codex,cursor,vscode --no-save'; +const exactCommand = + 'npx -y universal-agent-plugins add context7 --target codex,cursor,vscode --no-save'; async function capture(page: Page, name: string) { const directory = process.env.UAP_LANDING_SCREENSHOT_DIR; @@ -25,10 +26,8 @@ test.beforeEach(async ({ page }) => { await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); }); -test('hero controls and all client links remain complete across four viewports', async ({ - page, -}) => { - for (const width of [1440, 1024, 390, 320]) { +for (const width of [1440, 1024, 390, 320]) { + test(`hero controls and all client links remain complete at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: width >= 1024 ? 1000 : 844 }); await page.goto('./'); await waitForCommand(page); @@ -95,8 +94,8 @@ test('hero controls and all client links remain complete across four viewports', expect(await code.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true); await capture(page, `hero-layout-${width}`); - } -}); + }); +} test('selected targets produce one complete copyable command with keyboard activation', async ({ page, diff --git a/landing/tests/browser/hero-orbit.spec.ts b/landing/tests/browser/hero-orbit.spec.ts index 7b1aec4ee..91221dd32 100644 --- a/landing/tests/browser/hero-orbit.spec.ts +++ b/landing/tests/browser/hero-orbit.spec.ts @@ -306,7 +306,7 @@ test('installation works with WebGL forbidden and the orbit has no canvas or opt await expect(field).toHaveClass(/hero-agent-field--active/); await expect(field.locator('canvas')).toHaveCount(0); await page - .getByRole('button', { name: 'Choose target agents: All installed agents', exact: true }) + .getByRole('button', { name: 'Choose target agents: Choose agents in terminal', exact: true }) .click(); await page.getByRole('checkbox', { name: /^Cursor / }).click(); await page.keyboard.press('Escape'); @@ -372,7 +372,7 @@ test('guided ChatGPT preparation remains available for an eligible deterministic await projections(page, index); await navigate(page, '/'); await page - .getByRole('button', { name: 'Choose target agents: All installed agents', exact: true }) + .getByRole('button', { name: 'Choose target agents: Choose agents in terminal', exact: true }) .click(); const chatgpt = page.getByRole('checkbox', { name: /^ChatGPT / }); await expect(chatgpt).toBeEnabled(); diff --git a/landing/tests/browser/i18n-state.helpers.ts b/landing/tests/browser/i18n-state.helpers.ts index f30d4cc0f..b77253130 100644 --- a/landing/tests/browser/i18n-state.helpers.ts +++ b/landing/tests/browser/i18n-state.helpers.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { loadI18nMessages } from '../../scripts/load-i18n-messages.ts'; import { expect, type Page, type Locator } from '@playwright/test'; import { createI18n, type LocaleMessageDictionary, type VueMessageType } from 'vue-i18n'; import { publishedLocales, type PublishedLocale } from '../../data/i18n'; @@ -9,7 +10,7 @@ import type { RegistryIndex } from '../../types/registry'; export const locales: readonly PublishedLocale[] = publishedLocales; export function wording(locale: PublishedLocale) { - const messages = JSON.parse(readFileSync(new URL(`../../locales/${locale}.json`, import.meta.url), 'utf8')); + const messages = loadI18nMessages(locale); const i18n = createI18n<[LocaleMessageDictionary], PublishedLocale, false>({ legacy: false, locale, fallbackLocale: false, pluralRules: { ru: slavicPluralRule, uk: slavicPluralRule }, messages: Object.fromEntries(publishedLocales.map(code => [code, code === locale ? messages : {}])) as Record> }); return (key: string, params: Record = {}) => { expect(i18n.global.te(key), `${locale}: missing ${key}`).toBe(true); diff --git a/landing/tests/browser/landing.spec.ts b/landing/tests/browser/landing.spec.ts index 2975cae6a..645865623 100644 --- a/landing/tests/browser/landing.spec.ts +++ b/landing/tests/browser/landing.spec.ts @@ -96,7 +96,7 @@ test('homepage installs with auto-detection and exposes the full directory', asy await expect .poll( async () => { - const intro = await page.locator('.catalog .section-heading > p').last().innerText(); + const intro = await page.locator('.unified-catalog__heading > p').innerText(); return [...intro.matchAll(/([\d,]+) (?:reviewed plugins?|community packages?)/g)] .map((match) => Number(match[1].replaceAll(',', ''))) .reduce((sum, count) => sum + count, 0); @@ -338,7 +338,7 @@ test.describe('mobile navigation and catalog', () => { const nuxt = app?.$nuxt || app?.config.globalProperties.$nuxt; return Boolean(app?.config.globalProperties.$router && nuxt?.isHydrating === false); }); - await page.locator('.catalog .section-heading').scrollIntoViewIfNeeded(); + await page.locator('#unified-catalog-title').scrollIntoViewIfNeeded(); const toggle = page.locator('.catalog-filter-toggle'); await expect(toggle).toBeVisible(); @@ -482,7 +482,7 @@ test('community install controls use the full panel width for long sources', asy } }); -test('directory filters and reviewed detail keep automatic detection as the default', async ({ +test('directory filters and reviewed detail keep terminal agent choice as the default', async ({ page, }) => { await page.goto('./plugins'); @@ -495,7 +495,12 @@ test('directory filters and reviewed detail keep automatic detection as the defa await gitlabCard.getByRole('link', { name: 'GitLab', exact: true }).click(); await expect(page).toHaveURL(/\/plugins\/gitlab\/?$/, { timeout: 15_000 }); await expect(page.getByRole('heading', { name: 'GitLab', exact: true })).toBeVisible(); - await expect(page.getByText('All installed agents')).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Choose target agents: Choose agents in terminal', + exact: true, + }), + ).toBeVisible(); await expect(page.getByRole('link', { name: 'Run with npx' })).toBeVisible(); await expect(page.locator('.command-snippet').first()).toContainText( 'npx -y universal-agent-plugins add', diff --git a/landing/tests/browser/skills-catalog.spec.ts b/landing/tests/browser/skills-catalog.spec.ts new file mode 100644 index 000000000..a6033184a --- /dev/null +++ b/landing/tests/browser/skills-catalog.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; + +function skill(index = 0) { + return { + id: `skills-sh:anthropics/skills:frontend-design-${index}`, + providerId: 'skills-sh', + providerUrl: 'https://skills.sh/anthropics/skills/frontend-design', + title: index === 0 ? 'frontend-design' : `Example skill ${index}`, + skillNameHint: 'frontend-design', + source: { + kind: 'github', + repository: 'anthropics/skills', + url: 'https://github.com/anthropics/skills', + }, + metrics: { installs: 1234 - index, window: 'all-time' }, + availability: 'unverified', + }; +} + +test('Skills search reveals install choices, copies the exact command and survives catalog navigation', async ({ + page, + context, +}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const requests: string[] = []; + await page.route('https://api.universal-agent-plugins.com/v1/skills**', async (route) => { + const url = new URL(route.request().url()); + requests.push(url.search); + await route.fulfill({ + json: { + items: [ + skill(), + { + ...skill(1), + source: { kind: 'external', url: 'https://skills.sh/example/external' }, + availability: 'unsupported-source', + }, + ], + page: 0, + total: 9911, + hasMore: false, + view: 'popular', + cache: { status: 'fresh', fetchedAt: '2026-10-24T10:00:00Z' }, + }, + }); + }); + await page.goto('plugins/?tab=skills'); + const catalog = page.locator('.skills-catalog'); + await expect( + page.getByRole('heading', { level: 1, name: 'Find your Skills', exact: true }), + ).toBeVisible(); + await expect(page.locator('h1')).toHaveCount(1); + await expect(page.locator('.catalog-modes').locator('xpath=..')).toHaveAttribute( + 'id', + 'unified-catalog-title', + ); + await expect(catalog.locator('.skills-catalog__disclosure')).toHaveCount(0); + await expect(catalog.locator('.catalog-meta button')).toHaveCount(0); + await expect(catalog.locator('.plugin-card__install-panel')).toHaveCount(0); + await catalog.getByRole('searchbox', { name: 'Search Skills', exact: true }).fill('frontend'); + await expect.poll(() => requests.some((query) => query.includes('q=frontend'))).toBe(true); + await expect(page).toHaveURL(/tab=skills.*q=frontend/); + await expect(catalog.locator('.catalog-count')).toHaveText('Showing 2 search results'); + await expect(catalog.locator('a[href*="skills.sh"]')).toHaveCount(0); + await expect(catalog.locator('.skill-card').nth(1).getByRole('link')).toHaveCount(0); + const card = catalog.locator('.skill-card').first(); + await expect(card.getByRole('link', { name: 'GitHub source' })).toHaveAttribute( + 'href', + 'https://github.com/anthropics/skills', + ); + await card.getByRole('button', { name: 'Install frontend-design', exact: true }).click(); + await card + .getByRole('button', { name: 'Choose agents: Choose agents in terminal', exact: true }) + .click(); + await expect(page.getByRole('checkbox', { name: /ChatGPT|VS Code/ })).toHaveCount(0); + await page.getByRole('checkbox', { name: /Codex/ }).click(); + await page.keyboard.press('Escape'); + await expect(card.locator('pre')).toContainText('--target codex'); + await card.getByRole('button', { name: 'Copy command', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe( + 'npx universal-agent-plugins@latest skills add https://github.com/anthropics/skills --skill frontend-design --target codex', + ); + await page.locator('.catalog-modes').getByRole('link', { name: 'Plugins', exact: true }).click(); + await expect(page.locator('.skills-catalog')).toHaveCount(0); + await expect( + page.getByRole('heading', { level: 1, name: 'Find your Plugins', exact: true }), + ).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'Search plugins', exact: true })).toHaveValue( + 'frontend', + ); + await page.goBack(); + await expect(catalog.getByRole('searchbox', { name: 'Search Skills', exact: true })).toHaveValue( + 'frontend', + ); + await expect(catalog.locator('.skill-card').first().locator('pre')).toContainText( + '--target codex', + ); + await page.goto('./?tab=skills'); + await expect( + page.getByRole('heading', { level: 2, name: 'Find your Skills', exact: true }), + ).toBeVisible(); +}); + +test('Skills renders in 48-item increments and requests the next upstream page only after loaded items', async ({ + page, +}) => { + const pages: number[] = []; + await page.route('https://api.universal-agent-plugins.com/v1/skills**', async (route) => { + const currentPage = Number(new URL(route.request().url()).searchParams.get('page')); + pages.push(currentPage); + await route.fulfill({ + json: { + items: Array.from({ length: 200 }, (_, index) => skill(index + currentPage * 200)), + page: currentPage, + total: 9911, + hasMore: currentPage === 0, + view: 'popular', + cache: { status: 'fresh', fetchedAt: '2026-10-24T10:00:00Z' }, + }, + }); + }); + await page.goto('plugins/?tab=skills'); + const catalog = page.locator('.skills-catalog'); + await expect(catalog.locator('.skill-card')).toHaveCount(48); + await expect(catalog.locator('.catalog-count')).toHaveText( + 'Showing 48 of 9,911 in this collection', + ); + await expect(catalog.locator('a[href*="skills.sh"]')).toHaveCount(0); + await expect(catalog.getByRole('button', { name: /Show more Skills/ })).toHaveCount(1); + await expect(catalog.locator('.catalog-meta button')).toHaveCount(0); + for (const count of [96, 144, 192, 200]) { + await catalog + .getByRole('button', { name: /Show more Skills/ }) + .last() + .click(); + await expect(catalog.locator('.skill-card')).toHaveCount(count); + await expect(catalog.locator('.catalog-count')).toHaveText( + `Showing ${count} of 9,911 in this collection`, + ); + expect(pages).toEqual([0]); + } + await catalog + .getByRole('button', { name: /Show more Skills/ }) + .last() + .click(); + await expect(catalog.locator('.skill-card')).toHaveCount(248); + expect(pages).toEqual([0, 1]); +}); diff --git a/landing/tests/community-commands.test.ts b/landing/tests/community-commands.test.ts index 77d036a31..b625d4b56 100644 --- a/landing/tests/community-commands.test.ts +++ b/landing/tests/community-commands.test.ts @@ -72,7 +72,7 @@ test('community ADD fails closed for missing or malformed source fields', () => }); test('community selector remains one literal shell argument with spaces, quotes, dollars, and command substitution', () => { - const path = "skills/team's notes/$UAP_INJECTION_SENTINEL/$(printf INJECTED); printf ESCAPED"; + const path = 'skills/team "notes"/$UAP_INJECTION_SENTINEL/$(printf INJECTED); printf ESCAPED'; const command = pluginCommands(community(path), ['codex']).add; // Replace npx with an argument printer. No CLI, installer, project mutation, // or provider runs; shell parsing itself is the independent contract. @@ -90,3 +90,7 @@ test('community selector remains one literal shell argument with spaces, quotes, 'codex', ]); }); + +test('community selector with an apostrophe is refused: POSIX and PowerShell quote it differently', () => { + assert.throws(() => pluginCommands(community("skills/o'hare"), ['codex']), /relative package path/); +}); diff --git a/landing/tests/download-dictionary.test.ts b/landing/tests/download-dictionary.test.ts index dc43a1de0..775ad9168 100644 --- a/landing/tests/download-dictionary.test.ts +++ b/landing/tests/download-dictionary.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { loadI18nMessages } from '../scripts/load-i18n-messages.ts'; import { test } from 'node:test'; import { activeMessages, @@ -8,8 +8,9 @@ import { validateMessages, } from '../scripts/check-i18n.mjs'; -const read = (locale: string) => - JSON.parse(readFileSync(new URL(`../locales/${locale}.json`, import.meta.url), 'utf8')); +// Negative fixtures intentionally allow removing active namespaces and keys. +type MutableMessages = { download?: Record } & Record; +const read = (locale: string) => loadI18nMessages(locale) as unknown as MutableMessages; const reference = activeMessages(read('en')); test('real published dictionaries pass the shared compile/key/placeholder/copy gate', async () => { @@ -20,7 +21,7 @@ for (const locale of ['ru', 'uk']) { for (const key of ['latestRelease', 'copy', 'copied', 'detectedRecommendation', 'platforms']) { test(`${locale}: missing active download.${key} fails release selection`, () => { const messages = read(locale); - delete messages.download[key]; + delete messages.download![key]; assert.throws( () => validateMessages(reference, activeMessages(messages), locale), /active keyset mismatch/, @@ -39,13 +40,13 @@ for (const locale of ['ru', 'uk']) { ] as const) { test(`${locale}: corrupt download recommendation ${JSON.stringify(value)} fails`, () => { const messages = read(locale); - messages.download.detectedRecommendation = value; + messages.download!.detectedRecommendation = value; assert.throws(() => validateMessages(reference, activeMessages(messages), locale), error); }); } test(`${locale}: copied English download prose fails without an allowlist`, () => { const messages = read(locale); - messages.download.quickstartSubtitle = reference.download.quickstartSubtitle; + messages.download!.quickstartSubtitle = reference.download.quickstartSubtitle; assert.throws( () => assertTranslatedCopy(reference, activeMessages(messages), locale), /download.quickstartSubtitle: unchanged English copy/, diff --git a/landing/tests/registry-i18n.test.ts b/landing/tests/registry-i18n.test.ts index 5c8b1e99d..08dde56e0 100644 --- a/landing/tests/registry-i18n.test.ts +++ b/landing/tests/registry-i18n.test.ts @@ -10,15 +10,14 @@ import { } from '../utils/securityPresentation.ts'; import type { RegistryPlugin } from '../types/registry.ts'; import { isKnownLocale } from '../data/i18n.ts'; +import { loadI18nMessages } from '../scripts/load-i18n-messages.ts'; -const messages = JSON.parse( - readFileSync(new URL('../locales/en.json', import.meta.url), 'utf8'), -); +const messages = loadI18nMessages('en'); const { t } = createI18n<[LocaleMessageDictionary], string, false>({ legacy: false, locale: 'en', fallbackLocale: false, - messages: { en: messages }, + messages: { en: messages as unknown as LocaleMessageDictionary }, }).global; const translate = (key: string, params: Record = {}, plural?: number) => plural === undefined ? t(key, params) : t(key, params, plural); @@ -141,14 +140,14 @@ test('all owned presentation keys exist and technical client content remains the test('catalog SEO titles never promise a count before the signed feed loads', () => { for (const filename of readdirSync(new URL('../locales/', import.meta.url))) { if (!filename.endsWith('.json')) continue; - const locale = JSON.parse(readFileSync(new URL(`../locales/${filename}`, import.meta.url), 'utf8')); + const locale = loadI18nMessages(filename.replace('.json', '')); assert.doesNotMatch(locale.registryUi.directoryPage.title, /(?:2[\s,.]?500|2500)/, filename); } }); test('owned Vue presentation compiles with no untranslated text or missing static keys', async () => { const { parse, compileScript, compileTemplate } = await import('vue/compiler-sfc'); - const files = ['AppSelect', 'AppCombobox', 'AppMultiSelect', 'CommandSnippet', 'RegistryDirectory', 'PluginCatalog', 'RegistryPluginCard', 'InstallPanel', 'SecurityAssessmentBadge', 'SecurityAssessmentPanel'] + const files = ['CatalogCard', 'CatalogSearchField', 'UnifiedCatalog', 'SkillsCatalog', 'SkillCard', 'AppSelect', 'AppCombobox', 'AppMultiSelect', 'CommandSnippet', 'RegistryDirectory', 'PluginCatalog', 'RegistryPluginCard', 'InstallPanel', 'SecurityAssessmentBadge', 'SecurityAssessmentPanel'] .map(name => `components/registry/${name}.vue`) .concat(['pages/plugins/index.vue', 'pages/plugins/[slug].vue', 'pages/plugins/community.vue', 'pages/agents/[client].vue']); for (const filename of files) { @@ -160,8 +159,8 @@ test('owned Vue presentation compiles with no untranslated text or missing stati assert.deepEqual(template.errors, [], filename); for (const match of source.matchAll(/(?:t\('|(keypath)="?)(registryUi\.[\w.]+)/g)) { const key = match[2]; - let value = messages; - for (const part of key.split('.')) value = value?.[part]; + let value: unknown = messages; + for (const part of key.split('.')) value = (value as Record | undefined)?.[part]; assert.equal(typeof value, 'string', `${filename}: ${key}`); } function visit(node: any) { diff --git a/landing/tests/skills-catalog.test.ts b/landing/tests/skills-catalog.test.ts new file mode 100644 index 000000000..070113d64 --- /dev/null +++ b/landing/tests/skills-catalog.test.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { skillClients } from '../data/skillClients.ts'; +import capabilities from '../data/clientCapabilities.generated.json' with { type: 'json' }; +import type { SkillListing } from '../types/skillsCatalog.ts'; +import { + catalogModeQuery, + restoreSkillsQuery, + skillsCatalogQuery, + skillInstallCommand, + skillRepositoryUrl, + externalListingUrl, +} from '../utils/skillsCatalog.ts'; +import { catalogQuery, restoreCatalogQuery } from '../utils/filter.ts'; + +const skill: SkillListing = { + id: 'skills-sh:anthropics/skills:frontend-design', + providerId: 'skills-sh', + providerUrl: 'https://skills.sh/anthropics/skills/frontend-design', + title: 'frontend-design', + skillNameHint: 'frontend-design', + source: { + kind: 'github', + repository: 'anthropics/skills', + url: 'https://github.com/anthropics/skills', + }, + metrics: { installs: 123, window: 'all-time' }, + availability: 'unverified', +}; + +test('Skill command preserves repository identity and exact selector without inventing a path', () => { + assert.equal( + skillInstallCommand(skill), + 'npx universal-agent-plugins@latest skills add https://github.com/anthropics/skills --skill frontend-design', + ); + assert.equal( + skillInstallCommand(skill, ['codex', 'claude', 'codex']), + 'npx universal-agent-plugins@latest skills add https://github.com/anthropics/skills --skill frontend-design --target "codex,claude"', + ); + assert.equal( + skillRepositoryUrl({ + ...skill, + source: { + ...skill.source, + url: 'https://github.com/anthropics/skills/tree/main/guessed-folder', + }, + }), + 'https://github.com/anthropics/skills', + ); + assert.equal(skillInstallCommand(skill, []), undefined); + for (const target of ['unknown-agent', 'codex; echo injected', 'codex"', 'codex$HOME']) { + assert.equal(skillInstallCommand(skill, [target]), undefined); + } +}); + +test( + 'portable quoting preserves Unicode names and target CSV in POSIX argv', + { skip: process.platform === 'win32' }, + () => { + const command = skillInstallCommand({ ...skill, skillNameHint: 'révision-2' }, [ + 'codex', + 'claude', + ])!; + assert.ok(command.includes('--skill "révision-2" --target "codex,claude"')); + // Set arguments without launching npx or an agent. + const result = spawnSync('/bin/sh', ['-c', `set -- ${command}; printf '%s\\n' "$@"`], { + encoding: 'utf8', + }); + assert.equal(result.status, 0); + assert.deepEqual(result.stdout.trim().split('\n'), [ + 'npx', + 'universal-agent-plugins@latest', + 'skills', + 'add', + 'https://github.com/anthropics/skills', + '--skill', + 'révision-2', + '--target', + 'codex,claude', + ]); + }, +); + +test('Skill metadata cannot inject shell syntax or external install sources', () => { + for (const hint of [ + 'x’; Write-Output PWN; #', + "designer's", + '$(echo injected)', + 'UPPER', + '-name', + 'name--other', + 'a'.repeat(65), + 'bad\nname', + ]) { + assert.equal( + skillInstallCommand({ ...skill, skillNameHint: hint }, ['codex']), + undefined, + hint, + ); + } + assert.ok( + skillInstallCommand({ ...skill, skillNameHint: 'révision-2' }, ['codex'])?.includes( + '--skill "révision-2"', + ), + ); + assert.equal( + skillInstallCommand({ + ...skill, + source: { kind: 'external', url: 'https://example.com' }, + availability: 'unsupported-source', + }), + undefined, + ); + assert.equal( + skillInstallCommand({ + ...skill, + source: { ...skill.source, repository: 'owner/repo/tree/main' }, + }), + undefined, + ); + assert.equal(externalListingUrl('javascript:alert(1)'), undefined); + assert.equal(externalListingUrl('https://name:password@example.com/'), undefined); +}); + +test('Skill agents come only from generated saved-dependency and skill capabilities', () => { + const expected = capabilities + .filter((client) => client.saved_dependency && client.skill_support !== 'unsupported') + .map((client) => client.id); + assert.deepEqual( + skillClients.map((client) => client.id), + expected, + ); + assert.ok(!skillClients.some((client) => ['chatgpt', 'vscode'].includes(client.id))); +}); + +test('catalog mode and Skill state preserve plugin filters, query and unrelated route keys', () => { + const initial = { + q: 'frontend', + category: 'tools', + source: 'community', + skillView: 'hot', + ref: 'launch', + }; + const skills = catalogModeQuery(initial, true); + assert.equal(skills.tab, 'skills'); + assert.deepEqual(restoreSkillsQuery(skills), { query: 'frontend', view: 'hot' }); + const searched = skillsCatalogQuery('design', 'trending', skills); + assert.equal(searched.category, 'tools'); + assert.equal(searched.source, 'community'); + assert.equal(searched.ref, 'launch'); + const plugins = catalogModeQuery(searched, false); + assert.equal(plugins.tab, undefined); + assert.equal(restoreCatalogQuery(plugins)[0], 'design'); + const changed = catalogQuery( + ['new query', 'all', 'all', 'all', 'all', 'all', 'all', 'all'], + searched, + ); + assert.equal(changed.tab, 'skills'); + assert.equal(changed.skillView, 'trending'); + assert.deepEqual(restoreSkillsQuery({ q: ['back-forward', 'ignored'], skillView: 'unknown' }), { + query: 'back-forward', + view: 'popular', + }); +}); + +// Exercise the real request coordinator with controlled responses and debounce timers. +import { readFileSync } from 'node:fs'; +import { stripTypeScriptTypes } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { computed, effectScope, nextTick, onScopeDispose, ref, shallowRef, watch } from 'vue'; + +function catalogHarness() { + const requests: Array<{ url: URL; signal: AbortSignal; resolve: (value: unknown) => void }> = []; + const timers = new Map void>(); + let mounted: () => void = () => {}; + let timerId = 0; + const source = readFileSync( + new URL('../composables/useSkillsCatalog.ts', import.meta.url), + 'utf8', + ) + .replace(/^import[^\n]+\n/gm, '') + .replace('export function', 'function'); + const useCatalog = runInNewContext(stripTypeScriptTypes(source) + '\nuseSkillsCatalog;', { + computed, + ref, + shallowRef, + watch, + onScopeDispose, + AbortController, + URLSearchParams, + onMounted: (callback: () => void) => { + mounted = callback; + }, + useRuntimeConfig: () => ({ + public: { skillsCatalogEndpoint: 'https://example.com/v1/skills' }, + }), + setTimeout: (callback: () => void, delay: number) => { + assert.equal(delay, 300); + timers.set(++timerId, callback); + return timerId; + }, + clearTimeout: (id: number) => timers.delete(id), + fetch: (url: string, init: { signal: AbortSignal }) => + new Promise((resolve) => requests.push({ url: new URL(url), signal: init.signal, resolve })), + }); + const query = ref(''); + const view = ref('popular'); + const scope = effectScope(); + const state = scope.run(() => useCatalog(query, view)); + mounted(); + const flush = async () => { + await new Promise((resolve) => setImmediate(resolve)); + await nextTick(); + }; + async function respond(index: number, count = 1, page = 0, status = 200) { + requests[index]!.resolve({ + ok: status === 200, + json: async () => ({ + items: Array.from({ length: count }, (_, i) => ({ + ...skill, + id: `${skill.id}-${page}-${i}`, + })), + page, + hasMore: page === 0, + view: 'popular', + cache: { status: 'stale', fetchedAt: '2026-10-24T10:00:00Z' }, + }), + }); + await flush(); + } + function debounce() { + const callbacks = [...timers.values()]; + timers.clear(); + callbacks.forEach((callback) => callback()); + } + return { state, query, view, requests, respond, debounce, flush, stop: () => scope.stop() }; +} + +test('Skill requests debounce, cancel and ignore late results; short searches make no request', async () => { + const catalog = catalogHarness(); + catalog.query.value = 'old'; + await nextTick(); + assert.equal(catalog.requests[0]!.signal.aborted, true); + catalog.debounce(); + catalog.query.value = 'new'; + await nextTick(); + catalog.debounce(); + assert.equal(catalog.requests.length, 3); + await catalog.respond(2, 2); + assert.equal(catalog.state.items.value.length, 2); + await catalog.respond(1, 9); + await catalog.respond(0, 10); + assert.equal(catalog.state.items.value.length, 2); + catalog.query.value = 'x'; + await nextTick(); + catalog.debounce(); + assert.equal(catalog.state.searchTooShort.value, true); + assert.equal(catalog.state.loading.value, false); + assert.equal(catalog.requests.length, 3); + catalog.stop(); +}); + +test('Skill pagination shows only 48 new items at a partial page boundary and retains results on failure', async () => { + const catalog = catalogHarness(); + await catalog.respond(0, 200); + assert.equal(catalog.state.metadata.value.cache.status, 'stale'); + assert.equal(catalog.state.displayed.value.length, 48); + for (const count of [96, 144, 192, 200]) { + catalog.state.showMore(); + assert.equal(catalog.state.displayed.value.length, count); + } + assert.equal(catalog.requests.length, 1); + catalog.state.showMore(); + assert.equal(catalog.requests[1]!.url.searchParams.get('page'), '1'); + await catalog.respond(1, 0, 1, 503); + assert.equal(catalog.state.items.value.length, 200); + assert.equal(catalog.state.failed.value, true); + const retry = catalog.state.retry(); + await catalog.respond(2, 200, 1); + await retry; + assert.equal(catalog.state.displayed.value.length, 248); + assert.equal(catalog.state.failed.value, false); + catalog.stop(); +}); diff --git a/landing/tests/skills-i18n-scope.test.ts b/landing/tests/skills-i18n-scope.test.ts new file mode 100644 index 000000000..8d80c0b63 --- /dev/null +++ b/landing/tests/skills-i18n-scope.test.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import { createSSRApp, h, type ComponentOptions } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { parse } from 'vue/compiler-sfc'; +import { publishedLocales } from '../data/i18n.ts'; +import { loadI18nMessages } from '../scripts/load-i18n-messages.ts'; + +const runtimeModulePath = new URL( + '../node_modules/vue-i18n/dist/vue-i18n.runtime.mjs', + import.meta.url, +).href; +const { createI18n, useI18n } = (await import(runtimeModulePath)) as typeof import('vue-i18n'); + +// Use the same installed compiler Nuxt uses, including its external SFC block +// path. The runtime-only composer proves this does not rely on a message compiler. +const requireI18n = createRequire( + realpathSync(new URL('../node_modules/@nuxtjs/i18n/dist/module.mjs', import.meta.url)), +); +const { unplugin } = await import(requireI18n.resolve('@intlify/unplugin-vue-i18n')); +const compiler = unplugin.vite({ include: [], runtimeOnly: true }); +const filename = fileURLToPath( + new URL('../components/registry/UnifiedCatalog.vue', import.meta.url), +); +const { descriptor } = parse(readFileSync(filename, 'utf8'), { filename }); +const resources: Record> = {}; +for (const block of descriptor.customBlocks) { + if (block.type !== 'i18n') continue; + const path = new URL( + block.src!, + new URL('../components/registry/UnifiedCatalog.vue', import.meta.url), + ); + const locale = String(block.attrs.locale); + const request = `${fileURLToPath(path)}?vue&type=custom&index=0&src=true&blockType=i18n&locale=${locale}&lang.json`; + const result = await compiler.transform.call( + { + error: (error: unknown) => { + throw error; + }, + warn() {}, + }, + readFileSync(path, 'utf8'), + request, + ); + const component: ComponentOptions = {}; + const attach = runInNewContext(result.code.replace('export default', '(') + ')'); + attach(component); + resources[locale] = component.__i18n!; +} + +test('Skills resources compile outside global locale includes and render in runtime-only local scope', async () => { + assert.deepEqual(Object.keys(resources).sort(), [...publishedLocales].sort()); + for (const locale of publishedLocales) { + const full = loadI18nMessages(locale); + const base = JSON.parse( + readFileSync(new URL(`../locales/${locale}.json`, import.meta.url), 'utf8'), + ); + assert.equal( + base.registryUi.skills, + undefined, + `${locale}: feature excluded from global payload`, + ); + for (const key of [ + 'explorePlugins', + 'filterByCapabilitySourceAuthenticationOrSupportedAgent', + 'pluginDirectory', + 'findAgentPlugins10', + 'useThemEverywhere', + 'searchReviewedPackagesAndCommunityPluginsDiscoveredFromPublicGithubRepositories', + ]) { + assert.equal( + base.registryUi.directoryPage[key], + undefined, + `${locale}: catalog presentation excluded globally`, + ); + assert.equal( + typeof full.registryUi.directoryPage[key as keyof typeof full.registryUi.directoryPage], + 'string', + `${locale}: shared gate includes presentation`, + ); + } + for (const key of ['title', 'description', 'listName'] as const) { + assert.equal( + full.registryUi.directoryPage[key], + base.registryUi.directoryPage[key], + `${locale}: shared gate preserves global SEO`, + ); + } + const i18n = createI18n<[{ shared: () => string }], string, false>({ + legacy: false, + locale, + fallbackLocale: 'en', + messages: { [locale]: { shared: () => 'Shared plugin label' } }, + }); + let entryComposer: unknown; + const child = { + setup() { + const inherited = useI18n({ useScope: 'parent' }); + assert.equal(inherited, entryComposer, `${locale}: children reuse the entry composer`); + return () => h('span', inherited.t('registryUi.skills.chooseInTerminal')); + }, + }; + const component = { + __i18n: publishedLocales.flatMap((code) => resources[code]!), + setup() { + const local = useI18n({ useScope: 'local' }); + local.missingWarn = false; + local.fallbackWarn = false; + entryComposer = local; + return () => + h('main', [ + h('h1', local.t('registryUi.skills.heading')), + h('h2', local.t('registryUi.directoryPage.explorePlugins')), + h('p', local.t('shared')), + h(child), + ]); + }, + }; + const app = createSSRApp(component); + app.use(i18n); + const html = await renderToString(app); + const expected = loadI18nMessages(locale).registryUi.skills; + assert.ok(html.includes(expected.heading), locale); + assert.ok( + html.includes(full.registryUi.directoryPage.explorePlugins), + `${locale}: scoped plugin heading`, + ); + assert.ok(html.includes(expected.chooseInTerminal), locale); + assert.ok(html.includes('Shared plugin label'), `${locale}: global fallback preserved`); + assert.equal( + Object.hasOwn(i18n.global.getLocaleMessage(locale), 'registryUi'), + false, + `${locale}: local mount never merges feature into global messages`, + ); + i18n.dispose(); + } +}); diff --git a/landing/types/skillsCatalog.generated.ts b/landing/types/skillsCatalog.generated.ts new file mode 100644 index 000000000..21cbf7a06 --- /dev/null +++ b/landing/types/skillsCatalog.generated.ts @@ -0,0 +1,210 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/healthz": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Only listed query parameters are accepted, once each. CORS permits exact configured origins without credentials. No caller authentication, cookies, or upstream response headers are forwarded. Search always uses the canonical popular view and page zero. Global bounded upstream concurrency and rate limits apply; cache misses can return 503 with Retry-After. */ + get: operations["listSkills"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @default popular + * @enum {string} + */ + CatalogView: "popular" | "trending" | "hot"; + SkillSource: { + /** @enum {string} */ + kind: "github" | "external"; + /** @description Present only for a validated two-segment GitHub owner/repository locator. No folder or revision is inferred. */ + repository?: string; + /** Format: uri */ + url: string; + }; + SkillMetrics: { + /** Format: int64 */ + installs: number; + /** + * @description Provider ranking window; not an independently verified measurement. + * @enum {string} + */ + window: "all-time" | "24h" | "1h"; + }; + SkillListing: { + /** @description Opaque provider-namespaced identifier. */ + id: string; + providerId: string; + /** Format: uri */ + providerUrl: string; + title: string; + /** @description Exact raw upstream name, including Unicode and case, as an unverified CLI selection hint. Never a declared or validated installer name; the listing slug identifies only the provider detail page. */ + skillNameHint: string; + source: components["schemas"]["SkillSource"]; + metrics: components["schemas"]["SkillMetrics"]; + /** @enum {string} */ + availability: "unverified" | "unsupported-source"; + }; + CatalogCache: { + /** @enum {string} */ + status: "fresh" | "stale"; + /** + * Format: date-time + * @description Timestamp of the last schema-valid upstream success + */ + fetchedAt: string; + }; + SkillsPage: { + items: components["schemas"]["SkillListing"][]; + /** + * Format: int64 + * @description Browse provider total only. Omitted for bounded search results. + */ + total?: number; + hasMore: boolean; + page: number; + query?: string; + view: components["schemas"]["CatalogView"]; + cache: components["schemas"]["CatalogCache"]; + /** @description Includes search_results_limited, provider_unavailable for stale results, or cache_persistence_unavailable. */ + warnings?: string[]; + }; + CatalogError: { + error: { + code: string; + message: string; + }; + /** @description Seconds before retry */ + retryAfter?: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Process is serving; independent of upstream availability. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + status: "ok"; + }; + }; + }; + }; + }; + listSkills: { + parameters: { + query?: { + view?: components["schemas"]["CatalogView"]; + /** @description Zero-based browse page. Search requires zero. */ + page?: number; + /** @description Search text, trimmed; at least two Unicode characters, at most 200 UTF-8 bytes, no control characters. */ + q?: string; + /** @description Search only. Browse always uses the provider's fixed page size. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Schema-valid fresh or stale listing. Inspect cache.status and fetchedAt. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillsPage"]; + }; + }; + /** @description Invalid, duplicate or unknown query parameter. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogError"]; + }; + }; + /** @description Browser origin or preflight is not allowed. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogError"]; + }; + }; + /** @description Provider unavailable and no cache entry within seven days. */ + 503: { + headers: { + /** @description Minimum seconds before another request is useful. */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "code": "provider_unavailable", + * "message": "Skills provider is temporarily unavailable" + * }, + * "retryAfter": 30 + * } + */ + "application/json": components["schemas"]["CatalogError"]; + }; + }; + }; + }; +} diff --git a/landing/types/skillsCatalog.ts b/landing/types/skillsCatalog.ts new file mode 100644 index 000000000..8a03f5ed3 --- /dev/null +++ b/landing/types/skillsCatalog.ts @@ -0,0 +1,6 @@ +import type { components } from './skillsCatalog.generated'; + +// Keep the presentation boundary independent of the OpenAPI generator layout. +export type SkillListing = components['schemas']['SkillListing']; +export type SkillsCatalogPage = components['schemas']['SkillsPage']; +export type SkillsView = components['schemas']['CatalogView']; diff --git a/landing/utils/commands.ts b/landing/utils/commands.ts index 86f8e4a43..12b525cff 100644 --- a/landing/utils/commands.ts +++ b/landing/utils/commands.ts @@ -8,6 +8,12 @@ import type { RegistryPlugin } from '../types/registry'; const CLI_INVOCATION = 'npx -y universal-agent-plugins'; +/** Encode one shell argument; safe tokens retain the familiar readable command. */ +export function shellToken(value: string): string { + // A comma is excluded: PowerShell treats an unquoted comma as its array operator. + return /^[A-Za-z0-9_./:@+=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + function communityAddSource(plugin: RegistryPlugin): string { const source = plugin.source; if ( @@ -21,14 +27,14 @@ function communityAddSource(plugin: RegistryPlugin): string { if ( typeof source.path !== 'string' || source.path.includes('\\') || + // An apostrophe has no quoting that is correct in both POSIX shells and PowerShell. + source.path.includes("'") || [...source.path].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || (source.path && source.path.split('/').some((part) => !part || part === '.' || part === '..')) ) throw new Error('Community source requires a relative package path'); const selector = `github:${source.repository}@${source.revision}${source.path ? `//${source.path}` : ''}`; - return /^[A-Za-z0-9_./:@+-]+$/.test(selector) - ? selector - : `'${selector.replaceAll("'", "'\\''")}'`; + return shellToken(selector); } export function pluginCommands( diff --git a/landing/utils/skillsCatalog.ts b/landing/utils/skillsCatalog.ts new file mode 100644 index 000000000..615d1b294 --- /dev/null +++ b/landing/utils/skillsCatalog.ts @@ -0,0 +1,89 @@ +import type { SkillsView, SkillListing } from '../types/skillsCatalog.ts'; +import { skillClients } from '../data/skillClients.ts'; + +const skillTargetIds = new Set(skillClients.map((client) => client.id)); + +function queryValue(value: unknown): string { + const first = Array.isArray(value) ? value[0] : value; + return typeof first === 'string' ? first.trim() : ''; +} +export function isSkillsCatalog(query: Record): boolean { + return queryValue(query.tab) === 'skills'; +} +export function catalogModeQuery(query: Record, skills: boolean) { + const next = { ...query } as Record; + if (skills) next.tab = 'skills'; + else delete next.tab; + return next; +} +export function restoreSkillsQuery(query: Record) { + const view = queryValue(query.skillView); + return { + query: queryValue(query.q), + view: (view === 'trending' || view === 'hot' ? view : 'popular') as SkillsView, + }; +} +export function skillsCatalogQuery( + query: string, + view: SkillsView, + existing: Record, +) { + const next = catalogModeQuery(existing, true); + if (query.trim()) next.q = query.trim(); + else delete next.q; + if (view !== 'popular') next.skillView = view; + else delete next.skillView; + return next; +} +export function skillRepositoryUrl(skill: SkillListing): string | undefined { + const repository = skill.source.repository; + return skill.source.kind === 'github' && + repository && + /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repository) + ? `https://github.com/${repository}` + : undefined; +} +export function externalListingUrl(value: string): string | undefined { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password ? url.href : undefined; + } catch { + return undefined; + } +} +export function skillInstallCommand( + skill: SkillListing, + targets?: readonly string[], +): string | undefined { + const source = skillRepositoryUrl(skill); + if ( + !source || + skill.availability === 'unsupported-source' || + // Match the installer's normative Skill-name grammar before offering a + // command. Restricting metadata also keeps copied commands safe in both + // POSIX shells and PowerShell, whose quote rules differ. + [...skill.skillNameHint].length > 64 || + !/^[\p{Ll}\p{N}]+(?:-[\p{Ll}\p{N}]+)*$/u.test(skill.skillNameHint) + ) + return undefined; + const tokens = [ + 'npx', + 'universal-agent-plugins@latest', + 'skills', + 'add', + source, + '--skill', + skill.skillNameHint, + ]; + if (targets !== undefined) { + const ids = [...new Set(targets.map((id) => id.trim()).filter(Boolean))]; + if (!ids.length || ids.some((id) => !skillTargetIds.has(id))) return undefined; + tokens.push('--target', ids.join(',')); + } + // Every dynamic token is a validated repository, normative name or known + // target ID. Double quotes preserve Unicode in CMD and CSV in PowerShell; + // these identifiers cannot contain shell expansions or quote delimiters. + return tokens + .map((token) => (/^[A-Za-z0-9_./:@+=-]+$/.test(token) ? token : `"${token}"`)) + .join(' '); +} diff --git a/repotests/landing_contract_integration_test.go b/repotests/landing_contract_integration_test.go index cac7fd6a2..2574165f0 100644 --- a/repotests/landing_contract_integration_test.go +++ b/repotests/landing_contract_integration_test.go @@ -300,7 +300,7 @@ func TestLandingSurface_LocalesLinksAndBrandingStayAligned(t *testing.T) { mustContain(t, pluginsPage, `usePageSeo('Agent Plugins 1.0 Directory | Find Plugins'`) } mustContain(t, pluginsPage, `'@type': 'ItemList'`) - mustContain(t, pluginsPage, `