mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54e468763 | ||
|
|
553a9ac638 | ||
|
|
7a98373cfd | ||
|
|
acce5cea2b | ||
|
|
2c8359fdb1 | ||
|
|
07d272727d |
+1
-1
@@ -57,4 +57,4 @@ Thumbs.db
|
||||
# bench benchmark related (local artifacts, not committed)
|
||||
/bench/
|
||||
/Dockerfile.bench
|
||||
/Dockerfile.bench.v2
|
||||
/Dockerfile.bench.v2backups/
|
||||
|
||||
@@ -148,6 +148,29 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
`scripts/backup.sh` dumps the Postgres database (and optionally the data
|
||||
directory) with timestamped output and rotation:
|
||||
|
||||
```bash
|
||||
scripts/backup.sh --with-data # db dump + data/ archive, keep 14
|
||||
scripts/backup.sh --keep 7 # db only, keep 7
|
||||
```
|
||||
|
||||
Restore a dump into a running stack:
|
||||
|
||||
```bash
|
||||
gunzip -c backups/artex-db-<timestamp>.sql.gz | docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
```
|
||||
|
||||
Daily cron example (03:20, operator crontab):
|
||||
|
||||
```
|
||||
20 3 * * * cd /path/to/ARTEX && scripts/backup.sh --with-data >> backups/backup.log 2>&1
|
||||
```
|
||||
|
||||
## Updating and upgrading
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (jwt.key / SQLite, etc.), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup the ARTEX database (and optionally the data directory) with rotation.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/backup.sh [--with-data] [--keep N] [--output-dir DIR]
|
||||
#
|
||||
# --with-data also archive the bind-mounted data/ directory (worker
|
||||
# artifacts, evidence, skills state) as a tar.gz
|
||||
# --keep N number of db dumps to keep (default 14; data archives rotate
|
||||
# to the same count)
|
||||
# --output-dir where to write (default backups/, relative to the repo root)
|
||||
#
|
||||
# The database dump runs inside the postgres compose service, so no psql client
|
||||
# is needed on the host. Restore:
|
||||
# gunzip -c backups/artex-db-<ts>.sql.gz | docker compose exec -T postgres \
|
||||
# psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
usage() { sed -n '2,15p' "$0" | sed 's/^# \?//'; }
|
||||
|
||||
KEEP=14
|
||||
OUTPUT_DIR="backups"
|
||||
WITH_DATA=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--keep) KEEP="${2:?}"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="${2:?}"; shift 2 ;;
|
||||
--with-data) WITH_DATA=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$KEEP" in (*[!0-9]*|'') echo "--keep must be a positive integer" >&2; exit 2 ;; esac
|
||||
|
||||
# Compose reads .env for credentials; source it for the dump command too.
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
PGUSER_NAME="${POSTGRES_USER:-artex}"
|
||||
PGDATABASE_NAME="${POSTGRES_DB:-artex}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
DB_OUT="$OUTPUT_DIR/artex-db-$TS.sql.gz"
|
||||
if [ -e "$DB_OUT" ]; then
|
||||
echo "refusing to overwrite $DB_OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose exec -T postgres pg_dump -U "$PGUSER_NAME" "$PGDATABASE_NAME" | gzip > "$DB_OUT"; then
|
||||
rm -f "$DB_OUT"
|
||||
echo "pg_dump failed (is the stack running?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
gzip -t "$DB_OUT"
|
||||
echo "wrote $DB_OUT ($(du -h "$DB_OUT" | cut -f1))"
|
||||
|
||||
if [ "$WITH_DATA" -eq 1 ]; then
|
||||
DATA_OUT="$OUTPUT_DIR/artex-data-$TS.tar.gz"
|
||||
# data/ contains files written by the container (root); unreadable ones make
|
||||
# tar exit 2. Fail loudly rather than ship a partial archive.
|
||||
if ! tar -czf "$DATA_OUT" data 2>"$OUTPUT_DIR/.tar.err"; then
|
||||
rm -f "$DATA_OUT"
|
||||
echo "data archive failed (run as root so container-written files are readable):" >&2
|
||||
head -5 "$OUTPUT_DIR/.tar.err" >&2
|
||||
rm -f "$OUTPUT_DIR/.tar.err"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$OUTPUT_DIR/.tar.err"
|
||||
echo "wrote $DATA_OUT ($(du -h "$DATA_OUT" | cut -f1))"
|
||||
fi
|
||||
|
||||
prune() {
|
||||
ls -1t "$OUTPUT_DIR"/$1 2>/dev/null | tail -n +$((KEEP + 1)) | while IFS= read -r old; do
|
||||
echo "pruning $old"
|
||||
rm -f "$old"
|
||||
done
|
||||
}
|
||||
prune 'artex-db-*.sql.gz'
|
||||
[ "$WITH_DATA" -eq 1 ] && prune 'artex-data-*.tar.gz'
|
||||
echo "backup complete ($KEEP newest kept)"
|
||||
@@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const askOperatorMaxImage = 10 << 20
|
||||
|
||||
var askOperatorImageExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true}
|
||||
|
||||
// toolAskOperator lets a worker formally request operator input (captcha, OTP,
|
||||
// 2FA, a decision): the question and an optional screenshot land in the intent's
|
||||
// transcript as a user-role turn with an attachment card, and the intent pauses.
|
||||
// The operator replies through the existing message-to-worker flow; the reply
|
||||
// resumes the run with the answer as the next input (#41).
|
||||
func (s *Server) toolAskOperator() actool.CoreTool {
|
||||
return wrTool("ask_operator",
|
||||
"Ask the human operator for input you cannot obtain yourself (a captcha solution, an email/SMS OTP code, a 2FA answer, a decision). The question appears in this intent's chat for the operator, optionally with a screenshot, and this intent then pauses; the operator's reply resumes this run with the answer as your next input. Ask precisely, and only when you are genuinely blocked.",
|
||||
map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What you need from the operator, precisely (e.g. 'enter the 6-digit code sent to x@y', 'solve the captcha in the attached screenshot').",
|
||||
},
|
||||
"image_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional path to a screenshot (png/jpg/jpeg/webp/gif, max 10MB), e.g. the browser_take_screenshot output. It is copied into the task uploads and shown to the operator.",
|
||||
},
|
||||
},
|
||||
"required": []any{"question"},
|
||||
},
|
||||
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var req struct {
|
||||
Question string `json:"question"`
|
||||
ImagePath string `json:"image_path"`
|
||||
}
|
||||
_ = json.Unmarshal(in, &req)
|
||||
q := strings.TrimSpace(req.Question)
|
||||
if q == "" {
|
||||
return actool.Errorf("Error: question is required"), nil
|
||||
}
|
||||
ri := agent.RunInfoFrom(ctx)
|
||||
if ri.TaskID == 0 || ri.IntentID == 0 {
|
||||
return actool.Errorf("Error: ask_operator can only be used inside a worker intent run"), nil
|
||||
}
|
||||
taskID := fmt.Sprintf("%d", ri.TaskID)
|
||||
t := s.m.ResolveTask(taskID)
|
||||
if t == nil {
|
||||
return actool.Errorf("Error: task not found"), nil
|
||||
}
|
||||
att, err := stageAskOperatorImage(s.m.dir, taskID, req.ImagePath)
|
||||
if err != nil {
|
||||
return actool.Errorf("Error: " + err.Error()), nil
|
||||
}
|
||||
text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)"
|
||||
act := userActivityWithAttachments("worker", text, att)
|
||||
node := ri.IntentID
|
||||
act.NodeID = &node
|
||||
s.engine.emitActivity(t, act)
|
||||
// The control call runs on a detached context: pausing may cancel the
|
||||
// run's context, and the pause bookkeeping itself must complete.
|
||||
if _, err := s.applyIntentControl(context.Background(), t, ri.IntentID, "pause", firstLine(q, 120), ""); err != nil {
|
||||
return actool.Errorf("Error: question delivered but pausing the intent failed: " + err.Error()), nil
|
||||
}
|
||||
return actool.Text("Question delivered to the operator. This intent is pausing now: finish your current step, record everything you already know as facts, and stop. The operator's reply will resume this run and arrive as your next input."), nil
|
||||
})
|
||||
}
|
||||
|
||||
// stageAskOperatorImage copies the referenced image into the task's uploads dir
|
||||
// (the same place chat uploads land, so the transcript attachment card resolves)
|
||||
// and returns the attachment descriptor. Empty src returns no attachment.
|
||||
func stageAskOperatorImage(dataDir, taskID, src string) ([]chatAttachment, error) {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(src))
|
||||
if !askOperatorImageExt[ext] {
|
||||
return nil, fmt.Errorf("image_path must be a png/jpg/jpeg/webp/gif file, got %q", filepath.Base(src))
|
||||
}
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image not readable: %v", err)
|
||||
}
|
||||
if st.Size() > askOperatorMaxImage {
|
||||
return nil, fmt.Errorf("image too large (%s, max 10MB)", humanBytes(st.Size()))
|
||||
}
|
||||
dir := filepath.Join(dataDir, "tasks", taskID, "uploads")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := fmt.Sprintf("ask-operator-%d%s", time.Now().Unix(), ext)
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []chatAttachment{{Name: name, Path: "uploads/" + name, Size: st.Size()}}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStageAskOperatorImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "shot.png")
|
||||
if err := os.WriteFile(src, []byte("fakepng"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// no image -> no attachment
|
||||
att, err := stageAskOperatorImage(dir, "7", "")
|
||||
if err != nil || att != nil {
|
||||
t.Fatalf("empty src: att=%v err=%v", att, err)
|
||||
}
|
||||
|
||||
// valid image -> copied under the task uploads, descriptor matches the chat format
|
||||
att, err = stageAskOperatorImage(dir, "7", src)
|
||||
if err != nil || len(att) != 1 {
|
||||
t.Fatalf("valid src: att=%v err=%v", att, err)
|
||||
}
|
||||
if !strings.HasPrefix(att[0].Path, "uploads/ask-operator-") || !strings.HasSuffix(att[0].Path, ".png") {
|
||||
t.Fatalf("unexpected attachment path %q", att[0].Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "tasks", "7", "uploads", att[0].Name)); err != nil {
|
||||
t.Fatalf("copy missing: %v", err)
|
||||
}
|
||||
|
||||
// wrong extension rejected
|
||||
bad := filepath.Join(dir, "payload.exe")
|
||||
if err := os.WriteFile(bad, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stageAskOperatorImage(dir, "7", bad); err == nil || !strings.Contains(err.Error(), "png/jpg") {
|
||||
t.Fatalf("expected extension rejection, got %v", err)
|
||||
}
|
||||
|
||||
// missing file rejected
|
||||
if _, err := stageAskOperatorImage(dir, "7", filepath.Join(dir, "nope.png")); err == nil {
|
||||
t.Fatal("expected missing-file rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// The emitted activity must carry the {text, attachments} Detail shape the
|
||||
// transcript parser expects, so the operator sees the question with the
|
||||
// attachment card for the screenshot.
|
||||
func TestAskOperatorActivityShape(t *testing.T) {
|
||||
att := []chatAttachment{{Name: "ask-operator-1.png", Path: "uploads/ask-operator-1.png", Size: 7}}
|
||||
a := userActivityWithAttachments("worker", "OPERATOR INPUT REQUESTED: solve the captcha", att)
|
||||
if a.Kind != "user" || a.Worker != "worker" {
|
||||
t.Fatalf("unexpected activity kind/worker: %+v", a)
|
||||
}
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
Attachments []chatAttachment `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(a.Detail), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(parsed.Text, "OPERATOR INPUT REQUESTED") || len(parsed.Attachments) != 1 || parsed.Attachments[0].Path != att[0].Path {
|
||||
t.Fatalf("unexpected detail payload: %+v", parsed)
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,10 @@ func (s *Server) seedOrchestrationTools() {
|
||||
// health visibility is for the executors too, not just the platform agent
|
||||
bindings = json.RawMessage(`["auto","worker","pentest"]`)
|
||||
}
|
||||
if t.Name() == "ask_operator" {
|
||||
// operator input requests are an executor concern (captcha/OTP walls)
|
||||
bindings = json.RawMessage(`["worker","pentest"]`)
|
||||
}
|
||||
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
||||
}
|
||||
s.refreshBuiltinToolSchemas()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
func (s *Server) platformTools() []actool.CoreTool {
|
||||
return []actool.CoreTool{
|
||||
s.toolMCPStatus(),
|
||||
s.toolAskOperator(),
|
||||
s.toolCreateSkill(),
|
||||
s.toolUpdateSkillFile(),
|
||||
s.toolCreateCustomTool(),
|
||||
|
||||
+4
-3
@@ -219,9 +219,10 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
}
|
||||
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // wire visible skills/MCP + traffic/orchestration host tools into the agent toolset
|
||||
domainReg := buildDomainReg(m.Assets())
|
||||
wireTools(m.pg, domainReg) // built-in tools table: filter per agent + override description/schema + inject defaults
|
||||
seedPrompts(m.pg) // seed built-in agent default prompt bodies into agent_prompts (only when empty)
|
||||
s.seedOrchestrationTools() // seed P2 cross-task orchestration tools into the tools table (bindable per agent)
|
||||
wireTools(m.pg, domainReg) // built-in tools table: filter per agent + override description/schema + inject defaults
|
||||
seedPrompts(m.pg) // seed built-in agent default prompt bodies into agent_prompts (only when empty)
|
||||
warnBrokenSkills(s.skillDir) // surface skills whose frontmatter silently failed to parse
|
||||
s.seedOrchestrationTools() // seed P2 cross-task orchestration tools into the tools table (bindable per agent)
|
||||
if err := s.seedFindingRetester(); err != nil {
|
||||
log.Printf("[retester] seed: %v", err)
|
||||
}
|
||||
|
||||
+72
-8
@@ -1108,17 +1108,21 @@ func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
// Markdown body: step-by-step instructions
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
fmt.Fprintf(&sb, "name: %s\n", body.Name)
|
||||
fmt.Fprintf(&sb, "description: %s\n", body.Description)
|
||||
fmt.Fprintf(&sb, "name: %s\n", yamlScalar(body.Name))
|
||||
fmt.Fprintf(&sb, "description: %s\n", yamlScalar(body.Description))
|
||||
if body.License != "" {
|
||||
fmt.Fprintf(&sb, "license: %s\n", body.License)
|
||||
fmt.Fprintf(&sb, "license: %s\n", yamlScalar(body.License))
|
||||
}
|
||||
if body.Compatibility != "" {
|
||||
fmt.Fprintf(&sb, "compatibility: %s\n", body.Compatibility)
|
||||
fmt.Fprintf(&sb, "compatibility: %s\n", yamlScalar(body.Compatibility))
|
||||
}
|
||||
// mcps: MCP servers this skill unlocks on load (skill-gated deferred tools).
|
||||
if mcps := cleanStrs(body.MCPs); len(mcps) > 0 {
|
||||
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(mcps, ", "))
|
||||
quoted := make([]string, len(mcps))
|
||||
for i, m := range mcps {
|
||||
quoted[i] = yamlScalar(m)
|
||||
}
|
||||
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(quoted, ", "))
|
||||
}
|
||||
sb.WriteString("---\n")
|
||||
if strings.TrimSpace(body.Instructions) != "" {
|
||||
@@ -1134,6 +1138,14 @@ func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Verify with the real parser: a description containing ": " (or any value that
|
||||
// is not plain-safe YAML) used to break the whole frontmatter silently, leaving
|
||||
// a skill whose fields were all empty and whose name came from the directory.
|
||||
if err := verifySkillFile(skillPath, body.Name, cleanStrs(body.MCPs)); err != nil {
|
||||
_ = os.RemoveAll(skillPath)
|
||||
writeErr(w, 400, "generated SKILL.md does not round-trip through the skill parser: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, map[string]any{"name": body.Name})
|
||||
}
|
||||
|
||||
@@ -1176,6 +1188,54 @@ func (s *Server) fsUpdateSkillMeta(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// yamlScalar renders one frontmatter scalar so the emitted YAML is valid by
|
||||
// construction: plain-safe tokens stay bare, everything else is single-quoted
|
||||
// with internal quotes doubled (YAML single-quote escaping).
|
||||
func yamlScalar(s string) string {
|
||||
if s != "" && !strings.ContainsAny(s, ":#{}[]&*!|>'\"%@`,\n") && !strings.HasPrefix(s, " ") && !strings.HasSuffix(s, " ") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// verifySkillFile re-loads a written SKILL.md with the real skill parser and
|
||||
// asserts the name and the mcps list survived the round trip.
|
||||
func verifySkillFile(skillPath, wantName string, wantMCPs []string) error {
|
||||
reg, err := skill.LoadDir(skillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load: %w", err)
|
||||
}
|
||||
for _, s := range reg.List() {
|
||||
if s.Dir == "" {
|
||||
continue
|
||||
}
|
||||
if s.Name != wantName {
|
||||
return fmt.Errorf("parsed name %q != written %q (frontmatter did not parse; the name fell back to the directory)", s.Name, wantName)
|
||||
}
|
||||
if len(wantMCPs) > 0 && len(s.MCPs) != len(wantMCPs) {
|
||||
return fmt.Errorf("parsed mcps %v != written %v", s.MCPs, wantMCPs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no skill loaded from %s", skillPath)
|
||||
}
|
||||
|
||||
// warnBrokenSkills logs skills whose parsed name does not match their directory
|
||||
// name: the signature of frontmatter that failed to parse and silently fell back
|
||||
// (fields empty, mcps gate lost). Best-effort, startup only.
|
||||
func warnBrokenSkills(dir string) {
|
||||
reg, err := skill.LoadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, s := range reg.List() {
|
||||
if s.Dir == "" || s.Name == filepath.Base(s.Dir) {
|
||||
continue
|
||||
}
|
||||
log.Printf("[skill] %s: frontmatter did not parse (loaded under the directory name; description and mcps are empty) - fix the YAML in %s", s.Name, filepath.Join(s.Dir, "SKILL.md"))
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteSkillFrontmatter parses the SKILL.md YAML frontmatter and replaces the
|
||||
// fields given as non-nil pointers. The instruction body (after the closing ---) is
|
||||
// preserved verbatim. Returns an error if the file has no recognisable frontmatter.
|
||||
@@ -1211,11 +1271,11 @@ func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, licens
|
||||
}
|
||||
for i, p := range pairs {
|
||||
if p.k == key {
|
||||
pairs[i].v = strings.TrimSpace(*val)
|
||||
pairs[i].v = yamlScalar(strings.TrimSpace(*val))
|
||||
return
|
||||
}
|
||||
}
|
||||
pairs = append(pairs, kv{key, strings.TrimSpace(*val)})
|
||||
pairs = append(pairs, kv{key, yamlScalar(strings.TrimSpace(*val))})
|
||||
}
|
||||
applyStr("description", description)
|
||||
applyStr("license", license)
|
||||
@@ -1231,7 +1291,11 @@ func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, licens
|
||||
}
|
||||
pairs = filtered
|
||||
if len(cleaned) > 0 {
|
||||
pairs = append(pairs, kv{"mcps", strings.Join(cleaned, ", ")})
|
||||
quoted := make([]string, len(cleaned))
|
||||
for i, m := range cleaned {
|
||||
quoted[i] = yamlScalar(m)
|
||||
}
|
||||
pairs = append(pairs, kv{"mcps", strings.Join(quoted, ", ")})
|
||||
}
|
||||
}
|
||||
// Reconstruct
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
)
|
||||
|
||||
func TestYamlScalar(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"rea", "rea"},
|
||||
{"api-recon", "api-recon"},
|
||||
{"plain value with spaces", "plain value with spaces"},
|
||||
{"target: embedded", "'target: embedded'"},
|
||||
{"it's quoted", "'it''s quoted'"},
|
||||
{"ends #now", "'ends #now'"},
|
||||
{"comma, separated", "'comma, separated'"},
|
||||
{" leading", "' leading'"},
|
||||
{"", "''"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yamlScalar(c.in); got != c.want {
|
||||
t.Errorf("yamlScalar(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SKILL.md written the way fsCreateSkill now writes it (yamlScalar values)
|
||||
// must round-trip through the real parser with name and mcps intact, even with
|
||||
// a colon+space and quotes in the description. The pre-fix format (raw
|
||||
// interpolation) fails exactly there and falls back to the directory name.
|
||||
func TestSkillFrontmatterRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
skillDir := filepath.Join(dir, "colon-skill")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
desc := "Analyze target: embedded secrets, it's binary"
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s\nmcps: %s\n---\nbody instructions\n",
|
||||
yamlScalar("colon-skill"), yamlScalar(desc), yamlScalar("rea"))
|
||||
|
||||
skillMD := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillMD, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifySkillFile(skillDir, "colon-skill", []string{"rea"}); err != nil {
|
||||
t.Fatalf("safe format failed verification: %v", err)
|
||||
}
|
||||
reg, err := skill.LoadDir(skillDir)
|
||||
if err != nil || len(reg.List()) != 1 {
|
||||
t.Fatalf("LoadDir: %v (%d skills)", err, len(reg.List()))
|
||||
}
|
||||
s := reg.List()[0]
|
||||
if s.Name != "colon-skill" || s.Description != desc || len(s.MCPs) != 1 || s.MCPs[0] != "rea" {
|
||||
t.Fatalf("round-trip mismatch: name=%q desc=%q mcps=%v", s.Name, s.Description, s.MCPs)
|
||||
}
|
||||
|
||||
// the old, unsafe emission: unquoted colon+space breaks the whole frontmatter
|
||||
unsafe := fmt.Sprintf("---\nname: %s\ndescription: %s\nmcps: rea\n---\nbody\n", "colon-skill", desc)
|
||||
if err := os.WriteFile(skillMD, []byte(unsafe), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifySkillFile(skillDir, "colon-skill", []string{"rea"}); err == nil {
|
||||
t.Fatal("unsafe format unexpectedly verified")
|
||||
} else if !strings.Contains(err.Error(), "fell back") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
|
||||
## name: scopesentry-mcp
|
||||
name: scopesentry-mcp
|
||||
description: Manage the security scanning platform (projects, tasks, templates, assets, nodes) through the ScopeSentry MCP. Use when the user mentions ScopeSentry, MCP, API Key, scan tasks, or asset queries.
|
||||
|
||||
# ScopeSentry MCP usage guide
|
||||
|
||||
Reference in New Issue
Block a user