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:
Rogee
2026-08-20 20:05:40 +08:00
committed by GitHub
co-authored by rogee
parent 60ad320e8d
commit abb1bed424
12 changed files with 818 additions and 19 deletions
+42
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
module gochat.local/captain-fixture
go 1.24.0
+291
View File
@@ -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
}
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"