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 <rogee@ipao.vip>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/captain-fixture
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
module gochat.local/captain-fixture
|
||||
|
||||
go 1.24.0
|
||||
@@ -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 = `<!doctype html><html><head><title>GoChat Local Acceptance Knowledge</title></head><body><main><h1>Local acceptance policy</h1><p id="fixture-fact">The local fixture warranty window is 37 days. Reference marker: KBASE-LOCAL-2026.</p></main></body></html>`
|
||||
)
|
||||
|
||||
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 = "<available_skills_json>", "</available_skills_json>"
|
||||
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)
|
||||
}
|
||||
@@ -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":"<available_skills_json>[{\"name\":\"local-skill\"}]</available_skills_json>"},{"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
|
||||
}
|
||||
@@ -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: ""
|
||||
+99
@@ -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"
|
||||
+55
@@ -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"
|
||||
+115
@@ -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" <<EOF
|
||||
skill_name=ganbing-local-acceptance
|
||||
skill_description=Isolated local acceptance skill
|
||||
skill_status=active
|
||||
reference_count=$total
|
||||
reference_importable=$safe
|
||||
reference_quarantined=$quarantined
|
||||
source_modified=false
|
||||
EOF
|
||||
chmod 600 "$dst/manifest.txt"
|
||||
|
||||
echo "captain assets staged: core=ready references=$safe quarantined=$quarantined"
|
||||
Reference in New Issue
Block a user