From f65d0f8f3cd4ad1cb30ba460834484f96cf97a79 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 16 Aug 2026 21:33:15 +0300 Subject: [PATCH] DEV-000053 add Creator advisory Mode A routing --- cmd/creator-web/main.go | 3 +- cmd/creator-web/server.go | 122 ++++++++++++++-- cmd/creator-web/server_test.go | 131 ++++++++++++++++++ cmd/creator-web/static/assets/console.css | 8 ++ cmd/creator-web/static/assets/console.js | 33 ++++- cmd/creator-web/static/index.html | 10 +- dev/ACTIVE_TASK.json | 4 +- ...-000053-creator-openai-mode-a-routing.json | 23 +++ docs/PROJECT_STATUS.md | 4 +- 9 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 dev/tasks/DEV-000053-creator-openai-mode-a-routing.json diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go index 3319c34..24ac897 100644 --- a/cmd/creator-web/main.go +++ b/cmd/creator-web/main.go @@ -9,11 +9,12 @@ import ( func main() { addr := envOr("LISTEN_ADDR", ":8084") - handler, err := newHandler( + handler, err := newHandlerWithAdvisoryMode( envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), &http.Client{Timeout: 10 * time.Second}, + envOr("ADVISORY_BINDING_MODE", advisoryModeDeterministic), envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), ) if err != nil { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 08c866b..f3bb730 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "unicode/utf8" "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" @@ -18,21 +19,26 @@ import ( ) const ( - maxRequestBytes = 8 << 10 - maxAdvisoryRequestBytes = 20 << 10 - maxResponseBytes = 1 << 20 + maxRequestBytes = 8 << 10 + maxAdvisoryRequestBytes = 20 << 10 + maxResponseBytes = 1 << 20 + advisoryModeDeterministic = "deterministic" + advisoryModeOpenAIModeA = "openai-mode-a" + openAIAdvisoryBindingID = "openai-responses-gpt-5.4-mini-2026-03-17" + openAIAdvisoryModel = "gpt-5.4-mini-2026-03-17" ) //go:embed static var staticFiles embed.FS type gateway struct { - core *url.URL - continuity *url.URL - commit *url.URL - cognitive *url.URL - client *http.Client - static http.Handler + core *url.URL + continuity *url.URL + commit *url.URL + cognitive *url.URL + advisoryMode string + client *http.Client + static http.Handler } type routeTarget uint8 @@ -44,6 +50,13 @@ const ( ) func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) { + return newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL, client, advisoryModeDeterministic, cognitiveURLs...) +} + +func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, advisoryMode string, cognitiveURLs ...string) (http.Handler, error) { + if advisoryMode != advisoryModeDeterministic && advisoryMode != advisoryModeOpenAIModeA { + return nil, fmt.Errorf("invalid advisory binding mode %q", advisoryMode) + } core, err := parseRuntimeURL(coreURL, "Core") if err != nil { return nil, err @@ -71,7 +84,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c if client == nil { client = http.DefaultClient } - return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil + return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, advisoryMode: advisoryMode, client: client, static: http.FileServer(http.FS(assets))}), nil } func parseRuntimeURL(rawURL, name string) (*url.URL, error) { @@ -89,6 +102,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if strings.HasPrefix(r.URL.Path, "/api/") { + if r.Method == http.MethodGet && r.URL.Path == "/api/advisory-policy" { + g.handleAdvisoryPolicy(w, r) + return + } if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" { g.handleAdvisory(w, r) return @@ -107,9 +124,52 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { } type browserAdvisoryRequest struct { - Message string `json:"message"` - SessionID string `json:"session_id"` - WorkspaceID string `json:"workspace_id"` + Message string `json:"message"` + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id"` + EgressAcknowledged bool `json:"egress_acknowledged"` +} + +type advisoryPolicy struct { + Mode string + ExternalEgress bool + AcknowledgementRequired bool + ProviderBindingID string + ProviderBindingRevision int64 + ModelSnapshot string + RetentionPolicy string +} + +func (g *gateway) policy() advisoryPolicy { + if g.advisoryMode == advisoryModeOpenAIModeA { + return advisoryPolicy{Mode: advisoryModeOpenAIModeA, ExternalEgress: true, AcknowledgementRequired: true, ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, ModelSnapshot: openAIAdvisoryModel, RetentionPolicy: "up-to-30-days-abuse-monitoring"} + } + return advisoryPolicy{Mode: advisoryModeDeterministic, ProviderBindingID: "deterministic-fake", ProviderBindingRevision: 1} +} + +func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { + 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 + } + if session.ActorID == "" || session.PrincipalID == "" { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(g.policy()) } func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { @@ -123,6 +183,14 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { http.Error(w, "creator session denied", http.StatusUnauthorized) return } + if g.advisoryMode == advisoryModeOpenAIModeA && !browserRequest.EgressAcknowledged { + http.Error(w, "external egress acknowledgement required", http.StatusBadRequest) + return + } + if g.advisoryMode == advisoryModeDeterministic && browserRequest.EgressAcknowledged { + http.Error(w, "unexpected external egress acknowledgement", http.StatusBadRequest) + return + } var session identity.CreatorSession status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session) if err != nil { @@ -153,7 +221,8 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { 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} + policy := g.policy() + 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(policy.ProviderBindingID)), ProviderBindingRevision: policy.ProviderBindingRevision} payload, err := json.Marshal(trusted) if err != nil { http.Error(w, "advisory unavailable", http.StatusServiceUnavailable) @@ -161,6 +230,9 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { } target := *g.cognitive target.Path = "/v1/advisories/deterministic" + if g.advisoryMode == advisoryModeOpenAIModeA { + target.Path = "/v1/advisories/openai" + } upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload))) if err != nil { http.Error(w, "advisory unavailable", http.StatusBadGateway) @@ -178,7 +250,7 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { 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 { + if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || !validAdvisoryResponse(advisory, trusted, g.advisoryMode) { http.Error(w, "invalid advisory response", http.StatusBadGateway) return } @@ -187,6 +259,26 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(advisory) } +func validAdvisoryResponse(response cognitive.AdvisoryResponse, request cognitive.AdvisoryRequest, mode string) bool { + if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision || len(response.ReferencedExcerptIDs) != 0 { + return false + } + if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) { + return false + } + if mode == advisoryModeDeterministic { + return response.FinishReason == cognitive.FinishProviderUnavailable && response.AdvisoryText == "" + } + switch response.FinishReason { + case cognitive.FinishCompleted: + return response.AdvisoryText != "" && len(response.AdvisoryText) <= request.MaxOutputBytes && utf8.ValidString(response.AdvisoryText) + case cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse: + return response.AdvisoryText == "" + default: + return false + } +} + func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQuery, authorization string, destination any) (int, error) { target := *upstream target.Path = path diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index bd68991..705e0c8 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -227,6 +227,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { !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(), "advisory-egress-ack") || !strings.Contains(response.Body.String(), "может храниться до 30 дней") || !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) @@ -236,6 +237,136 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { } } +func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) { + _, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected") + if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") { + t.Fatalf("error=%v", err) + } +} + +func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" { + t.Fatalf("unexpected policy authentication request %s %q", r.URL.Path, r.Header.Get("Authorization")) + } + _, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`) + })) + defer core.Close() + handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA) + if err != nil { + t.Fatal(err) + } + denied := httptest.NewRecorder() + handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil)) + if denied.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status=%d", denied.Code) + } + request := httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String()) + } + var policy advisoryPolicy + if err := json.Unmarshal(response.Body.Bytes(), &policy); err != nil { + t.Fatal(err) + } + if policy.Mode != advisoryModeOpenAIModeA || !policy.ExternalEgress || !policy.AcknowledgementRequired || policy.ProviderBindingID != openAIAdvisoryBindingID || policy.ProviderBindingRevision != 1 || policy.ModelSnapshot != openAIAdvisoryModel || policy.RetentionPolicy != "up-to-30-days-abuse-monitoring" { + t.Fatalf("unexpected policy %+v", policy) + } +} + +func TestOpenAIAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *testing.T) { + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer upstream.Close() + handler, err := newHandlerWithAdvisoryMode(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), advisoryModeOpenAIModeA, 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","egress_acknowledged":false}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || called { + t.Fatalf("status=%d upstream_called=%t", response.Code, called) + } +} + +func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/creator/sessions/current": + _, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`) + case "/v1/creator/focus": + _, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ScopeID":"trusted-scope","Active":true}`) + default: + t.Fatalf("unexpected core path %s", r.URL.Path) + } + })) + defer core.Close() + cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/advisories/openai" { + t.Fatalf("unexpected cognitive path %s", r.URL.Path) + } + var trusted cognitive.AdvisoryRequest + if err := json.NewDecoder(r.Body).Decode(&trusted); err != nil { + t.Fatal(err) + } + if trusted.ProviderBindingID != openAIAdvisoryBindingID || trusted.ProviderBindingRevision != 1 || len(trusted.Context) != 0 || trusted.Message != "help" { + t.Fatalf("unexpected trusted request %+v", trusted) + } + _ = json.NewEncoder(w).Encode(cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: trusted.RequestID, ProviderBindingID: trusted.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "Проверьте границы.", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}}) + })) + defer cognitiveRuntime.Close() + handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA, cognitiveRuntime.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","egress_acknowledged":true}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Проверьте границы") { + t.Fatalf("response=%d %s", response.Code, response.Body.String()) + } +} + +func TestOpenAIAdvisoryRejectsBrowserSelectedBinding(t *testing.T) { + handler, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, advisoryModeOpenAIModeA) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":true,"provider_binding_id":"attacker"}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} + +func TestOpenAIAdvisoryResponseValidationFailsClosed(t *testing.T) { + request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, MaxOutputBytes: 16} + valid := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "совет", FinishReason: cognitive.FinishCompleted} + if !validAdvisoryResponse(valid, request, advisoryModeOpenAIModeA) { + t.Fatal("valid completed response was rejected") + } + invalid := []cognitive.AdvisoryResponse{ + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "unexpected", FinishReason: cognitive.FinishTimeout}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "0123456789abcdefg", FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", ReferencedExcerptIDs: []cognitive.ContextExcerptID{"untrusted"}, FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: -1}}, + } + for index, response := range invalid { + if validAdvisoryResponse(response, request, advisoryModeOpenAIModeA) { + t.Fatalf("invalid response %d was accepted: %+v", index, response) + } + } +} + func TestAdvisoryRequiresSessionBeforeUpstreamCalls(t *testing.T) { called := false upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 9b0c632..9fe0a97 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -103,9 +103,17 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .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-egress { padding: 13px 14px; border: 1px solid rgba(222,164,73,.58); border-left: 3px solid var(--warn); border-radius: var(--radius); background: rgba(111,72,18,.17); color: var(--muted); font-size: 11px; line-height: 1.5; } +.advisor-egress[hidden] { display: none; } +.advisor-egress p { margin: 0; } +.advisor-egress .advisor-egress-kicker { margin-bottom: 7px; color: var(--warn); font: 700 9px/1 var(--mono); letter-spacing: .12em; } +.advisor-egress label { margin-top: 11px; display: flex; align-items: flex-start; gap: 9px; color: var(--ink); font: 600 10px/1.4 var(--mono); cursor: pointer; } +.advisor-egress input { width: 16px; height: 16px; flex: 0 0 auto; margin: 0; accent-color: var(--warn); } +.advisor-egress input:focus-visible { outline: 2px solid var(--warn); outline-offset: 3px; } .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 > span.external i { background: var(--warn); box-shadow: 0 0 10px rgba(222,164,73,.65); } .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; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index f49b126..9d48865 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -14,6 +14,7 @@ let currentProspective = null; let pendingCommand = null; let pendingNotification = null; let lastAuthorizedActionID = ""; +let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false}; function setText(id, value, fallback = "—") { byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value); @@ -80,6 +81,7 @@ async function showConsole() { loginView.hidden = true; consoleView.hidden = false; restoreContext(); + await loadAdvisoryPolicy(); const context = saveContext(); if (context["session-id"] && context["workspace-id"]) loadDashboard(); else loadActionHistory(); @@ -98,6 +100,8 @@ function endSession(message = "Сессия завершена.") { pendingNotification = null; lastAuthorizedActionID = ""; advisoryForm.reset(); + advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false}; + renderAdvisoryPolicy(); byId("advisory-result").hidden = true; notify(message); } @@ -155,6 +159,11 @@ advisoryForm.addEventListener("submit", async (event) => { return; } if (!message) return; + if (advisoryPolicy.AcknowledgementRequired && !byId("advisory-egress-ack").checked) { + notify("Подтвердите передачу этого вопроса внешнему провайдеру."); + byId("advisory-egress-ack").focus(); + return; + } button.disabled = true; button.firstElementChild.textContent = "Проверяю контекст…"; byId("advisory-result").hidden = true; @@ -162,23 +171,43 @@ advisoryForm.addEventListener("submit", async (event) => { 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"]}) + body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"], egress_acknowledged: advisoryPolicy.AcknowledgementRequired && byId("advisory-egress-ack").checked}) }); 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, "Ответ не получен."); + const unavailable = advisoryPolicy.ExternalEgress ? "Внешний провайдер недоступен. Ответ не получен." : "Когнитивный провайдер пока не подключён. Наружу ничего не отправлено."; + setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? unavailable : result.AdvisoryText, "Ответ не получен."); byId("advisory-result").hidden = false; } catch (error) { setText("advisory-state", "ОШИБКА"); setText("advisory-text", error.message); byId("advisory-result").hidden = false; } finally { + byId("advisory-egress-ack").checked = false; button.disabled = false; button.firstElementChild.textContent = "Получить совет"; } }); +async function loadAdvisoryPolicy() { + advisoryPolicy = await request("/api/advisory-policy"); + renderAdvisoryPolicy(); +} + +function renderAdvisoryPolicy() { + const external = advisoryPolicy.Mode === "openai-mode-a" && advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true; + byId("advisory-egress").hidden = !external; + byId("advisory-egress-ack").checked = false; + byId("advisory-egress-ack").required = external; + setText("advisory-privacy", external ? "в OpenAI передаётся только текст вопроса" : "локальный детерминированный режим"); + const privacy = byId("advisory-privacy"); + privacy.classList.toggle("external", external); + const marker = document.createElement("i"); + marker.setAttribute("aria-hidden", "true"); + privacy.prepend(marker); +} + async function loadDashboard() { const context = saveContext(); const sessionID = context["session-id"]; diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 5e76c22..8df3ea8 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -101,8 +101,16 @@
+
- только выбранный контекст + локальный детерминированный режим
diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 8e774ae..ba2bb24 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000052-promote-disabled-openai-gate.json", - "task_id": "DEV-000052", + "task_file": "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json", + "task_id": "DEV-000053", "task_revision": 1 } diff --git a/dev/tasks/DEV-000053-creator-openai-mode-a-routing.json b/dev/tasks/DEV-000053-creator-openai-mode-a-routing.json new file mode 100644 index 0000000..c627393 --- /dev/null +++ b/dev/tasks/DEV-000053-creator-openai-mode-a-routing.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000053", + "task_revision": 1, + "architecture_revision": "ADR-0007", + "base_repo_revision": "8318194cd076acb7243384fa9fe633712d9f358d", + "risk_tier": "E", + "intent": "Add server-selected Creator Web advisory routing for deterministic or OpenAI Mode A, expose an authenticated policy view, require per-request egress acknowledgement for OpenAI, and render a clear responsive retention disclosure while keeping the production default deterministic and provider selection unavailable to the browser.", + "requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000051", "INV-EPI-001"], + "allowed_change_scope": {"modules": [], "paths": ["cmd/creator-web/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]}, + "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", "ADR-0007", "CreatorSession", "Situation FocusBinding"], "may_change": []}, + "semantic_owner": "none-gateway-and-display", + "canonical_state": [], + "state_transitions": [], + "side_effects_and_privileges": ["server configuration may select the already accepted OpenAI Mode A internal endpoint", "browser may acknowledge egress for one request but cannot select provider/model/binding", "production default remains deterministic without configuration change"], + "persistence_and_migrations": [], + "failure_retry_restart": ["missing/invalid acknowledgement denies OpenAI request before cognitive call", "invalid configured mode fails startup", "advisory and acknowledgement are not persisted", "no automatic retry", "restart restores configured mode only"], + "assumptions": ["production does not set ADVISORY_BINDING_MODE and therefore remains deterministic", "OpenAI credential remains absent", "docker-test remains prohibited"], + "acceptance_scenarios": ["authenticated policy endpoint reports server-selected mode and immutable binding metadata", "deterministic mode retains current payload and behavior without acknowledgement", "OpenAI mode requires explicit per-request acknowledgement and routes only to fixed OpenAI endpoint/binding", "browser cannot supply mode/model/binding/retention policy", "completed and typed failure responses are validated fail closed", "UI displays retention/egress disclosure and checkbox only in OpenAI Mode A", "acknowledgement and message are not stored", "desktop/mobile layout and console remain 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_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index e8d74f6..27a3ec5 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,6 @@ # Assistant project status -Audit date: 2026-08-16. Active audit: DEV-000049 revision 1. +Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1. ## Delivered @@ -34,4 +34,6 @@ ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026 DEV-000051 adds an optional cognitive-worker runtime gate for that adapter. It is disabled when `OPENAI_API_KEY_FILE` is absent, rejects direct environment credentials, bounds and validates the secret file, and returns 503 before parsing an OpenAI request while disabled. Production configuration and Creator Web routing are unchanged, so the gate is not active and no provider egress occurs. +DEV-000052 promotes the disabled gate without adding a credential, mount or provider environment variable. DEV-000053 adds the server-selected Creator Web routing policy: deterministic behavior remains the default, while the accepted OpenAI Mode A route can only be selected by server configuration and requires a fresh per-request egress acknowledgement. The browser receives authenticated binding metadata for disclosure but cannot select the provider, model or binding. The message and acknowledgement are not persisted. + These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository. -- 2.54.0