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: 'save ',
+};
+
+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(
/>