From abb1bed4243a00e210441e63fea672896568f18b Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 20 Aug 2026 20:05:40 +0800 Subject: [PATCH] H-337: add local Captain acceptance fixture (#63) * H-337: add local Captain acceptance fixture * fix(H-337): close acceptance fixture review gaps * fix(H-337): reject non-string skill metadata --------- Co-authored-by: Rogee --- deploy/quickstart/README.md | 42 +++ deploy/quickstart/captain-fixture/.gitignore | 1 + deploy/quickstart/captain-fixture/Dockerfile | 12 + deploy/quickstart/captain-fixture/go.mod | 3 + deploy/quickstart/captain-fixture/main.go | 291 ++++++++++++++++++ .../quickstart/captain-fixture/main_test.go | 96 ++++++ deploy/quickstart/compose.acceptance.yaml | 19 ++ .../scripts/acceptance_scripts_test.sh | 99 ++++++ .../scripts/preflight_acceptance.sh | 55 ++++ .../scripts/stage_captain_assets.sh | 115 +++++++ .../AssistantSystemSettingsForm.spec.js | 80 +++++ .../settings/AssistantSystemSettingsForm.vue | 24 +- 12 files changed, 818 insertions(+), 19 deletions(-) create mode 100644 deploy/quickstart/captain-fixture/.gitignore create mode 100644 deploy/quickstart/captain-fixture/Dockerfile create mode 100644 deploy/quickstart/captain-fixture/go.mod create mode 100644 deploy/quickstart/captain-fixture/main.go create mode 100644 deploy/quickstart/captain-fixture/main_test.go create mode 100644 deploy/quickstart/compose.acceptance.yaml create mode 100755 deploy/quickstart/scripts/acceptance_scripts_test.sh create mode 100755 deploy/quickstart/scripts/preflight_acceptance.sh create mode 100755 deploy/quickstart/scripts/stage_captain_assets.sh create mode 100644 frontend/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.spec.js diff --git a/deploy/quickstart/README.md b/deploy/quickstart/README.md index f42aad1a..09c31df1 100644 --- a/deploy/quickstart/README.md +++ b/deploy/quickstart/README.md @@ -61,6 +61,48 @@ Example settings value (the key remains a separate `.env` variable): {"chat":{"provider":"openai_compatible","base_url":"https://provider.example.com/v1","model":"gpt-5.6-luna"},"embedding":{"mode":"reuse_chat_credentials","provider":"openai_compatible","base_url":"https://provider.example.com/v1","model":"text-embedding-3-small","dimensions":1536},"generation":{"temperature":0.7,"max_tokens":2048},"request":{"timeout_seconds":60,"max_retries":2}} ``` +## Local Captain acceptance fixture + +The acceptance overlay uses a deterministic, OpenAI-compatible local service; +the configured key is a non-secret sentinel. The same service exposes the +container-reachable knowledge URL `http://captain-fixture:8080/knowledge`. + +```bash +docker compose -f compose.yaml -f compose.acceptance.yaml up -d --build +GANBING_SOURCE=/path/to/read-only-assets ./scripts/preflight_acceptance.sh +``` + +Fixture scenarios are selected by the UI message text: + +- normal text: successful chat +- `[fixture:fail]`: persistent HTTP 503 +- `[fixture:retry] unique-id`: one HTTP 503 followed by success on provider retry +- `[fixture:embedding-fail]` and `[fixture:embedding-retry] unique-id`: matching embedding paths +- `[fixture:skill]`: activate the first bound Skill and read its first reference + +For the disabled path, clear the Copilot provider in SuperAdmin and confirm the +Playground unavailable response. Restore by saving the same fixture URL/model +and sentinel key shown in `compose.acceptance.yaml`; a normal message must then +succeed. Use a new suffix for each retry scenario, since retries are counted by +the exact request body. The preflight rejects persisted database settings that +would silently override the fixture. + +To prepare the authorized SKILL/SOUL assets without touching their source: + +```bash +GANBING_SOURCE=/path/to/read-only-assets \ + ./scripts/stage_captain_assets.sh /private/path/gochat-captain-stage +``` + +In Captain UI, create `ganbing-local-acceptance`, paste +`captain-skill/instructions.md`, add only files under +`captain-skill/references/` using their filename stem as `reference_key`, then +publish and add it to the test assistant. Paste `assistant-instructions.md` +into the assistant's Additional instructions field. Files under `quarantine/` +exceed the current 8 KB per-request Skill runtime budget when combined with the +Skill instructions; do not import them without a content-owner-approved split. +The staging script never truncates or rewrites source content. + ## Useful Commands ```bash diff --git a/deploy/quickstart/captain-fixture/.gitignore b/deploy/quickstart/captain-fixture/.gitignore new file mode 100644 index 00000000..6b8914e5 --- /dev/null +++ b/deploy/quickstart/captain-fixture/.gitignore @@ -0,0 +1 @@ +/captain-fixture diff --git a/deploy/quickstart/captain-fixture/Dockerfile b/deploy/quickstart/captain-fixture/Dockerfile new file mode 100644 index 00000000..fd4743fe --- /dev/null +++ b/deploy/quickstart/captain-fixture/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25.13-alpine AS build + +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /captain-fixture . + +FROM alpine:3.21 +RUN addgroup -S fixture && adduser -S fixture -G fixture +COPY --from=build /captain-fixture /usr/local/bin/captain-fixture +USER fixture +EXPOSE 8080 +ENTRYPOINT ["captain-fixture"] diff --git a/deploy/quickstart/captain-fixture/go.mod b/deploy/quickstart/captain-fixture/go.mod new file mode 100644 index 00000000..8e565bda --- /dev/null +++ b/deploy/quickstart/captain-fixture/go.mod @@ -0,0 +1,3 @@ +module gochat.local/captain-fixture + +go 1.24.0 diff --git a/deploy/quickstart/captain-fixture/main.go b/deploy/quickstart/captain-fixture/main.go new file mode 100644 index 00000000..4df861ab --- /dev/null +++ b/deploy/quickstart/captain-fixture/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" +) + +const ( + listenAddr = ":8080" + maxRequestBody = 1 << 20 + knowledgeHTML = `GoChat Local Acceptance Knowledge

Local acceptance policy

The local fixture warranty window is 37 days. Reference marker: KBASE-LOCAL-2026.

` +) + +type fixtureServer struct { + mu sync.Mutex + attempts map[[32]byte]int +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Tools []toolDefinition `json:"tools"` + Stream bool `json:"stream"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + Name string `json:"name"` + ToolCallID string `json:"tool_call_id"` + ToolCalls []toolCall `json:"tool_calls"` +} + +type toolDefinition struct { + Function struct { + Name string `json:"name"` + } `json:"function"` +} + +type toolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type embeddingRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +func main() { + logger := log.New(os.Stdout, "captain-fixture ", log.LstdFlags) + server := &http.Server{Addr: listenAddr, Handler: newFixtureServer(logger), ReadHeaderTimeout: 5 * time.Second} + logger.Printf("listening addr=%s", listenAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal(err) + } +} + +func newFixtureServer(logger *log.Logger) http.Handler { + s := &fixtureServer{attempts: make(map[[32]byte]int)} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("GET /knowledge", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, knowledgeHTML) + }) + mux.HandleFunc("POST /v1/chat/completions", s.chat) + mux.HandleFunc("POST /v1/embeddings", s.embeddings) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + status := &statusWriter{ResponseWriter: w, status: http.StatusOK} + mux.ServeHTTP(status, r) + logger.Printf("method=%s path=%s status=%d", r.Method, r.URL.Path, status.status) + }) +} + +func (s *fixtureServer) chat(w http.ResponseWriter, r *http.Request) { + body, ok := readBody(w, r) + if !ok { + return + } + var req chatRequest + if err := json.Unmarshal(body, &req); err != nil || len(req.Messages) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request") + return + } + latest := latestUserContent(req.Messages) + if strings.Contains(latest, "[fixture:fail]") { + writeError(w, http.StatusServiceUnavailable, "fixture_failure") + return + } + if strings.Contains(latest, "[fixture:retry]") && s.firstAttempt(body) { + writeError(w, http.StatusServiceUnavailable, "fixture_retry_once") + return + } + + message, finishReason := fixtureChatMessage(req) + if req.Stream { + writeStream(w, req.Model, message.Content) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": "chatcmpl-fixture", "object": "chat.completion", "created": 1, "model": req.Model, + "choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}}, + "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) +} + +func (s *fixtureServer) embeddings(w http.ResponseWriter, r *http.Request) { + body, ok := readBody(w, r) + if !ok { + return + } + var req embeddingRequest + if err := json.Unmarshal(body, &req); err != nil || len(req.Input) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request") + return + } + content := strings.Join(req.Input, "\n") + if strings.Contains(content, "[fixture:embedding-fail]") { + writeError(w, http.StatusServiceUnavailable, "fixture_embedding_failure") + return + } + if strings.Contains(content, "[fixture:embedding-retry]") && s.firstAttempt(body) { + writeError(w, http.StatusServiceUnavailable, "fixture_embedding_retry_once") + return + } + vector := make([]float64, 1536) + for i := range vector { + vector[i] = 0.01 + } + data := make([]map[string]any, len(req.Input)) + for i := range req.Input { + data[i] = map[string]any{"object": "embedding", "index": i, "embedding": vector} + } + writeJSON(w, http.StatusOK, map[string]any{ + "object": "list", "data": data, "model": req.Model, + "usage": map[string]int{"prompt_tokens": len(req.Input), "total_tokens": len(req.Input)}, + }) +} + +func (s *fixtureServer) firstAttempt(body []byte) bool { + key := sha256.Sum256(body) + s.mu.Lock() + defer s.mu.Unlock() + s.attempts[key]++ + return s.attempts[key] == 1 +} + +func fixtureChatMessage(req chatRequest) (chatMessage, string) { + all := allMessageContent(req.Messages) + if strings.Contains(all, "Extract factual, self-contained FAQs") { + return chatMessage{Role: "assistant", Content: `{"faqs":[{"question":"What is the local fixture warranty window?","answer":"The local fixture warranty window is 37 days."}]}`}, "stop" + } + if last := req.Messages[len(req.Messages)-1]; last.Role == "tool" { + if last.Name == "activate_skill" { + if key := firstJSONListValue(last.Content, "reference_keys"); key != "" { + return toolMessage("fixture-read", "read_skill_reference", map[string]string{"skill_name": firstCatalogSkill(all), "reference_key": key}), "tool_calls" + } + } + return chatMessage{Role: "assistant", Content: "Fixture skill path completed."}, "stop" + } + if strings.Contains(latestUserContent(req.Messages), "[fixture:skill]") && hasTool(req.Tools, "activate_skill") { + if name := firstCatalogSkill(all); name != "" { + return toolMessage("fixture-activate", "activate_skill", map[string]string{"skill_name": name}), "tool_calls" + } + } + if strings.Contains(all, "Knowledge Base Context:") { + return chatMessage{Role: "assistant", Content: "The local fixture warranty window is 37 days [FAQ 1]."}, "stop" + } + return chatMessage{Role: "assistant", Content: "Fixture chat response."}, "stop" +} + +func toolMessage(id, name string, args map[string]string) chatMessage { + raw, _ := json.Marshal(args) + call := toolCall{ID: id, Type: "function"} + call.Function.Name, call.Function.Arguments = name, string(raw) + return chatMessage{Role: "assistant", ToolCalls: []toolCall{call}} +} + +func firstCatalogSkill(content string) string { + const start, end = "", "" + i := strings.Index(content, start) + if i < 0 { + return "" + } + j := strings.Index(content[i+len(start):], end) + if j < 0 { + return "" + } + var catalog []struct { + Name string `json:"name"` + } + if json.Unmarshal([]byte(content[i+len(start):i+len(start)+j]), &catalog) != nil || len(catalog) == 0 { + return "" + } + return catalog[0].Name +} + +func firstJSONListValue(content, key string) string { + var payload map[string]any + start, end := strings.Index(content, "{"), strings.LastIndex(content, "}") + if start < 0 || end < start || json.Unmarshal([]byte(content[start:end+1]), &payload) != nil { + return "" + } + values, _ := payload[key].([]any) + if len(values) == 0 { + return "" + } + value, _ := values[0].(string) + return value +} + +func hasTool(tools []toolDefinition, name string) bool { + for _, tool := range tools { + if tool.Function.Name == name { + return true + } + } + return false +} + +func allMessageContent(messages []chatMessage) string { + var b strings.Builder + for _, message := range messages { + b.WriteString(message.Content) + b.WriteByte('\n') + } + return b.String() +} + +func latestUserContent(messages []chatMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return messages[i].Content + } + } + return "" +} + +func readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody+1)) + if err != nil || len(body) == 0 || len(body) > maxRequestBody { + writeError(w, http.StatusBadRequest, "invalid_request") + return nil, false + } + return body, true +} + +func writeStream(w http.ResponseWriter, model, content string) { + w.Header().Set("Content-Type", "text/event-stream") + chunk := map[string]any{ + "id": "chatcmpl-fixture", "object": "chat.completion.chunk", "created": 1, "model": model, + "choices": []map[string]any{{"index": 0, "delta": map[string]string{"role": "assistant", "content": content}, "finish_reason": "stop"}}, + } + raw, _ := json.Marshal(chunk) + _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", raw) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"message": "local acceptance fixture error", "type": "fixture_error", "code": code}}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} diff --git a/deploy/quickstart/captain-fixture/main_test.go b/deploy/quickstart/captain-fixture/main_test.go new file mode 100644 index 00000000..bf1cd92a --- /dev/null +++ b/deploy/quickstart/captain-fixture/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAcceptanceFixtureContract(t *testing.T) { + server := httptest.NewServer(newFixtureServer(log.New(io.Discard, "", 0))) + t.Cleanup(server.Close) + + t.Run("knowledge", func(t *testing.T) { + resp, err := http.Get(server.URL + "/knowledge") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || !bytes.Contains(body, []byte("KBASE-LOCAL-2026")) { + t.Fatalf("knowledge status=%d marker=%t", resp.StatusCode, bytes.Contains(body, []byte("KBASE-LOCAL-2026"))) + } + }) + + t.Run("chat retry and faq", func(t *testing.T) { + fail := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:fail]"}]}` + if status := post(t, server.URL+"/v1/chat/completions", fail, nil); status != http.StatusServiceUnavailable { + t.Fatalf("failure status=%d", status) + } + recovered := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:fail]"},{"role":"user","content":"recovered"}]}` + if status := post(t, server.URL+"/v1/chat/completions", recovered, nil); status != http.StatusOK { + t.Fatalf("recovery status=%d", status) + } + retry := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:retry] one"}]}` + if status := post(t, server.URL+"/v1/chat/completions", retry, nil); status != http.StatusServiceUnavailable { + t.Fatalf("first retry status=%d", status) + } + if status := post(t, server.URL+"/v1/chat/completions", retry, nil); status != http.StatusOK { + t.Fatalf("second retry status=%d", status) + } + faq := `{"model":"gpt-5.6-luna","messages":[{"role":"system","content":"Extract factual, self-contained FAQs"}]}` + var response struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + } + if status := post(t, server.URL+"/v1/chat/completions", faq, &response); status != http.StatusOK || len(response.Choices) != 1 || !strings.Contains(response.Choices[0].Message.Content, `"faqs"`) { + t.Fatalf("faq status=%d choices=%d", status, len(response.Choices)) + } + }) + + t.Run("skill activation", func(t *testing.T) { + request := `{"model":"gpt-5.6-luna","messages":[{"role":"system","content":"[{\"name\":\"local-skill\"}]"},{"role":"user","content":"[fixture:skill]"}],"tools":[{"type":"function","function":{"name":"activate_skill"}}]}` + var response struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + } + status := post(t, server.URL+"/v1/chat/completions", request, &response) + if status != http.StatusOK || len(response.Choices) != 1 || len(response.Choices[0].Message.ToolCalls) != 1 || response.Choices[0].Message.ToolCalls[0].Function.Name != "activate_skill" { + t.Fatalf("status=%d choices=%d", status, len(response.Choices)) + } + }) + + t.Run("embedding dimensions", func(t *testing.T) { + var response struct { + Data []struct { + Embedding []float64 `json:"embedding"` + } `json:"data"` + } + status := post(t, server.URL+"/v1/embeddings", `{"model":"fixture-embedding","input":["hello"]}`, &response) + if status != http.StatusOK || len(response.Data) != 1 || len(response.Data[0].Embedding) != 1536 { + t.Fatalf("status=%d vectors=%d dimensions=%d", status, len(response.Data), len(response.Data[0].Embedding)) + } + }) +} + +func post(t *testing.T, url, body string, dst any) int { + t.Helper() + resp, err := http.Post(url, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if dst != nil { + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + t.Fatal(err) + } + } + return resp.StatusCode +} diff --git a/deploy/quickstart/compose.acceptance.yaml b/deploy/quickstart/compose.acceptance.yaml new file mode 100644 index 00000000..57d857c4 --- /dev/null +++ b/deploy/quickstart/compose.acceptance.yaml @@ -0,0 +1,19 @@ +services: + captain-fixture: + build: + context: captain-fixture + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"] + interval: 2s + timeout: 2s + retries: 20 + + gochat: + depends_on: + captain-fixture: + condition: service_healthy + environment: + GOCHAT_COPILOT_PROVIDER_CONFIG: '{"chat":{"provider":"openai_compatible","base_url":"http://captain-fixture:8080/v1","model":"gpt-5.6-luna"},"embedding":{"mode":"reuse_chat_credentials","provider":"openai_compatible","base_url":"http://captain-fixture:8080/v1","model":"fixture-embedding","dimensions":1536},"generation":{"temperature":0,"max_tokens":1024},"request":{"timeout_seconds":10,"max_retries":1}}' + GOCHAT_COPILOT_CHAT_API_KEY: acceptance-fixture-not-a-secret + GOCHAT_COPILOT_EMBEDDING_API_KEY: "" diff --git a/deploy/quickstart/scripts/acceptance_scripts_test.sh b/deploy/quickstart/scripts/acceptance_scripts_test.sh new file mode 100755 index 00000000..1bbef561 --- /dev/null +++ b/deploy/quickstart/scripts/acceptance_scripts_test.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d "${TMPDIR:-/tmp}/gochat-acceptance-scripts.XXXXXX") +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +fail() { + echo "acceptance scripts test failed: $1" >&2 + exit 1 +} + +make_source() { + mkdir -p "$1" + cat > "$1/SKILL.md" <<'EOF' +--- +name: fixture +description: Local fixture +--- +Use the local fixture. +EOF + printf '%s\n' 'Local assistant instructions.' > "$1/SOUL.md" +} + +expect_staging_failure() { + if GANBING_SOURCE=$1 "$root/scripts/stage_captain_assets.sh" "$tmp/output" > "$tmp/staging.log" 2>&1; then + fail "$2 was accepted" + fi + if grep -qx 'skill_status=active' "$tmp/output/manifest.txt" 2>/dev/null; then + fail "$2 generated an active manifest" + fi + rm -rf "$tmp/output" +} + +mkdir "$tmp/bin" +cat > "$tmp/bin/docker" <<'EOF' +#!/bin/sh +case " $* " in + *" config --quiet "*) ;; + *" ps --status running --services "*) + printf '%s\n' captain-fixture gochat + ;; + *" exec -T postgres "*) + if [ -n "${MOCK_PROVIDER_CONFIG:-}" ]; then printf '%s\n' 1; else printf '%s\n' 0; fi + ;; + *" exec -T gochat curl "*|*" exec -T gochat sh "*) ;; + *) echo "unexpected docker invocation" >&2; exit 2 ;; +esac +EOF +chmod 700 "$tmp/bin/docker" + +make_source "$tmp/clean" +clean_before=$(find "$tmp/clean" -type f -exec sha256sum {} +) +GANBING_SOURCE=$tmp/clean PATH=$tmp/bin:$PATH "$root/scripts/preflight_acceptance.sh" > "$tmp/preflight-clean.log" 2>&1 || fail "clean preflight failed" +[ "$clean_before" = "$(find "$tmp/clean" -type f -exec sha256sum {} +)" ] || fail "staging modified its source" + +provider_config='{"base_url":"http://captain-fixture:8080/v1","api_key":"must-not-leak","extra":true}' +if GANBING_SOURCE=$tmp/clean MOCK_PROVIDER_CONFIG=$provider_config PATH=$tmp/bin:$PATH "$root/scripts/preflight_acceptance.sh" > "$tmp/preflight-override.log" 2>&1; then + fail "nonempty database provider override was accepted" +fi +grep -F "$provider_config" "$tmp/preflight-override.log" >/dev/null && fail "provider override leaked to logs" + +make_source "$tmp/malformed" +cat > "$tmp/malformed/SKILL.md" <<'EOF' +--- +name: fixture +--- +description: This is body text, not frontmatter. +EOF +expect_staging_failure "$tmp/malformed" "missing frontmatter description" + +case_number=0 +for field in name description; do + for value in null Null NULL '~' '""' "''" '[]' '{}' true 123 .5 '[fixture]'; do + case_number=$((case_number + 1)) + source="$tmp/frontmatter-$case_number" + make_source "$source" + sed -i "s/^$field:.*/$field: $value/" "$source/SKILL.md" + expect_staging_failure "$source" "$field value $value" + done +done + +for fixture in json yaml markdown; do + source="$tmp/credential-$fixture" + make_source "$source" + case "$fixture" in + json) printf '%s\n' '{"api_key":"fixture-secret"}' > "$source/config.json" ;; + yaml) printf '%s\n' 'access_token: fixture-token' > "$source/reference.md" ;; + markdown) printf '%s\n' '**password** = `fixture-password`' > "$source/reference.md" ;; + esac + expect_staging_failure "$source" "$fixture credential assignment" +done + +GANBING_SOURCE=$tmp/clean "$root/scripts/stage_captain_assets.sh" "$tmp/output" >/dev/null +[ "$clean_before" = "$(find "$tmp/clean" -type f -exec sha256sum {} +)" ] || fail "staging modified its source" +grep -qx 'skill_status=active' "$tmp/output/manifest.txt" || fail "valid string frontmatter did not generate an active manifest" +find "$tmp/output" -type f -exec stat -c '%a' {} + | grep -vx 600 >/dev/null && fail "staged file permissions are not 0600" + +echo "acceptance scripts tests passed" diff --git a/deploy/quickstart/scripts/preflight_acceptance.sh b/deploy/quickstart/scripts/preflight_acceptance.sh new file mode 100755 index 00000000..fedd7af3 --- /dev/null +++ b/deploy/quickstart/scripts/preflight_acceptance.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +stage=$(mktemp -d "${TMPDIR:-/tmp}/gochat-captain-stage.XXXXXX") +trap 'rm -rf "$stage"' EXIT HUP INT TERM + +"$root/scripts/stage_captain_assets.sh" "$stage" + +compose() { + docker compose -f "$root/compose.yaml" -f "$root/compose.acceptance.yaml" "$@" +} + +compose config --quiet +compose ps --status running --services | grep -qx captain-fixture || { + echo "preflight failed: captain-fixture is not running" >&2 + exit 1 +} +compose ps --status running --services | grep -qx gochat || { + echo "preflight failed: gochat is not running" >&2 + exit 1 +} +compose exec -T gochat curl -fsS --retry 30 --retry-delay 1 --retry-connrefused --max-time 2 http://127.0.0.1:3000/health >/dev/null + +overrides=$(compose exec -T postgres sh -eu -c ' + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc " + SELECT count(*) FROM installation_configs + WHERE deleted_at IS NULL AND ( + (name = '\''COPILOT_PROVIDER_CONFIG'\'' AND COALESCE(value, '\'''\'') <> '\'''\'') OR + (name = '\''COPILOT_CHAT_API_KEY'\'' AND value <> '\''acceptance-fixture-not-a-secret'\'') OR + (name = '\''COPILOT_EMBEDDING_API_KEY'\'' AND value <> '\'''\'') + )" +') +[ "$overrides" = "0" ] || { + echo "preflight failed: database Copilot settings override the local fixture" >&2 + exit 1 +} + +compose exec -T gochat sh -eu -c ' + test -n "$GOCHAT_COPILOT_PROVIDER_CONFIG" + test "$GOCHAT_COPILOT_CHAT_API_KEY" = acceptance-fixture-not-a-secret + case "$GOCHAT_COPILOT_PROVIDER_CONFIG" in + *http://captain-fixture:8080/v1*) ;; + *) exit 1 ;; + esac + knowledge=$(curl -fsS http://captain-fixture:8080/knowledge) + test -n "$knowledge" + case "$knowledge" in *KBASE-LOCAL-2026*) ;; *) exit 1 ;; esac + chat=$(curl -fsS -H "Content-Type: application/json" -d "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"preflight\"}]}" http://captain-fixture:8080/v1/chat/completions) + case "$chat" in *chat.completion*) ;; *) exit 1 ;; esac + embedding=$(curl -fsS -H "Content-Type: application/json" -d "{\"model\":\"fixture-embedding\",\"input\":[\"preflight\"]}" http://captain-fixture:8080/v1/embeddings) + case "$embedding" in *\"object\":\"list\"*) ;; *) exit 1 ;; esac +' + +echo "acceptance preflight passed: provider=injected fixture=reachable knowledge=nonempty captain_assets=staged" diff --git a/deploy/quickstart/scripts/stage_captain_assets.sh b/deploy/quickstart/scripts/stage_captain_assets.sh new file mode 100755 index 00000000..97192012 --- /dev/null +++ b/deploy/quickstart/scripts/stage_captain_assets.sh @@ -0,0 +1,115 @@ +#!/bin/sh +set -eu + +src=${GANBING_SOURCE:-} +dst=${1:-} + +fail() { + echo "captain asset staging failed: $1" >&2 + exit 1 +} + +[ -n "$src" ] || fail "set GANBING_SOURCE to the read-only source directory" +[ -d "$src" ] || fail "source directory does not exist" +[ -n "$dst" ] || fail "usage: GANBING_SOURCE=/path $0 DESTINATION" +if [ -e "$dst" ]; then + [ -d "$dst" ] && [ -z "$(find "$dst" -mindepth 1 -print -quit)" ] || fail "destination must be absent or empty" +fi + +src=$(realpath "$src") +case "$(realpath -m "$dst")/" in + "$src"/*) fail "destination must be outside the source directory" ;; +esac + +[ "$(find "$src" -type l | wc -l | tr -d ' ')" -eq 0 ] || fail "symbolic links are not allowed" +[ "$(find "$src" ! -type d ! -type f | wc -l | tr -d ' ')" -eq 0 ] || fail "special files are not allowed" +[ "$(find "$src" -type f -name SKILL.md | wc -l | tr -d ' ')" -eq 1 ] || fail "exactly one SKILL.md is required" +[ "$(find "$src" -type f -name SOUL.md | wc -l | tr -d ' ')" -eq 1 ] || fail "exactly one SOUL.md is required" + +skill=$(find "$src" -type f -name SKILL.md -print -quit) +soul=$(find "$src" -type f -name SOUL.md -print -quit) +awk ' + function present(line, first, lower) { + sub(/^[^:]*:[[:space:]]*/, "", line) + sub(/[[:space:]]+#.*$/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + first = substr(line, 1, 1) + if (first == "\"" || first == "\047") + return length(line) > 2 && substr(line, length(line), 1) == first && substr(line, 2, length(line) - 2) !~ /^[[:space:]]*$/ + lower = tolower(line) + if (line == "" || line ~ /^#/ || lower ~ /^(~|null|true|false|yes|no|on|off|[+-]?\.(inf|nan))$/) + return 0 + if (first == "[" || first == "{" || first == "|" || first == ">" || first == "!" || first == "&" || first == "*") + return 0 + if (lower ~ /^[+-]?0[xob][0-9a-f_]+$/ || lower ~ /^[+-]?([0-9][0-9_]*(\.[0-9_]*)?|\.[0-9_]+)(e[+-]?[0-9_]+)?$/ || lower ~ /^[0-9][0-9_.:tz+-]*$/) + return 0 + return 1 + } + NR == 1 { if ($0 != "---") exit 1; next } + $0 == "---" { closed = 1; exit } + /^name:[[:space:]]*/ { if (present($0)) name = 1 } + /^description:[[:space:]]*/ { if (present($0)) description = 1 } + END { if (!closed || !name || !description) exit 1 } +' "$skill" || fail "SKILL.md frontmatter must contain name and description" +[ "$(wc -c < "$soul" | tr -d ' ')" -le 20000 ] || fail "SOUL.md exceeds the Assistant instructions UI limit" +[ "$(find "$src" -type f ! -name '*.md' ! -name '*.json' | wc -l | tr -d ' ')" -eq 0 ] || fail "only Markdown and JSON source files are allowed" + +find "$src" -type f -exec sh -c ' + for file do + case "$(file -b --mime-encoding "$file")" in + utf-8|us-ascii) ;; + *) exit 1 ;; + esac + done +' sh {} + || fail "all staged files must be UTF-8" + +credential_scan=0 +credential_pattern="['\"\`*_]?([[:alnum:]_.-]*[_-])?(api[ _-]?key|token|secret|password|authorization)['\"\`*_]*[[:space:]]*[:=][[:space:]]*['\"\`]?[^[:space:]'\"\`\$<{][^[:space:]]*" +grep -ERqi "$credential_pattern" "$src" || credential_scan=$? +case "$credential_scan" in + 0) fail "a possible plaintext credential was detected" ;; + 1) ;; + *) fail "credential scan could not verify the source" ;; +esac + +mkdir -m 700 -p "$dst/captain-skill/references" "$dst/quarantine" +instructions="$dst/captain-skill/instructions.md" +awk 'NR == 1 && $0 == "---" { frontmatter=1; next } frontmatter && $0 == "---" { frontmatter=0; body=1; next } body { print }' "$skill" > "$instructions" +chmod 600 "$instructions" +[ -s "$instructions" ] || fail "SKILL.md body is empty" +[ "$(wc -c < "$instructions" | tr -d ' ')" -le 32768 ] || fail "SKILL.md body exceeds the Captain Skill limit" +install -m 600 "$soul" "$dst/assistant-instructions.md" + +instruction_bytes=$(wc -c < "$instructions" | tr -d ' ') +export dst instruction_bytes +find "$src" -type f -name '*.md' ! -name SKILL.md ! -name SOUL.md -exec sh -eu -c ' + for reference do + bytes=$(wc -c < "$reference" | tr -d " ") + [ "$bytes" -le 65536 ] || exit 1 + key=$(printf "%s" "$reference" | sha256sum | cut -c1-12) + # Conservative allowance for JSON/tool wrappers and the complete reference-key list. + if [ $((instruction_bytes + bytes + 1200)) -le 8000 ]; then + install -m 600 "$reference" "$dst/captain-skill/references/reference-$key.md" + else + install -m 600 "$reference" "$dst/quarantine/reference-$key.md" + fi + done +' sh {} + || fail "a reference exceeds the Captain reference limit" + +safe=$(find "$dst/captain-skill/references" -type f | wc -l | tr -d ' ') +quarantined=$(find "$dst/quarantine" -type f | wc -l | tr -d ' ') +total=$((safe + quarantined)) +[ "$total" -le 20 ] || fail "Captain supports at most 20 references" + +cat > "$dst/manifest.txt" <', +}; + +const Button = { + name: 'Button', + emits: ['click'], + template: '', +}; + +const mountForm = assistant => + shallowMount(AssistantSystemSettingsForm, { + props: { assistant }, + global: { stubs: { Editor, Button } }, + }); + +it('saves additional instructions for Captain V2 runtime prompts', async () => { + const wrapper = mountForm({ + config: { + handoff_message: 'handoff', + resolution_message: 'resolved', + instructions: 'old instructions', + temperature: 0.5, + }, + }); + + expect(wrapper.findAll('[data-editor]')[2].element.value).toBe( + 'old instructions' + ); + await wrapper.findAll('[data-editor]')[2].setValue('staged SOUL instructions'); + await wrapper.find('button').trigger('click'); + await flushPromises(); + + expect(wrapper.emitted('submit')[0][0].config.instructions).toBe( + 'staged SOUL instructions' + ); +}); + +it.each([ + [ + 'empty config', + { config: {} }, + { + handoff_message: '', + resolution_message: '', + instructions: '', + temperature: 1, + }, + ], + [ + 'assistant created before instructions existed', + { + config: { + handoff_message: 'handoff', + resolution_message: 'resolved', + temperature: 0.5, + }, + }, + { + handoff_message: 'handoff', + resolution_message: 'resolved', + instructions: '', + temperature: 0.5, + }, + ], +])('saves %s without instructions', async (_, assistant, expectedConfig) => { + const wrapper = mountForm(assistant); + + await wrapper.find('button').trigger('click'); + await flushPromises(); + + expect(wrapper.emitted('submit')[0][0].config).toMatchObject(expectedConfig); +}); diff --git a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue index ee20fded..be78af53 100644 --- a/frontend/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue +++ b/frontend/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue @@ -3,8 +3,6 @@ import { reactive, computed, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useVuelidate } from '@vuelidate/core'; import { minLength } from '@vuelidate/validators'; -import { FEATURE_FLAGS } from 'dashboard/featureFlags'; -import { useAccount } from 'dashboard/composables/useAccount'; import Button from 'dashboard/components-next/button/Button.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; @@ -19,11 +17,6 @@ const props = defineProps({ const emit = defineEmits(['submit']); const { t } = useI18n(); -const { isCloudFeatureEnabled } = useAccount(); - -const isCaptainV2Enabled = computed(() => - isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2) -); const initialState = { handoffMessage: '', @@ -54,9 +47,9 @@ const formErrors = computed(() => ({ const updateStateFromAssistant = assistant => { const { config = {} } = assistant; - state.handoffMessage = config.handoff_message; - state.resolutionMessage = config.resolution_message; - state.instructions = config.instructions; + state.handoffMessage = config.handoff_message ?? ''; + state.resolutionMessage = config.resolution_message ?? ''; + state.instructions = config.instructions ?? ''; state.temperature = config.temperature || 1; }; @@ -64,12 +57,9 @@ const handleSystemMessagesUpdate = async () => { const validations = [ v$.value.handoffMessage.$validate(), v$.value.resolutionMessage.$validate(), + v$.value.instructions.$validate(), ]; - if (!isCaptainV2Enabled.value) { - validations.push(v$.value.instructions.$validate()); - } - const result = await Promise.all(validations).then(results => results.every(Boolean) ); @@ -80,14 +70,11 @@ const handleSystemMessagesUpdate = async () => { ...props.assistant.config, handoff_message: state.handoffMessage, resolution_message: state.resolutionMessage, + instructions: state.instructions, temperature: state.temperature || 1, }, }; - if (!isCaptainV2Enabled.value) { - payload.config.instructions = state.instructions; - } - emit('submit', payload); }; @@ -121,7 +108,6 @@ watch( />