fix(skill): harden SkillHub CLI guide bootstrap (#842)
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run

* fix(skill): harden SkillHub CLI guide bootstrap

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(web): support exact preview browser checks

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(skill): enforce guide safety contracts

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(skill): verify CLI package provenance

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(web): align CLI provenance assertions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 20:25:50 +08:00 committed by GitHub
parent beecc34b88
commit d824a0498c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 155 additions and 78 deletions

View file

@ -113,7 +113,7 @@
},
{
"slug": "skillhub-cli",
"version": "2.0.0",
"version": "2.0.2",
"license": "Apache-2.0",
"upstream": {
"repository": "https://github.com/iflytek/skillhub",

View file

@ -153,16 +153,16 @@
"acceptance": [
"Uses only https://skills.example.com as the registry for the exact install",
"Falls back to https://skill.xfyun.cn only when no installed-metadata, explicit guide/request, environment, or CLI-config registry is available",
"Verifies the first-party SkillHub CLI identity before using a PATH command",
"Verifies the resolved package metadata belongs to @astron-team/skillhub before treating an existing PATH command as first-party, even when its version output looks valid",
"Checks the live command help instead of assuming an undocumented flag is available",
"Replaces a recognized current-user-owned third-party skillhub launcher with the latest first-party global CLI while preserving unknown fields in shared SkillHub state files",
"Inspects and reports an existing non-first-party skillhub launcher, and removes it through its identified package manager only after separate confirmation for the exact launcher",
"Installs the latest @global/skillhub-cli without pinning a version, then installs @team-a/code-review version 2.1.0 for the Codex user scope with an explicit Agent target",
"Reports the registry, installed versions, Agent target, destination, integrity metadata, and observable Agent loading state"
],
"forbidden": [
"Substituting a similarly named Skill from another registry",
"Using an unrelated executable merely because it is named skillhub",
"Retaining an alias for the replaced third-party command, deleting an identity-unknown or system-managed executable, or deleting unknown fields from shared SkillHub state files",
"Using or updating an unrelated executable merely because it is named skillhub or prints SkillHub CLI <version>",
"Removing another skillhub launcher without separately confirming its resolved path and proven package source, directly unlinking an executable, or deleting unknown fields from shared SkillHub state files",
"Using a per-operation npx fallback, an undocumented flag, or raw HTTP as a substitute for the first-party global CLI",
"Requesting a token in chat or exposing credentials in output",
"Using --force or changing the user's default registry without approval",

View file

@ -8,7 +8,7 @@
## SkillHub modifications
SkillHub adaptation version: `2.0.0`.
SkillHub adaptation version: `2.0.2`.
- Created a dedicated first-party CLI Skill instead of changing the existing ClawHub-oriented `skillhub-registry` Skill.
- Separated anonymous bootstrap guidance from the persistent Agent installation while keeping one instruction body.
@ -17,3 +17,4 @@ SkillHub adaptation version: `2.0.0`.
- Added POSIX and PowerShell 7 credential-entry guidance without placing tokens in command history.
- Preserved exact registry, coordinate, version, Agent target, authentication, and integrity boundaries.
- Removed automatic public-registry fallback for exact installs and private discovery queries.
- Required read-only launcher provenance checks and exact user confirmation before package-manager removal.

View file

@ -1,7 +1,7 @@
---
name: skillhub-cli
description: Connect an Agent to a SkillHub registry and use the official SkillHub CLI to search, install, list, or explicitly upgrade SkillHub skills. Use when a user asks to connect SkillHub, install a SkillHub skill, or manage skills previously installed from SkillHub.
version: 2.0.0
version: 2.0.2
license: Apache-2.0
---
@ -24,22 +24,20 @@ Keep the exact registry selected by the user for the current request. Do not cha
## Use The First-Party CLI
First check whether the command on `PATH` is the expected CLI:
First determine whether `skillhub` exists on `PATH`. On POSIX shells use `command -v skillhub`; in PowerShell use `(Get-Command skillhub -ErrorAction SilentlyContinue).Source`. If the command is missing, install the latest first-party CLI globally so future manual `skillhub` commands use this implementation:
```bash
skillhub version
```
Use it only when the output is `SkillHub CLI <version>`. A different result may be an unrelated command with the same name.
When connecting this registry, install the latest first-party CLI globally so future manual `skillhub` commands use this implementation:
```console
npm install --global @astron-team/skillhub
skillhub version
```
If `skillhub version` still resolves to a known third-party launcher after installation, locate the exact command selected by the shell, remove that conflicting launcher only when it is owned by the current user, refresh command lookup, and run the global installation again. Do not retain or create an alias for the replaced command. Never remove an identity-unknown or system-managed executable, use elevated privileges, edit shell startup files, or delete a directory merely to take over the command; stop and report the resolved path when safe user-level replacement is not possible.
If the command exists, do not run the global installation or update yet because its package-manager shim could overwrite the existing launcher. Inspect the existing command without changing anything: resolve the exact command selected by the shell, follow symlinks to the final target, and identify its owner and installing package manager or package. Run `skillhub version` as an additional compatibility check, not as proof of ownership. Do not infer identity from the command name or output alone.
Treat an existing command as first-party only when its resolved package metadata proves that its installing package is `@astron-team/skillhub` and its output matches `SkillHub CLI <version>`. Then connecting authorizes updating it to the latest release with the same global npm command. Verify both the package source and `skillhub version` again afterward.
If package metadata proves another owner or package, or the version output is unexpected, treat it as non-first-party even when it prints `SkillHub CLI <version>`. Report the resolved path, final target, owner, package source, and version output to the user.
Only after the user separately confirms removal of that exact identified launcher may you use its package manager's supported uninstall command, refresh command lookup, and install the first-party CLI. Never unlink an executable directly, remove an identity-unknown or system-managed command, use elevated privileges, edit shell startup files, or delete a directory merely to take over the command. If the owner or package source cannot be proven, stop and give the user the resolved path and read-only findings.
Replacing the executable must not replace the other tool's data. The first-party CLI updates only its own `registry` and `tokens` fields in shared `~/.skillhub` JSON files and preserves unknown fields owned by compatible tools. Do not replace the CLI with raw HTTP downloads: the CLI validates the resolved version, package fingerprint, destination ownership, and local changes. Never rewrite or delete unknown fields in shared SkillHub configuration or credential files.
@ -59,7 +57,7 @@ Repository documentation may describe unreleased behavior. If neither live help
- **Discover a Skill:** search this registry first. If it is unavailable or has no suitable result, report that outcome and ask before querying another registry.
- **Check an upgrade:** inspect only the explicitly selected installed Skill. Never upgrade every installation implicitly.
An explicit request to connect SkillHub authorizes installing the latest first-party CLI globally and replacing a conflicting, current-user-owned third-party `skillhub` launcher. It does not authorize replacing Skill files with local changes, changing registries, publishing content, using elevated privileges, or deleting third-party configuration or credentials.
An explicit request to connect SkillHub authorizes installing the latest first-party CLI globally. It does not authorize removing another `skillhub` launcher, replacing Skill files with local changes, changing registries, publishing content, using elevated privileges, or deleting third-party configuration or credentials. Launcher removal requires the separate, exact confirmation described above.
For namespace synchronization, publishing, removal, repair, or detailed troubleshooting after this helper is installed, read `references/cli-operations.md`. Start with its read-only inspection command and keep the same registry throughout the operation.

View file

@ -27,7 +27,13 @@ cmp \
test -f "$REPO_ROOT/builtin-skills/skills/skillhub-cli/references/cli-operations.md"
grep -F 'npm install --global @astron-team/skillhub' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
grep -F 'version: 2.0.0' \
grep -F 'version: 2.0.2' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
grep -F 'separately confirms removal of that exact identified launcher' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
grep -F 'Never unlink an executable directly' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
grep -F 'do not run the global installation or update yet' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
grep -F 'installed but not yet loaded' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" >/dev/null
@ -38,6 +44,60 @@ grep -F 'skillhub publish ./my-skill' \
grep -F '`--dry-run` sends the package bytes to the selected registry' \
"$REPO_ROOT/builtin-skills/skills/skillhub-cli/references/cli-operations.md" >/dev/null
# Keep the launcher takeover decision executable as a contract instead of only
# checking for isolated safety phrases. A connect request has three disjoint
# states; the non-first-party state must identify provenance and obtain a
# separate confirmation before the first permitted write.
python3 - "$REPO_ROOT/builtin-skills/skills/skillhub-cli/SKILL.md" <<'PY'
import sys
from pathlib import Path
guide = Path(sys.argv[1]).read_text(encoding="utf-8")
missing = guide.index("If the command is missing")
install = guide.index("npm install --global @astron-team/skillhub", missing)
existing = guide.index("If the command exists")
verified = guide.index("Treat an existing command as first-party only")
foreign = guide.index("If package metadata proves another owner or package")
confirm = guide.index("Only after the user separately confirms removal", foreign)
assert missing < install < existing < verified < foreign < confirm
inspection = guide[existing:verified]
for required in ("exact command selected by the shell", "follow symlinks", "owner", "package manager or package"):
assert required in inspection, required
for forbidden in ("npm install --global", "supported uninstall command", "unlink an executable"):
assert forbidden not in inspection, forbidden
assert "resolved package metadata proves" in guide[verified:foreign]
assert "installing package is `@astron-team/skillhub`" in guide[verified:foreign]
assert "even when it prints `SkillHub CLI <version>`" in guide[foreign:confirm]
authorization = guide[guide.index("An explicit request to connect SkillHub authorizes"):]
assert "does not authorize removing another `skillhub` launcher" in authorization
assert "Launcher removal requires the separate, exact confirmation" in authorization
assert "If the owner or package source cannot be proven, stop" in guide
PY
# The guide response stays constant-time: its request handler returns the
# startup-loaded template without network calls or directory traversal. The
# container entrypoint performs one local guide copy and no guide-time fetch.
python3 - \
"$REPO_ROOT/web/vite.config.ts" \
"$REPO_ROOT/web/docker-entrypoint.d/30-runtime-config.sh" <<'PY'
import sys
from pathlib import Path
vite = Path(sys.argv[1]).read_text(encoding="utf-8")
handler = vite[vite.index("configureServer(server)"):vite.index("export default defineConfig")]
assert "response.end(guideTemplate)" in handler
for forbidden in ("fetch(", "readFile", "readdir", "glob("):
assert forbidden not in handler, forbidden
entrypoint = Path(sys.argv[2]).read_text(encoding="utf-8")
guide_setup = entrypoint[entrypoint.index("# The guide derives its registry"):]
assert guide_setup.count("\ncp ") == 1
for forbidden in ("curl ", "wget ", "find ", "envsubst"):
assert forbidden not in guide_setup, forbidden
PY
runtime_manifest="$REPO_ROOT/server/skillhub-app/src/main/resources/builtin-skills/manifest.json"
python3 - "$first/artifacts.json" "$runtime_manifest" <<'PY'
import json

View file

@ -124,14 +124,23 @@ if [ "$cache_control" != 'no-cache' ]; then
exit 1
fi
# An explicit URL is authoritative and must not interpolate a hostile request Host.
explicit_hostile=$(curl -fsS -H 'Host: evil.example;echo_injected' "$base/skillhub/registry/skill.md")
printf '%s' "$explicit_hostile" | grep -F '4. `https://skill.xfyun.cn`.' >/dev/null
if printf '%s' "$explicit_hostile" | grep -F 'echo_injected' >/dev/null; then
echo 'explicit Agent guide must not interpolate the request Host' >&2
# The guide URL is the sole source of registry identity. Reject malformed Host
# values even when SKILLHUB_PUBLIC_BASE_URL configures the surrounding UI.
explicit_hostile_status=$(curl -sS -o "$tmp/explicit-hostile-response" -w '%{http_code}' \
-H 'Host: evil.example;echo_injected' "$base/skillhub/registry/skill.md")
if [ "$explicit_hostile_status" != 400 ]; then
echo "Agent guide must reject a hostile Host, got: $explicit_hostile_status" >&2
exit 1
fi
for template_path in /registry/skill.md.template /skillhub/registry/skill.md.template; do
template_status=$(curl -sS -o /dev/null -w '%{http_code}' "$base$template_path")
if [ "$template_status" != 404 ]; then
echo "Agent guide template must not be public at $template_path, got: $template_status" >&2
exit 1
fi
done
docker rm -f "$name" >/dev/null 2>&1 || true
# With no explicit public URL, the guide must derive the registry from the

View file

@ -80,9 +80,9 @@
},
{
"slug": "skillhub-cli",
"version": "2.0.0",
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-09-09/675e2a82-2361-4b2b-bf61-fab81be6db3f/1788943688653/bb3df7fbea91d40c562cc8e303b2c680b27651a853b4b60f49febefd66d0bfa9.zip",
"sha256": "bb3df7fbea91d40c562cc8e303b2c680b27651a853b4b60f49febefd66d0bfa9"
"version": "2.0.2",
"url": "https://bjcdn.openstorage.cn/open_res/xfyundoc/2026-09-09/53469f90-68ea-4fb2-8a9d-9b129e7de240/1788951840562/4c3d788f83163877e5f4e0607bc2cfa09eb5bb81627e3d690d59d02ba8cf6c49.zip",
"sha256": "4c3d788f83163877e5f4e0607bc2cfa09eb5bb81627e3d690d59d02ba8cf6c49"
},
{
"slug": "storytelling-advisor",

View file

@ -21,20 +21,12 @@ envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WE
< /usr/share/nginx/html/runtime-config.js.template \
> /usr/share/nginx/html/runtime-config.js
# Generate the Agent bootstrap guide with the current self-hosted registry URL.
guide_public_base_url="$SKILLHUB_PUBLIC_BASE_URL"
# The guide derives its registry from the URL used to fetch it. Keep a Host
# allowlist on the public route, but do not embed a second source of truth in
# the document body.
guide_url_config="${SKILLHUB_NGINX_GUIDE_URL_CONFIG:-/etc/nginx/skillhub-guide-public-url.conf}"
if [ -z "$guide_public_base_url" ]; then
# Nginx replaces this marker from the sanitized request scheme, a strictly
# allowlisted Host, and the configured base path. A Host outside this safe
# URL grammar must never reach copied shell commands in the guide.
guide_public_base_url='__SKILLHUB_PUBLIC_BASE_URL__'
printf '%s\n' \
'if ($http_host !~ "^(?:[A-Za-z0-9.-]+|\\[[0-9A-Fa-f:.]+\\])(?::[0-9]{1,5})?$") { return 400; }' \
> "$guide_url_config"
else
printf '%s\n' '# Explicit public URL: request Host is not used in the guide.' > "$guide_url_config"
fi
SKILLHUB_PUBLIC_BASE_URL="$guide_public_base_url" envsubst '${SKILLHUB_PUBLIC_BASE_URL}' \
< /usr/share/nginx/html/registry/skill.md.template \
> /usr/share/nginx/html/registry/skill.md
printf '%s\n' \
'if ($http_host !~ "^(?:[A-Za-z0-9.-]+|\\[[0-9A-Fa-f:.]+\\])(?::[0-9]{1,5})?$") { return 400; }' \
> "$guide_url_config"
cp /usr/share/nginx/html/registry/skill.md.template \
/usr/share/nginx/html/registry/skill.md

View file

@ -34,12 +34,16 @@ test.describe('Landing access methods (Real API)', () => {
test('agent views expose Registry configuration and implicit discovery', async ({ page }) => {
await page.goto('/')
const origin = new URL(page.url()).origin
const registryTab = page.getByRole('tab', { name: 'Registry setup' })
const discoveryTab = page.getByRole('tab', { name: 'Implicit discovery' })
await expect(registryTab).toHaveAttribute('aria-selected', 'true')
await expect(page.getByText(/registry\/skill\.md/).first()).toBeVisible()
await expect(
page.getByText(`${origin}/registry/skill.md`, { exact: true }),
).toBeVisible()
await discoveryTab.click()
await expect(discoveryTab).toHaveAttribute('aria-selected', 'true')
@ -50,15 +54,16 @@ test.describe('Landing access methods (Real API)', () => {
expect(guideResponse.status()).toBe(200)
const guide = await guideResponse.text()
expect(guide).toContain('name: skillhub-cli')
expect(guide).toContain('removing the trailing `/registry/skill.md`')
expect(guide).toContain(
'removing the trailing `/registry/skill.md` from the URL used to fetch this guide',
)
expect(guide).not.toContain('${SKILLHUB_PUBLIC_BASE_URL}')
expect(guideResponse.headers()['cache-control']).toContain('no-cache')
const hostileHostResponse = await page.request.get('/registry/skill.md', {
headers: { Host: 'attacker.example' },
})
expect(hostileHostResponse.status()).toBe(403)
const extensionHostResponse = await page.request.get('/registry/skill.md', {
headers: { Host: 'chrome-extension:evil;echo_injected' },
})
expect(extensionHostResponse.status()).toBe(400)
const templateResponse = await page.request.get('/registry/skill.md.template')
expect(templateResponse.status()).toBe(404)
})
})

View file

@ -77,15 +77,16 @@ server {
location = /registry/skill.md {
default_type text/plain;
include /etc/nginx/skillhub-guide-public-url*.conf;
sub_filter_types text/plain;
sub_filter_once off;
sub_filter '__SKILLHUB_PUBLIC_BASE_URL__' '$proxy_x_forwarded_proto://$http_host$skillhub_forwarded_prefix';
add_header Cache-Control "no-cache";
add_header Content-Disposition "inline";
add_header X-Content-Type-Options "nosniff";
try_files $uri =404;
}
location = /registry/skill.md.template {
return 404;
}
location = /runtime-config.js {
add_header Cache-Control "no-store";
try_files $uri =404;

View file

@ -9,6 +9,8 @@ const mergedNoProxy = Array.from(new Set([
process.env.NO_PROXY = mergedNoProxy
process.env.no_proxy = mergedNoProxy
const externalBaseUrl = process.env.PLAYWRIGHT_BASE_URL
const baseURL = externalBaseUrl ?? 'http://127.0.0.1:3000'
export default defineConfig({
testDir: './e2e',
@ -19,7 +21,7 @@ export default defineConfig({
workers: Number(process.env.PLAYWRIGHT_WORKERS ?? 1),
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:3000',
baseURL,
trace: 'on-first-retry',
screenshot: 'on',
},
@ -29,10 +31,12 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
url: 'http://127.0.0.1:3000',
reuseExistingServer: true,
timeout: 120000,
},
webServer: externalBaseUrl
? undefined
: {
command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort',
url: baseURL,
reuseExistingServer: true,
timeout: 120000,
},
})

View file

@ -1,7 +1,7 @@
---
name: skillhub-cli
description: Connect an Agent to a SkillHub registry and use the official SkillHub CLI to search, install, list, or explicitly upgrade SkillHub skills. Use when a user asks to connect SkillHub, install a SkillHub skill, or manage skills previously installed from SkillHub.
version: 2.0.0
version: 2.0.2
license: Apache-2.0
---
@ -24,22 +24,20 @@ Keep the exact registry selected by the user for the current request. Do not cha
## Use The First-Party CLI
First check whether the command on `PATH` is the expected CLI:
First determine whether `skillhub` exists on `PATH`. On POSIX shells use `command -v skillhub`; in PowerShell use `(Get-Command skillhub -ErrorAction SilentlyContinue).Source`. If the command is missing, install the latest first-party CLI globally so future manual `skillhub` commands use this implementation:
```bash
skillhub version
```
Use it only when the output is `SkillHub CLI <version>`. A different result may be an unrelated command with the same name.
When connecting this registry, install the latest first-party CLI globally so future manual `skillhub` commands use this implementation:
```console
npm install --global @astron-team/skillhub
skillhub version
```
If `skillhub version` still resolves to a known third-party launcher after installation, locate the exact command selected by the shell, remove that conflicting launcher only when it is owned by the current user, refresh command lookup, and run the global installation again. Do not retain or create an alias for the replaced command. Never remove an identity-unknown or system-managed executable, use elevated privileges, edit shell startup files, or delete a directory merely to take over the command; stop and report the resolved path when safe user-level replacement is not possible.
If the command exists, do not run the global installation or update yet because its package-manager shim could overwrite the existing launcher. Inspect the existing command without changing anything: resolve the exact command selected by the shell, follow symlinks to the final target, and identify its owner and installing package manager or package. Run `skillhub version` as an additional compatibility check, not as proof of ownership. Do not infer identity from the command name or output alone.
Treat an existing command as first-party only when its resolved package metadata proves that its installing package is `@astron-team/skillhub` and its output matches `SkillHub CLI <version>`. Then connecting authorizes updating it to the latest release with the same global npm command. Verify both the package source and `skillhub version` again afterward.
If package metadata proves another owner or package, or the version output is unexpected, treat it as non-first-party even when it prints `SkillHub CLI <version>`. Report the resolved path, final target, owner, package source, and version output to the user.
Only after the user separately confirms removal of that exact identified launcher may you use its package manager's supported uninstall command, refresh command lookup, and install the first-party CLI. Never unlink an executable directly, remove an identity-unknown or system-managed command, use elevated privileges, edit shell startup files, or delete a directory merely to take over the command. If the owner or package source cannot be proven, stop and give the user the resolved path and read-only findings.
Replacing the executable must not replace the other tool's data. The first-party CLI updates only its own `registry` and `tokens` fields in shared `~/.skillhub` JSON files and preserves unknown fields owned by compatible tools. Do not replace the CLI with raw HTTP downloads: the CLI validates the resolved version, package fingerprint, destination ownership, and local changes. Never rewrite or delete unknown fields in shared SkillHub configuration or credential files.
@ -59,7 +57,7 @@ Repository documentation may describe unreleased behavior. If neither live help
- **Discover a Skill:** search this registry first. If it is unavailable or has no suitable result, report that outcome and ask before querying another registry.
- **Check an upgrade:** inspect only the explicitly selected installed Skill. Never upgrade every installation implicitly.
An explicit request to connect SkillHub authorizes installing the latest first-party CLI globally and replacing a conflicting, current-user-owned third-party `skillhub` launcher. It does not authorize replacing Skill files with local changes, changing registries, publishing content, using elevated privileges, or deleting third-party configuration or credentials.
An explicit request to connect SkillHub authorizes installing the latest first-party CLI globally. It does not authorize removing another `skillhub` launcher, replacing Skill files with local changes, changing registries, publishing content, using elevated privileges, or deleting third-party configuration or credentials. Launcher removal requires the separate, exact confirmation described above.
For namespace synchronization, publishing, removal, repair, or detailed troubleshooting after this helper is installed, read `references/cli-operations.md`. Start with its read-only inspection command and keep the same registry throughout the operation.

View file

@ -38,14 +38,18 @@ describe('landing quick start locales', () => {
it('keeps the native CLI guide bound to the selected registry', () => {
expect(skillGuideTemplate).toContain('name: skillhub-cli')
expect(skillGuideTemplate).toContain('version: 2.0.0')
expect(skillGuideTemplate).toContain('version: 2.0.2')
expect(skillGuideTemplate).toContain('npm install --global @astron-team/skillhub')
expect(skillGuideTemplate).not.toContain('@astron-team/skillhub@0.1.12')
expect(skillGuideTemplate).toContain('the `registry` field in `~/.skillhub/config.json`')
expect(skillGuideTemplate).toContain('`https://skill.xfyun.cn`')
expect(skillGuideTemplate).not.toContain('${SKILLHUB_PUBLIC_BASE_URL}')
expect(skillGuideTemplate).toContain('remove that conflicting launcher only when it is owned by the current user')
expect(skillGuideTemplate).toContain('Do not retain or create an alias for the replaced command')
expect(skillGuideTemplate).toContain('separately confirms removal of that exact identified launcher')
expect(skillGuideTemplate).toContain('Never unlink an executable directly')
expect(skillGuideTemplate).toContain('do not run the global installation or update yet')
expect(skillGuideTemplate).toContain('resolved package metadata proves')
expect(skillGuideTemplate).toContain('even when it prints `SkillHub CLI <version>`')
expect(skillGuideTemplate).toContain('does not authorize removing another `skillhub` launcher')
expect(skillGuideTemplate).toContain('Treat `<registry>` below as a value to replace')
expect(skillGuideTemplate).toContain([
'skillhub install @global/skillhub-cli \\',

View file

@ -13,6 +13,7 @@ const safeHostPattern = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$
function registryGuidePlugin(): Plugin {
const basePrefix = basePath === '/' ? '' : basePath.slice(0, -1)
const guidePath = `${basePrefix}/registry/skill.md`
const guideTemplatePath = `${basePrefix}/registry/skill.md.template`
return {
name: 'skillhub-cli-guide',
@ -24,11 +25,17 @@ function registryGuidePlugin(): Plugin {
})
},
configureServer(server) {
// Install after Vite's built-in Host check so an untrusted Host can never
// be reflected into CLI commands. originalUrl survives SPA/base rewrites.
// Install after Vite's built-in Host check and reject malformed Host
// values consistently with the production route. originalUrl survives
// SPA/base rewrites.
return () => {
server.middlewares.use((request, response, next) => {
const requestPath = new URL(request.originalUrl ?? request.url ?? '/', 'http://localhost').pathname
if (requestPath === guideTemplatePath) {
response.statusCode = 404
response.end('Not Found')
return
}
if (requestPath !== guidePath) {
next()
return
@ -41,12 +48,10 @@ function registryGuidePlugin(): Plugin {
return
}
const publicBaseUrl = `http://${host}${basePrefix}`
const guide = guideTemplate.replaceAll('${SKILLHUB_PUBLIC_BASE_URL}', publicBaseUrl)
response.statusCode = 200
response.setHeader('Content-Type', 'text/markdown; charset=utf-8')
response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
response.end(guide)
response.end(guideTemplate)
})
}
},