Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6dba0d39b | ||
|
|
59f5b4ce57 | ||
|
|
6440cbff81 | ||
|
|
d0255558e2 | ||
|
|
861438a4ca | ||
|
|
fbc6538d18 | ||
|
|
4b8832766a | ||
|
|
f9cdd67229 | ||
|
|
b896d218f8 |
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxAdvisoryMessageBytes = 16 * 1024
|
||||
MaxAdvisoryExcerpts = 8
|
||||
MaxAdvisoryExcerptBytes = 8 * 1024
|
||||
MaxAdvisoryContextBytes = 48 * 1024
|
||||
MaxAdvisoryResponseBytes = 16 * 1024
|
||||
)
|
||||
|
||||
var ErrInvalidAdvisory = errors.New("invalid creator advisory")
|
||||
|
||||
func ValidateAdvisoryRequest(request cognitive.AdvisoryRequest) error {
|
||||
if request.Version != cognitive.CreatorAdvisoryV1 || request.RequestID == "" || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.ProviderBindingID == "" || request.ProviderBindingRevision <= 0 {
|
||||
return fmt.Errorf("%w: missing or unsupported identity/binding fields", ErrInvalidAdvisory)
|
||||
}
|
||||
if request.Message == "" || !utf8.ValidString(request.Message) || len(request.Message) > MaxAdvisoryMessageBytes {
|
||||
return fmt.Errorf("%w: message", ErrInvalidAdvisory)
|
||||
}
|
||||
if request.Locale == "" || len(request.Locale) > 32 || !utf8.ValidString(request.Locale) || request.MaxOutputBytes <= 0 || request.MaxOutputBytes > MaxAdvisoryResponseBytes {
|
||||
return fmt.Errorf("%w: locale or response limit", ErrInvalidAdvisory)
|
||||
}
|
||||
if len(request.Context) > MaxAdvisoryExcerpts {
|
||||
return fmt.Errorf("%w: too many context excerpts", ErrInvalidAdvisory)
|
||||
}
|
||||
seen := make(map[cognitive.ContextExcerptID]struct{}, len(request.Context))
|
||||
total := 0
|
||||
for _, excerpt := range request.Context {
|
||||
if excerpt.ID == "" || excerpt.SourceKind == "" || excerpt.SourceID == "" || excerpt.Revision <= 0 || excerpt.Text == "" || !utf8.ValidString(excerpt.Text) || len(excerpt.Text) > MaxAdvisoryExcerptBytes {
|
||||
return fmt.Errorf("%w: context excerpt", ErrInvalidAdvisory)
|
||||
}
|
||||
if excerpt.Sensitivity != cognitive.SensitivityPublic && excerpt.Sensitivity != cognitive.SensitivityPrivateAllowed {
|
||||
return fmt.Errorf("%w: context sensitivity", ErrInvalidAdvisory)
|
||||
}
|
||||
if _, exists := seen[excerpt.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate context excerpt", ErrInvalidAdvisory)
|
||||
}
|
||||
seen[excerpt.ID] = struct{}{}
|
||||
total += len(excerpt.Text)
|
||||
}
|
||||
if total > MaxAdvisoryContextBytes {
|
||||
return fmt.Errorf("%w: total context", ErrInvalidAdvisory)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAdvisoryResponse(request cognitive.AdvisoryRequest, response cognitive.AdvisoryResponse) error {
|
||||
if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision {
|
||||
return fmt.Errorf("%w: response identity", ErrInvalidAdvisory)
|
||||
}
|
||||
if !validFinishReason(response.FinishReason) || !utf8.ValidString(response.AdvisoryText) || len(response.AdvisoryText) > request.MaxOutputBytes {
|
||||
return fmt.Errorf("%w: response content", ErrInvalidAdvisory)
|
||||
}
|
||||
if response.FinishReason == cognitive.FinishCompleted && response.AdvisoryText == "" {
|
||||
return fmt.Errorf("%w: completed response is empty", ErrInvalidAdvisory)
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishCompleted && response.AdvisoryText != "" {
|
||||
return fmt.Errorf("%w: failure response contains advisory text", ErrInvalidAdvisory)
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishCompleted && len(response.ReferencedExcerptIDs) != 0 {
|
||||
return fmt.Errorf("%w: failure response contains citations", ErrInvalidAdvisory)
|
||||
}
|
||||
allowed := make(map[cognitive.ContextExcerptID]struct{}, len(request.Context))
|
||||
for _, excerpt := range request.Context {
|
||||
allowed[excerpt.ID] = struct{}{}
|
||||
}
|
||||
seen := make(map[cognitive.ContextExcerptID]struct{}, len(response.ReferencedExcerptIDs))
|
||||
for _, id := range response.ReferencedExcerptIDs {
|
||||
if _, ok := allowed[id]; !ok {
|
||||
return fmt.Errorf("%w: unknown citation", ErrInvalidAdvisory)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("%w: duplicate citation", ErrInvalidAdvisory)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) {
|
||||
return fmt.Errorf("%w: negative usage", ErrInvalidAdvisory)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeterministicAdvisoryUnavailable(request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
return cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: request.ProviderBindingRevision, FinishReason: cognitive.FinishProviderUnavailable}, nil
|
||||
}
|
||||
|
||||
func validFinishReason(reason cognitive.FinishReason) bool {
|
||||
switch reason {
|
||||
case cognitive.FinishCompleted, cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func TestDeterministicAdvisoryRejectsUnknownJSONField(t *testing.T) {
|
||||
body := `{"Version":"creator-advisory-v1","Unexpected":"provider override"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
deterministicAdvisory(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicAdvisoryReturnsUnavailableContract(t *testing.T) {
|
||||
body := `{"Version":"creator-advisory-v1","RequestID":"request-1","ActorID":"actor","PrincipalID":"principal","ScopeID":"scope","Message":"help","Context":[],"Locale":"en","MaxOutputBytes":4096,"ProviderBindingID":"fake-binding","ProviderBindingRevision":1}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
deterministicAdvisory(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var advisory cognitive.AdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&advisory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if advisory.FinishReason != cognitive.FinishProviderUnavailable || advisory.AdvisoryText != "" {
|
||||
t.Fatalf("unexpected response: %+v", advisory)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -17,12 +19,35 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
|
||||
mux.HandleFunc("POST /v1/advisories/deterministic", deterministicAdvisory)
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 80*1024)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var request cognitive.AdvisoryRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := models.DeterministicAdvisoryUnavailable(request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func deterministicProposal(w http.ResponseWriter, r *http.Request) {
|
||||
var request models.DeterministicRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ func main() {
|
||||
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
+141
-4
@@ -9,11 +9,18 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRequestBytes = 8 << 10
|
||||
maxResponseBytes = 1 << 20
|
||||
maxRequestBytes = 8 << 10
|
||||
maxAdvisoryRequestBytes = 20 << 10
|
||||
maxResponseBytes = 1 << 20
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -23,6 +30,7 @@ type gateway struct {
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
}
|
||||
@@ -35,7 +43,7 @@ const (
|
||||
targetCommit
|
||||
)
|
||||
|
||||
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (http.Handler, error) {
|
||||
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) {
|
||||
core, err := parseRuntimeURL(coreURL, "Core")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,6 +56,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cognitiveURL := "http://cognitive-worker:8083"
|
||||
if len(cognitiveURLs) > 0 {
|
||||
cognitiveURL = cognitiveURLs[0]
|
||||
}
|
||||
cognitiveRuntime, err := parseRuntimeURL(cognitiveURL, "Cognitive")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load embedded console: %w", err)
|
||||
@@ -55,7 +71,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, client: client, static: http.FileServer(http.FS(assets))}), nil
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -73,6 +89,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" {
|
||||
g.handleAdvisory(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +106,123 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.static.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type browserAdvisoryRequest struct {
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
var browserRequest browserAdvisoryRequest
|
||||
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
query := url.Values{"session_id": {browserRequest.SessionID}, "workspace_id": {browserRequest.WorkspaceID}}.Encode()
|
||||
var focus situation.FocusBinding
|
||||
status, err = g.getTrustedJSON(r, g.core, "/v1/creator/focus", query, authorization, &focus)
|
||||
if err != nil {
|
||||
http.Error(w, "creator focus unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
http.Error(w, "creator focus unavailable", status)
|
||||
return
|
||||
}
|
||||
if session.ActorID == "" || session.PrincipalID == "" || focus.ActorID != session.ActorID || focus.ScopeID == "" {
|
||||
http.Error(w, "creator focus denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
requestID, err := ids.New()
|
||||
if err != nil {
|
||||
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
trusted := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, ProviderBindingID: capability.CapabilityBindingID(ids.ID("deterministic-fake")), ProviderBindingRevision: 1}
|
||||
payload, err := json.Marshal(trusted)
|
||||
if err != nil {
|
||||
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
target := *g.cognitive
|
||||
target.Path = "/v1/advisories/deterministic"
|
||||
upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
http.Error(w, "advisory unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
upstreamRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := g.client.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
http.Error(w, "advisory unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
http.Error(w, "advisory unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var advisory cognitive.AdvisoryResponse
|
||||
if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || advisory.RequestID != trusted.RequestID || advisory.ProviderBindingID != trusted.ProviderBindingID || advisory.ProviderBindingRevision != 1 || advisory.FinishReason != cognitive.FinishProviderUnavailable || advisory.AdvisoryText != "" || len(advisory.ReferencedExcerptIDs) != 0 {
|
||||
http.Error(w, "invalid advisory response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(advisory)
|
||||
}
|
||||
|
||||
func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQuery, authorization string, destination any) (int, error) {
|
||||
target := *upstream
|
||||
target.Path = path
|
||||
target.RawQuery = rawQuery
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Authorization", authorization)
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes))
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
if err := decodeStrictJSON(response.Body, maxResponseBytes, destination); err != nil {
|
||||
return response.StatusCode, err
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(reader io.Reader, limit int64, destination any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, limit+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return fmt.Errorf("trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
routeTarget, upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path)
|
||||
if !allowed {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) {
|
||||
@@ -222,6 +226,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
|
||||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
|
||||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
|
||||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
|
||||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
|
||||
strings.Contains(response.Body.String(), "bootstrap-token") {
|
||||
t.Fatalf("console response = %d", response.Code)
|
||||
@@ -230,3 +235,85 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
t.Fatal("content security policy is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryRequiresSessionBeforeUpstreamCalls(t *testing.T) {
|
||||
called := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
defer upstream.Close()
|
||||
handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home"}`))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized || called {
|
||||
t.Fatalf("status=%d upstream_called=%t", response.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryAssemblesTrustedIdentityAndScope(t *testing.T) {
|
||||
longMessage := strings.Repeat("a", 16*1024)
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatal("missing bounded session")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/creator/sessions/current":
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
case "/v1/creator/focus":
|
||||
if r.URL.Query().Get("session_id") != "focus-session" || r.URL.Query().Get("workspace_id") != "home" {
|
||||
t.Fatalf("unexpected focus query: %s", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ProjectID":"domovoy","GoalID":"goal-1","ScopeID":"trusted-scope","Active":true}`)
|
||||
default:
|
||||
t.Fatalf("unexpected core path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
text := string(body)
|
||||
var trusted cognitive.AdvisoryRequest
|
||||
if err := json.Unmarshal(body, &trusted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trusted.ActorID != "creator-actor" || trusted.PrincipalID != "creator-principal" || trusted.ScopeID != "trusted-scope" || trusted.Message != longMessage || strings.Contains(text, "attacker") {
|
||||
t.Fatal("cognitive request was not assembled from trusted context")
|
||||
}
|
||||
var request map[string]any
|
||||
_ = json.Unmarshal(body, &request)
|
||||
_, _ = fmt.Fprintf(w, `{"Version":"creator-advisory-v1","RequestID":%q,"ProviderBindingID":"deterministic-fake","ProviderBindingRevision":1,"AdvisoryText":"","ReferencedExcerptIDs":null,"FinishReason":"PROVIDER_UNAVAILABLE","Usage":null}`, request["RequestID"])
|
||||
}))
|
||||
defer cognitive.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitive.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(fmt.Sprintf(`{"message":%q,"session_id":"focus-session","workspace_id":"home"}`, longMessage)))
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "PROVIDER_UNAVAILABLE") {
|
||||
t.Fatalf("response=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryRejectsBrowserAuthorityFieldsBeforeCognitiveCall(t *testing.T) {
|
||||
called := false
|
||||
cognitive := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
defer cognitive.Close()
|
||||
handler, err := newHandler("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, cognitive.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","ActorID":"attacker"}`))
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest || called {
|
||||
t.Fatalf("status=%d cognitive_called=%t", response.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,26 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.signal-strip strong[data-state="error"] { color: var(--bad); }
|
||||
.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||
|
||||
.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); }
|
||||
.advisor-intro { align-self: center; }
|
||||
.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; }
|
||||
.advisor-intro h2 { margin: 0; font: 400 clamp(25px, 3vw, 36px)/1.05 var(--serif); }
|
||||
.advisor-intro span { display: block; max-width: 44ch; margin-top: 12px; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.advisor-form { display: grid; gap: 9px; }
|
||||
.advisor-form > label { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.advisor-form textarea { width: 100%; min-height: 92px; padding: 13px 14px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: rgba(7,12,10,.82); font: 12px/1.55 var(--sans); transition: border-color .18s, box-shadow .18s; }
|
||||
.advisor-form textarea:focus { outline: 0; border-color: var(--signal); box-shadow: 0 0 0 2px rgba(201,168,95,.12); }
|
||||
.advisor-controls { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.advisor-controls > span { color: var(--muted); font: 9px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.advisor-controls i { width: 6px; height: 6px; margin-right: 7px; display: inline-block; border-radius: 50%; background: var(--good); box-shadow: 0 0 10px rgba(120,198,163,.7); }
|
||||
.advisor-controls .primary-button { width: auto; min-width: 180px; margin: 0; }
|
||||
.advisor-result { grid-column: 1 / -1; padding-top: 18px; display: grid; grid-template-columns: 150px minmax(0,1fr); gap: 18px; border-top: 1px solid var(--line); }
|
||||
.advisor-result[hidden] { display: none; }
|
||||
.advisor-result span { display: block; margin-bottom: 7px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .12em; }
|
||||
.advisor-result strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
|
||||
.advisor-result strong[data-state="COMPLETED"] { color: var(--good); }
|
||||
.advisor-result p { margin: 0; color: var(--ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
||||
|
||||
.register-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16, 23, 20, .82); }
|
||||
.register-panel > header { padding: 17px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.register-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
@@ -228,6 +248,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.context-rail { padding-inline: 20px; }
|
||||
.workspace { padding: 32px 24px; }
|
||||
.data-grid { grid-template-columns: 1fr; }
|
||||
.advisor-panel { grid-template-columns: 1fr; }
|
||||
.capability-card { grid-column: auto; }
|
||||
.prospective-card { grid-column: auto; }
|
||||
}
|
||||
@@ -246,6 +267,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.last-update { text-align: left; }
|
||||
.signal-strip { grid-template-columns: 1fr; }
|
||||
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.advisor-panel { padding: 20px 16px; gap: 20px; }
|
||||
.advisor-controls { align-items: stretch; flex-direction: column; }
|
||||
.advisor-controls .primary-button { width: 100%; }
|
||||
.advisor-result { grid-template-columns: 1fr; gap: 10px; }
|
||||
.register-panel > header { align-items: start; }
|
||||
.activity-panel > header { align-items: flex-start; }
|
||||
.activity-tools { align-items: flex-end; flex-direction: column-reverse; }
|
||||
|
||||
@@ -3,6 +3,7 @@ const loginView = byId("login-view");
|
||||
const consoleView = byId("console-view");
|
||||
const loginForm = byId("login-form");
|
||||
const contextForm = byId("context-form");
|
||||
const advisoryForm = byId("advisory-form");
|
||||
const loginError = byId("login-error");
|
||||
const toast = byId("toast");
|
||||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
|
||||
@@ -96,6 +97,8 @@ function endSession(message = "Сессия завершена.") {
|
||||
pendingCommand = null;
|
||||
pendingNotification = null;
|
||||
lastAuthorizedActionID = "";
|
||||
advisoryForm.reset();
|
||||
byId("advisory-result").hidden = true;
|
||||
notify(message);
|
||||
}
|
||||
|
||||
@@ -142,6 +145,40 @@ byId("refresh-all").addEventListener("click", loadDashboard);
|
||||
byId("refresh-actions").addEventListener("click", loadActionHistory);
|
||||
byId("logout").addEventListener("click", () => endSession());
|
||||
|
||||
advisoryForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const context = saveContext();
|
||||
const message = byId("advisory-message").value.trim();
|
||||
const button = byId("advisory-submit");
|
||||
if (!context["session-id"] || !context["workspace-id"]) {
|
||||
notify("Сначала укажите сессию и рабочую область контекста.");
|
||||
return;
|
||||
}
|
||||
if (!message) return;
|
||||
button.disabled = true;
|
||||
button.firstElementChild.textContent = "Проверяю контекст…";
|
||||
byId("advisory-result").hidden = true;
|
||||
try {
|
||||
const result = await request("/api/advisory", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"]})
|
||||
});
|
||||
const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE";
|
||||
setText("advisory-state", state);
|
||||
byId("advisory-state").dataset.state = state;
|
||||
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? "Когнитивный провайдер пока не подключён. Контекст проверен, наружу ничего не отправлено." : result.AdvisoryText, "Ответ не получен.");
|
||||
byId("advisory-result").hidden = false;
|
||||
} catch (error) {
|
||||
setText("advisory-state", "ОШИБКА");
|
||||
setText("advisory-text", error.message);
|
||||
byId("advisory-result").hidden = false;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.firstElementChild.textContent = "Получить совет";
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
const context = saveContext();
|
||||
const sessionID = context["session-id"];
|
||||
|
||||
@@ -92,6 +92,26 @@
|
||||
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="advisor-panel" aria-labelledby="advisor-title">
|
||||
<div class="advisor-intro">
|
||||
<p>COGNITIVE / ADVISORY V1</p>
|
||||
<h2 id="advisor-title">Спросить Домового</h2>
|
||||
<span>Ответ — только совет. Он не сохраняется, не создаёт задач и не запускает действий.</span>
|
||||
</div>
|
||||
<form id="advisory-form" class="advisor-form">
|
||||
<label for="advisory-message">Ваш вопрос</label>
|
||||
<textarea id="advisory-message" rows="3" maxlength="16384" placeholder="Что стоит проверить в выбранном контексте?" required></textarea>
|
||||
<div class="advisor-controls">
|
||||
<span id="advisory-privacy"><i></i> только выбранный контекст</span>
|
||||
<button id="advisory-submit" class="primary-button" type="submit"><span>Получить совет</span><span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="advisory-result" class="advisor-result" aria-live="polite" hidden>
|
||||
<div><span>СТАТУС</span><strong id="advisory-state">—</strong></div>
|
||||
<p id="advisory-text"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-panel" aria-labelledby="register-title">
|
||||
<header>
|
||||
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package cognitive defines provider-neutral, non-canonical cognitive gateway contracts.
|
||||
package cognitive
|
||||
@@ -0,0 +1,71 @@
|
||||
package cognitive
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type RequestID ids.ID
|
||||
type ContextExcerptID ids.ID
|
||||
type ContractVersion string
|
||||
type Sensitivity string
|
||||
type FinishReason string
|
||||
|
||||
const (
|
||||
CreatorAdvisoryV1 ContractVersion = "creator-advisory-v1"
|
||||
|
||||
SensitivityPublic Sensitivity = "PUBLIC"
|
||||
SensitivityPrivateAllowed Sensitivity = "PRIVATE_ALLOWED"
|
||||
SensitivitySecret Sensitivity = "SECRET"
|
||||
SensitivityUnknown Sensitivity = "UNKNOWN"
|
||||
|
||||
FinishCompleted FinishReason = "COMPLETED"
|
||||
FinishLengthLimit FinishReason = "LENGTH_LIMIT"
|
||||
FinishContentRejected FinishReason = "CONTENT_REJECTED"
|
||||
FinishProviderUnavailable FinishReason = "PROVIDER_UNAVAILABLE"
|
||||
FinishTimeout FinishReason = "TIMEOUT"
|
||||
FinishInvalidProviderResponse FinishReason = "INVALID_PROVIDER_RESPONSE"
|
||||
)
|
||||
|
||||
type ContextExcerpt struct {
|
||||
ID ContextExcerptID
|
||||
SourceKind string
|
||||
SourceID string
|
||||
Revision int64
|
||||
Sensitivity Sensitivity
|
||||
Text string
|
||||
}
|
||||
|
||||
// AdvisoryRequest is assembled by a trusted gateway. Actor, Principal, Scope
|
||||
// and Binding fields must never be accepted as browser-selected authority.
|
||||
type AdvisoryRequest struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Message string
|
||||
Context []ContextExcerpt
|
||||
Locale string
|
||||
MaxOutputBytes int
|
||||
ProviderBindingID capability.CapabilityBindingID
|
||||
ProviderBindingRevision int64
|
||||
}
|
||||
|
||||
type ProviderUsage struct {
|
||||
InputUnits int64
|
||||
OutputUnits int64
|
||||
}
|
||||
|
||||
type AdvisoryResponse struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
ProviderBindingID capability.CapabilityBindingID
|
||||
ProviderBindingRevision int64
|
||||
AdvisoryText string
|
||||
ReferencedExcerptIDs []ContextExcerptID
|
||||
FinishReason FinishReason
|
||||
Usage *ProviderUsage
|
||||
}
|
||||
@@ -122,8 +122,9 @@ services:
|
||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_RUNTIME_URL: http://commit-runtime:8082
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/ARCH-000008-accept-cognitive-advisory-boundary.json",
|
||||
"task_id": "ARCH-000008",
|
||||
"task_revision": 1
|
||||
"task_file": "dev/tasks/DEV-000048-creator-advisory-console.json",
|
||||
"task_id": "DEV-000048",
|
||||
"task_revision": 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000046",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0006",
|
||||
"base_repo_revision": "b896d218f80af277ccf4e15d28fbffb1c7bad937",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement the provider-neutral creator-advisory-v1 contract, fail-closed validation, deterministic unavailable fake, and cognitive-worker HTTP contract without provider egress, persistence, tools or canonical authority.",
|
||||
"requirement_ids": ["ADR-0006", "FD-001", "FD-009", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["contracts/cognitive/**", "adapters/models/**", "cmd/cognitive-worker/**", "tests/cognitive_advisory/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000046-cognitive-advisory-contract.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/authority/**", "contracts/commit/**", "modules/**", "adapters/postgres/**", "database/**", "deploy/**", "security/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["contracts.identity@v1", "contracts.authority@v1", "contracts.capability@v1", "ADR-0006"], "may_change": ["new contracts.cognitive creator-advisory-v1"]},
|
||||
"semantic_owner": "none-cognitive-gateway",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["none; deterministic endpoint performs no network call or mutation"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["invalid or secret context rejected before adapter", "fake reports PROVIDER_UNAVAILABLE without fabricated text", "request loss creates no obligation and no retry"],
|
||||
"assumptions": ["real provider remains unselected and outbound egress disabled", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["valid typed request serializes without provider types", "secret/unknown/oversized/duplicate context is rejected", "response citations outside supplied excerpts are rejected", "unknown JSON fields are rejected", "fake returns explicit PROVIDER_UNAVAILABLE and no action/tool fields", "existing deterministic proposal fake remains compatible"],
|
||||
"required_tests": ["unit", "HTTP contract", "adversarial validation", "go test", "go vet", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000047",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0006",
|
||||
"base_repo_revision": "4b8832766a979ae4ff69e921fc28f5f0d15b91b6",
|
||||
"risk_tier": "E",
|
||||
"intent": "Promote the exact merged provider-neutral cognitive advisory fake to docker-prod, refresh all project image provenance labels/manifests, and prove the internal endpoint has no provider egress or mutation behavior.",
|
||||
"requirement_ids": ["ADR-0006", "DEV-000045", "DEV-000046", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000047-production-cognitive-advisory-fake.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**", "docs/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["creator-advisory-v1", "existing production compose", "production artifact manifest v1"], "may_change": []},
|
||||
"semantic_owner": "none-production-promotion",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["rebuild and replace project-owned containers on docker-prod from exact merged main", "refresh non-secret artifact manifest", "no provider credential or outbound provider call"],
|
||||
"persistence_and_migrations": ["refresh external non-secret deployment manifest only"],
|
||||
"failure_retry_restart": ["existing canonical volumes remain mounted", "failed promotion does not publish a new manifest", "fake returns PROVIDER_UNAVAILABLE after restart"],
|
||||
"assumptions": ["current production settings are reused in process memory without printing", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["all seven project images carry exact merged main revision", "production health remains healthy", "valid internal advisory request returns PROVIDER_UNAVAILABLE with empty text", "unknown provider override field is rejected", "cognitive worker has no database/executor/provider secret environment or mounts"],
|
||||
"required_tests": ["architecture preflight", "exact diff scope", "CI", "production build/up", "artifact confirmation", "production health", "adversarial advisory smoke", "secret/mount inspection", "docker-test absence"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000048",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ADR-0006",
|
||||
"base_repo_revision": "861438a4cae844edfe8a17615fb700d17c950d8e",
|
||||
"risk_tier": "E",
|
||||
"intent": "Expose creator-advisory-v1 through authenticated Creator Web, assembling trusted identity/scope fields server-side and adding a responsive advisory console that displays explicit provider-unavailable state without persistence, tools or egress.",
|
||||
"requirement_ids": ["ADR-0006", "DEV-000046", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["cmd/creator-web/**", "deploy/docker-compose.prod.yml", "tests/creator_web/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000048-creator-advisory-console.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "cmd/cognitive-worker/**", "database/**", "security/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["creator-advisory-v1", "CreatorSession", "Situation FocusBinding"], "may_change": []},
|
||||
"semantic_owner": "none-gateway-and-display",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["Creator Web may call fixed internal cognitive-worker endpoint after Core session/focus validation", "no provider network egress or credentials"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["advisory text is not persisted", "session/focus failure prevents cognitive call", "provider unavailable is displayed explicitly", "no automatic retry"],
|
||||
"assumptions": ["browser supplies only message and existing focus locators", "deterministic fake remains the only binding", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["unauthenticated advisory denied before cognitive call", "browser cannot select Actor/Principal/Scope/binding/provider/context", "trusted request uses current session and validated focus", "the gateway accepts the full 16 KiB advisory message contract plus its JSON envelope", "UI labels advisory as non-canonical and unavailable", "message is not stored in local/session storage", "desktop/mobile layout and console are clean"],
|
||||
"required_tests": ["unit", "gateway adversarial", "static UI", "go test", "go vet", "architecture preflight", "exact diff scope", "browser desktop/mobile", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -26,4 +26,6 @@ Audit date: 2026-08-16. Active audit: DEV-000044 revision 3.
|
||||
|
||||
DEV-000045 closes the DEV-000044 artifact-provenance finding for new promotions by stamping the merged Git revision into OCI labels, recording exact promoted image IDs outside the repository, and making production monitoring reject label or digest drift. Images that predate DEV-000045 remain historically unverifiable from the image alone.
|
||||
|
||||
ADR-0006 and DEV-000046 establish the provider-neutral, non-canonical Creator advisory boundary. DEV-000048 connects it to authenticated Creator Web and adds the advisory console; until a provider/data-egress decision is accepted, it deliberately reports `PROVIDER_UNAVAILABLE` and sends nothing outside the private runtime topology.
|
||||
|
||||
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package cognitive_advisory_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
func validRequest() cognitive.AdvisoryRequest {
|
||||
return cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(ids.ID("request-1")), ActorID: identity.ActorID(ids.ID("actor")), PrincipalID: identity.PrincipalID(ids.ID("principal")), ScopeID: authority.ScopeID(ids.ID("scope")), Message: "What should I review?", Context: []cognitive.ContextExcerpt{{ID: cognitive.ContextExcerptID(ids.ID("excerpt-1")), SourceKind: "prospective-item", SourceID: "item-1", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Review tomorrow"}}, Locale: "en", MaxOutputBytes: 4096, ProviderBindingID: capability.CapabilityBindingID(ids.ID("fake-binding")), ProviderBindingRevision: 1}
|
||||
}
|
||||
|
||||
func TestDeterministicAdvisoryIsExplicitlyUnavailable(t *testing.T) {
|
||||
request := validRequest()
|
||||
response, err := models.DeterministicAdvisoryUnavailable(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishProviderUnavailable || response.AdvisoryText != "" {
|
||||
t.Fatalf("unexpected fake response: %+v", response)
|
||||
}
|
||||
if err := models.ValidateAdvisoryResponse(request, response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"ActionProposal", "ToolCall", "AuthorityGrant"} {
|
||||
if strings.Contains(string(raw), forbidden) {
|
||||
t.Fatalf("response contains forbidden field %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryRejectsUnsafeContextAndCitations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*cognitive.AdvisoryRequest)
|
||||
}{
|
||||
{"secret", func(r *cognitive.AdvisoryRequest) { r.Context[0].Sensitivity = cognitive.SensitivitySecret }},
|
||||
{"unknown", func(r *cognitive.AdvisoryRequest) { r.Context[0].Sensitivity = cognitive.SensitivityUnknown }},
|
||||
{"duplicate", func(r *cognitive.AdvisoryRequest) { r.Context = append(r.Context, r.Context[0]) }},
|
||||
{"oversized-message", func(r *cognitive.AdvisoryRequest) { r.Message = strings.Repeat("x", models.MaxAdvisoryMessageBytes+1) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := validRequest()
|
||||
test.mutate(&request)
|
||||
if err := models.ValidateAdvisoryRequest(request); err == nil {
|
||||
t.Fatal("unsafe request accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
request := validRequest()
|
||||
response := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "answer", ReferencedExcerptIDs: []cognitive.ContextExcerptID{cognitive.ContextExcerptID(ids.ID("not-supplied"))}, FinishReason: cognitive.FinishCompleted}
|
||||
if err := models.ValidateAdvisoryResponse(request, response); err == nil {
|
||||
t.Fatal("unknown citation accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user