WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// declareBundle is the skill package every validation case is matched back
|
||||
// against. The interesting name lives in the script rather than in SKILL.md,
|
||||
// because that is where a variable a skill really reads shows up.
|
||||
func declareBundle() *SkillBundle {
|
||||
return &SkillBundle{
|
||||
Name: "pdf-tools",
|
||||
Files: map[string][]byte{
|
||||
"SKILL.md": []byte(
|
||||
"Set DOC_ROOT before running. Do not touch PATH, path_prefix or " +
|
||||
"WEKNORA_SKILL_DIR.\n",
|
||||
),
|
||||
"scripts/extract.py": []byte(
|
||||
"import os\nkey = os.environ[\"TAVILY_API_KEY\"]\n",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationReadsTheAgentsFile(t *testing.T) {
|
||||
declared, err := parseEnvDeclaration([]byte(`{"env":[
|
||||
{"name":"TAVILY_API_KEY","description":"search key","required":true},
|
||||
{"name":"DOC_ROOT"}
|
||||
]}`))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []declaredSkillEnv{
|
||||
{Name: "TAVILY_API_KEY", Description: "search key", Required: true},
|
||||
{Name: "DOC_ROOT"},
|
||||
}, declared)
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationAcceptsAnEmptyList(t *testing.T) {
|
||||
declared, err := parseEnvDeclaration([]byte(`{"env":[]}`))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, declared)
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationRequiresAnExplicitArray(t *testing.T) {
|
||||
for _, raw := range []string{`{}`, `{"env":null}`} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
_, err := parseEnvDeclaration([]byte(raw))
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationRejectsGarbage(t *testing.T) {
|
||||
_, err := parseEnvDeclaration([]byte("I installed the skill, boss!"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationRejectsAWrongShape(t *testing.T) {
|
||||
_, err := parseEnvDeclaration([]byte(`{"env":"TAVILY_API_KEY"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseEnvDeclarationRejectsAnImplausiblyLargeList(t *testing.T) {
|
||||
entries := make([]declaredSkillEnv, maxSkillEnvDeclarationCandidates+1)
|
||||
for i := range entries {
|
||||
entries[i].Name = fmt.Sprintf("VAR_%03d", i)
|
||||
}
|
||||
raw, err := json.Marshal(skillEnvDeclarationFile{Env: entries})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = parseEnvDeclaration(raw)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "too many")
|
||||
}
|
||||
|
||||
func TestValidateUserEnvNameFormatLayer(t *testing.T) {
|
||||
require.NoError(t, validateEnvNameFormat("PATH"),
|
||||
"format validation must not accidentally include the reserved-name layer")
|
||||
require.NoError(t, validateEnvNameFormat("_PRIVATE"))
|
||||
require.Error(t, validateEnvNameFormat("path_prefix"))
|
||||
require.Error(t, validateEnvNameFormat("HAS SPACE"))
|
||||
require.Error(t, validateEnvNameFormat("1LEADING_DIGIT"))
|
||||
}
|
||||
|
||||
func TestValidateUserEnvNameReservedLayer(t *testing.T) {
|
||||
require.NoError(t, validateEnvNameNotReserved("TAVILY_API_KEY"))
|
||||
require.NoError(t, validateEnvNameNotReserved("WEKNORA_API_KEY"),
|
||||
"credential names a WeKnora skill reads are not sandbox-injected")
|
||||
require.NoError(t, validateEnvNameNotReserved("WEKNORA_BASE_URL"))
|
||||
require.NoError(t, validateEnvNameNotReserved("lowercase-is-a-format-concern"),
|
||||
"reserved-name validation must remain independent of name format")
|
||||
require.Error(t, validateEnvNameNotReserved("PATH"))
|
||||
require.Error(t, validateEnvNameNotReserved("LD_PRELOAD"))
|
||||
require.Error(t, validateEnvNameNotReserved("WEKNORA_SKILL_DIR"))
|
||||
require.Error(t, validateEnvNameNotReserved("WEKNORA_SKILL_OUTPUT_DIR"))
|
||||
require.Error(t, validateEnvNameNotReserved("WEKNORA_SKILL_HISTORY_ROOT"))
|
||||
require.Error(t, validateEnvNameNotReserved("WEKNORA_SESSION_INPUT_DIR"))
|
||||
}
|
||||
|
||||
// Every name ExecuteScript injects must be undeclarable. This is the lock that
|
||||
// output dir, history root, skill dir and session input cannot drift out of
|
||||
// the blacklist the way a hand-maintained prefix list can.
|
||||
func TestReservedEnvNamesCoverEveryInjectedSandboxVar(t *testing.T) {
|
||||
for _, name := range skills.InjectedSandboxEnvVars() {
|
||||
require.Error(t, validateEnvNameNotReserved(name), name)
|
||||
require.Error(t, validateUserEnvName(name), name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnvDeclarationBundleMatchLayerSearchesEveryFile(t *testing.T) {
|
||||
bundle := declareBundle()
|
||||
require.True(t, bundleMentionsEnvName(bundle, "TAVILY_API_KEY"))
|
||||
require.True(t, bundleMentionsEnvName(bundle, "DOC_ROOT"))
|
||||
require.False(t, bundleMentionsEnvName(bundle, "OPENAI_API_KEY"))
|
||||
}
|
||||
|
||||
func TestValidateEnvDeclarationsAppliesThreeLayers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
declared []declaredSkillEnv
|
||||
want types.SkillEnvVars
|
||||
}{
|
||||
{
|
||||
name: "a name the script reads survives",
|
||||
declared: []declaredSkillEnv{{Name: "TAVILY_API_KEY", Required: true}},
|
||||
want: types.SkillEnvVars{{Name: "TAVILY_API_KEY", Required: true}},
|
||||
},
|
||||
{
|
||||
name: "a name only SKILL.md mentions survives",
|
||||
declared: []declaredSkillEnv{{Name: "DOC_ROOT", Description: "corpus root"}},
|
||||
want: types.SkillEnvVars{{Name: "DOC_ROOT", Description: "corpus root"}},
|
||||
},
|
||||
{
|
||||
name: "a name that appears nowhere in the bundle is a hallucination",
|
||||
declared: []declaredSkillEnv{{Name: "OPENAI_API_KEY"}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "PATH is reserved even though the bundle mentions it",
|
||||
declared: []declaredSkillEnv{{Name: "PATH"}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "sandbox-injected WEKNORA_SKILL_* names are reserved",
|
||||
declared: []declaredSkillEnv{{Name: "WEKNORA_SKILL_DIR"}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "a lowercase name fails the format layer",
|
||||
declared: []declaredSkillEnv{{Name: "path_prefix"}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "a name with a space fails the format layer",
|
||||
declared: []declaredSkillEnv{{Name: "TAVILY API KEY"}},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "one rejected entry does not take its neighbours with it",
|
||||
declared: []declaredSkillEnv{
|
||||
{Name: "OPENAI_API_KEY"},
|
||||
{Name: "TAVILY_API_KEY", Required: true},
|
||||
{Name: "PATH"},
|
||||
{Name: "DOC_ROOT"},
|
||||
},
|
||||
want: types.SkillEnvVars{
|
||||
{Name: "TAVILY_API_KEY", Required: true},
|
||||
{Name: "DOC_ROOT"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a repeated name collapses onto the first declaration",
|
||||
declared: []declaredSkillEnv{
|
||||
{Name: "DOC_ROOT", Description: "first"},
|
||||
{Name: "DOC_ROOT", Description: "second", Required: true},
|
||||
},
|
||||
want: types.SkillEnvVars{{Name: "DOC_ROOT", Description: "first"}},
|
||||
},
|
||||
{
|
||||
name: "nothing declared yields nothing stored",
|
||||
declared: nil,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, validateEnvDeclarations(tc.declared, declareBundle()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnvDeclarationsTruncatesARunawayList(t *testing.T) {
|
||||
bundle := declareBundle()
|
||||
var declared []declaredSkillEnv
|
||||
for i := 0; i < maxSkillEnvDeclarations+5; i++ {
|
||||
name := fmt.Sprintf("VAR_%02d", i)
|
||||
bundle.Files["scripts/extract.py"] = append(
|
||||
bundle.Files["scripts/extract.py"], []byte("os.environ["+name+"]\n")...)
|
||||
declared = append(declared, declaredSkillEnv{Name: name})
|
||||
}
|
||||
|
||||
got := validateEnvDeclarations(declared, bundle)
|
||||
|
||||
require.Len(t, got, maxSkillEnvDeclarations)
|
||||
require.Equal(t, "VAR_00", got[0].Name)
|
||||
require.Equal(t, "VAR_19", got[maxSkillEnvDeclarations-1].Name)
|
||||
}
|
||||
|
||||
// A declaration is metadata. The prompt forbids values outright; parsing one
|
||||
// and dropping it here is what keeps a disobedient agent from writing a
|
||||
// credential of its own invention into the workspace-wide slot.
|
||||
func TestValidateEnvDeclarationsNeverCarriesAValue(t *testing.T) {
|
||||
got := validateEnvDeclarations([]declaredSkillEnv{
|
||||
{Name: "TAVILY_API_KEY", Value: "tvly-guessed-by-the-model"},
|
||||
}, declareBundle())
|
||||
|
||||
require.Equal(t, types.SkillEnvVars{{Name: "TAVILY_API_KEY"}}, got)
|
||||
}
|
||||
|
||||
// Official WeKnora skills (and the ClawHub weknora skill) authenticate with
|
||||
// WEKNORA_API_KEY / WEKNORA_BASE_URL. Those names must survive validation:
|
||||
// only the sandbox-injected WEKNORA_SKILL_* / WEKNORA_SESSION_INPUT_DIR
|
||||
// names are reserved.
|
||||
func TestValidateEnvDeclarationsAllowsWeknoraCredentialNames(t *testing.T) {
|
||||
bundle := &SkillBundle{
|
||||
Name: "weknora",
|
||||
Files: map[string][]byte{
|
||||
"SKILL.md": []byte(
|
||||
"export WEKNORA_BASE_URL WEKNORA_API_KEY WEKNORA_HOST WEKNORA_TOKEN WEKNORA_KB_ID; " +
|
||||
"do not override WEKNORA_SKILL_DIR or WEKNORA_SESSION_INPUT_DIR.\n",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
got := validateEnvDeclarations([]declaredSkillEnv{
|
||||
{Name: "WEKNORA_BASE_URL", Required: true, Description: "API origin"},
|
||||
{Name: "WEKNORA_API_KEY", Required: true},
|
||||
{Name: "WEKNORA_HOST"},
|
||||
{Name: "WEKNORA_SKILL_DIR"},
|
||||
{Name: "WEKNORA_SESSION_INPUT_DIR"},
|
||||
}, bundle)
|
||||
|
||||
require.Equal(t, types.SkillEnvVars{
|
||||
{Name: "WEKNORA_BASE_URL", Description: "API origin", Required: true},
|
||||
{Name: "WEKNORA_API_KEY", Required: true},
|
||||
{Name: "WEKNORA_HOST"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestMergeEnvDeclarationKeepsTheAdminValueByName(t *testing.T) {
|
||||
previous := types.SkillEnvVars{
|
||||
{Name: "TAVILY_API_KEY", Description: "old text", Required: false, Value: "tvly-real"},
|
||||
{Name: "GONE_IN_THE_NEW_VERSION", Value: "orphan"},
|
||||
}
|
||||
declared := types.SkillEnvVars{
|
||||
{Name: "TAVILY_API_KEY", Description: "new text", Required: true},
|
||||
{Name: "DOC_ROOT"},
|
||||
}
|
||||
|
||||
got := mergeEnvDeclaration(previous, declared)
|
||||
|
||||
require.Equal(t, types.SkillEnvVars{
|
||||
{Name: "TAVILY_API_KEY", Description: "new text", Required: true, Value: "tvly-real"},
|
||||
{Name: "DOC_ROOT"},
|
||||
}, got, "metadata follows the new declaration; the value an admin typed survives it")
|
||||
}
|
||||
|
||||
func TestMergeEnvDeclarationDropsWhatTheNewVersionNoLongerReads(t *testing.T) {
|
||||
got := mergeEnvDeclaration(
|
||||
types.SkillEnvVars{{Name: "GONE", Value: "orphan"}},
|
||||
types.SkillEnvVars{{Name: "DOC_ROOT"}},
|
||||
)
|
||||
|
||||
require.Equal(t, types.SkillEnvVars{{Name: "DOC_ROOT"}}, got,
|
||||
"asking a user forever for a variable nothing reads is worse than losing it")
|
||||
}
|
||||
|
||||
func TestMergeEnvDeclarationWithoutADeclarationStoresNothing(t *testing.T) {
|
||||
require.Nil(t, mergeEnvDeclaration(types.SkillEnvVars{{Name: "OLD", Value: "v"}}, nil))
|
||||
}
|
||||
|
||||
func TestValidateUserEnvNameAcceptsAndRejects(t *testing.T) {
|
||||
require.NoError(t, validateUserEnvName("TAVILY_API_KEY"))
|
||||
require.NoError(t, validateUserEnvName("_PRIVATE"))
|
||||
|
||||
require.Error(t, validateUserEnvName(""))
|
||||
require.Error(t, validateUserEnvName("path_prefix"))
|
||||
require.Error(t, validateUserEnvName("HAS SPACE"))
|
||||
require.Error(t, validateUserEnvName("1LEADING_DIGIT"))
|
||||
require.Error(t, validateUserEnvName("PATH"))
|
||||
require.Error(t, validateUserEnvName("LD_PRELOAD"))
|
||||
require.Error(t, validateUserEnvName("WEKNORA_SKILL_DIR"))
|
||||
require.Error(t, validateUserEnvName("WEKNORA_SESSION_INPUT_DIR"))
|
||||
require.NoError(t, validateUserEnvName("WEKNORA_API_KEY"))
|
||||
require.NoError(t, validateUserEnvName("WEKNORA_BASE_URL"))
|
||||
}
|
||||
|
||||
// The user path deliberately skips the bundle match: matching exists to catch
|
||||
// an LLM's invention, and a user filling a form is not guessing.
|
||||
func TestValidateUserEnvNameDoesNotRequireTheNameToBeInAnyBundle(t *testing.T) {
|
||||
require.NoError(t, validateUserEnvName("SOMETHING_NO_SKILL_MENTIONS"))
|
||||
}
|
||||
Reference in New Issue
Block a user