Compare commits

...
Author SHA1 Message Date
m 1dc9f40595 test: audit production advisory boundary
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:30:12 +03:00
m 4de34ad8f8 Merge pull request 'DEV-000048 r2: honor advisory message size contract' (#21) from codex/creator-advisory-console-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:23:50 +00:00
m f6dba0d39b Merge remote-tracking branch 'origin/main' into codex/creator-advisory-console-v1
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:23:11 +03:00
m 59f5b4ce57 fix: honor advisory message size contract
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:22:27 +03:00
m 6440cbff81 Merge pull request 'DEV-000048: add Creator advisory console' (#20) from codex/creator-advisory-console-v1 into main
CI / test (push) Successful in 12s
2026-08-16 17:16:32 +00:00
m d0255558e2 feat: add creator advisory console
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-08-16 20:15:47 +03:00
m 861438a4ca Merge pull request 'DEV-000047: promote cognitive advisory fake' (#19) from codex/deploy-cognitive-advisory-fake-v1 into main
CI / test (push) Successful in 12s
2026-08-16 17:07:08 +00:00
m fbc6538d18 chore: authorize cognitive advisory promotion
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-08-16 20:06:36 +03:00
m 4b8832766a Merge pull request 'DEV-000046: add cognitive advisory contract' (#18) from codex/cognitive-advisory-contract-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:05:51 +00:00
13 changed files with 475 additions and 8 deletions
+1
View File
@@ -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
View File
@@ -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 {
+87
View File
@@ -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)
}
}
+25
View File
@@ -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; }
+37
View File
@@ -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"];
+20
View File
@@ -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>
+2 -1
View File
@@ -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
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000046-cognitive-advisory-contract.json",
"task_id": "DEV-000046",
"task_file": "dev/tasks/DEV-000049-post-advisory-production-audit.json",
"task_id": "DEV-000049",
"task_revision": 1
}
@@ -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"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000049",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "4de34ad8f8389d28b1120a3c6203ea32b8c7ff22",
"risk_tier": "E",
"intent": "Add and execute a repeatable read-only production audit for the authenticated Creator advisory boundary after DEV-000048 promotion, proving contract limits, authority-field rejection, explicit degraded behavior and cognitive-worker isolation without selecting or calling an external provider.",
"requirement_ids": ["ADR-0006", "DEV-000046", "DEV-000048", "INV-AUTH-001", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["tests/cognitive_advisory/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000049-post-advisory-production-audit.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "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": ["creator-advisory-v1", "CreatorSession", "Situation FocusBinding"], "may_change": []},
"semantic_owner": "none-audit-only",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["read production runtime/container metadata", "issue an ephemeral Creator session and advisory request", "no database mutation, external provider egress or credential persistence"],
"persistence_and_migrations": [],
"failure_retry_restart": ["audit fails closed on missing focus or credential", "audit never prints credentials or session tokens", "advisory content remains ephemeral and is not retried"],
"assumptions": ["production contains at least one active Creator-owned FocusBinding", "Creator credential remains mounted as a Docker secret", "docker-test remains prohibited"],
"acceptance_scenarios": ["authenticated 16 KiB advisory returns only PROVIDER_UNAVAILABLE with empty text and citations", "unauthenticated advisory is denied", "browser authority-field injection is rejected", "the audit sentinel is absent from cognitive-worker logs", "cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount", "Creator console exposes the advisory UI", "production revision and health remain valid"],
"required_tests": ["PowerShell parse", "architecture preflight", "exact diff scope", "production advisory audit", "production artifact confirmation", "production health", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
+5 -1
View File
@@ -1,6 +1,6 @@
# Assistant project status
Audit date: 2026-08-16. Active audit: DEV-000044 revision 3.
Audit date: 2026-08-16. Active audit: DEV-000049 revision 1.
## Delivered
@@ -26,4 +26,8 @@ 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.
DEV-000049 adds and executes a repeatable production audit for that boundary. It proves that an authenticated 16 KiB message reaches only the deterministic unavailable binding, unauthenticated and browser-supplied authority fields fail closed, the request sentinel is absent from cognitive-worker logs, and the cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount.
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
@@ -0,0 +1,86 @@
[CmdletBinding()]
param(
[string]$DockerContext = 'docker-prod',
[string]$BaseURL = 'https://assistent.cin.su',
[string]$CoreContainer = 'assistant-core-runtime-1',
[string]$PostgresContainer = 'assistant-postgres-1',
[string]$CognitiveContainer = 'assistant-cognitive-worker-1'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-ContainerEnvironment([string]$Container) {
$result = @{}
$inspect = ((& docker.exe --context $DockerContext inspect $Container) -join "`n") | ConvertFrom-Json
foreach ($entry in $inspect[0].Config.Env) {
$parts = $entry -split '=', 2
$result[$parts[0]] = if ($parts.Count -eq 2) { $parts[1] } else { '' }
}
return $result
}
function Invoke-StatusRequest([string]$Uri, [hashtable]$Headers, [string]$Body) {
$parameters = @{
Uri = $Uri
Method = 'Post'
ContentType = 'application/json'
SkipHttpErrorCheck = $true
}
if ($Headers.Count -gt 0) { $parameters.Headers = $Headers }
if ($Body) { $parameters.Body = $Body }
return Invoke-WebRequest @parameters
}
$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get
if ($health.status -ne 'ok' -or $health.runtime -ne 'creator-web') { throw 'Creator Web health is invalid' }
$page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get
if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' }
$coreEnvironment = Get-ContainerEnvironment $CoreContainer
$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
foreach ($name in $required) {
if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) {
throw "Core runtime is missing $name"
}
}
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' }
$sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest
$creatorPassword = $null
if (-not $issued.Token) { throw 'Creator session was not issued' }
$authorization = @{Authorization = "Bearer $($issued.Token)"}
$focusSql = "select session_id,workspace_id from situation.focus_bindings where active=true and actor_id='domovoy-creator-actor' order by session_id desc limit 1"
$focusLine = ((& docker.exe --context $DockerContext exec --env "PGPASSWORD=$($coreEnvironment['POSTGRES_PASSWORD'])" $PostgresContainer psql -U $coreEnvironment['POSTGRES_USER'] -d $coreEnvironment['POSTGRES_DATABASE'] -AtF '|' -c $focusSql) -join '').Trim()
if ($LASTEXITCODE -ne 0 -or $focusLine -notmatch '^[-a-zA-Z0-9._:]+\|[-a-zA-Z0-9._:]+$') { throw 'Active Creator focus is unavailable' }
$focus = $focusLine -split '\|', 2
$sentinelPrefix = "audit-$([guid]::NewGuid().ToString('N'))-"
$message = $sentinelPrefix + ('a' * ((16 * 1024) - $sentinelPrefix.Length))
$advisoryBody = @{message = $message; session_id = $focus[0]; workspace_id = $focus[1]} | ConvertTo-Json -Compress
$advisory = Invoke-RestMethod -Uri "$BaseURL/api/advisory" -Method Post -ContentType 'application/json' -Headers $authorization -Body $advisoryBody
if ($advisory.Version -ne 'creator-advisory-v1' -or $advisory.FinishReason -ne 'PROVIDER_UNAVAILABLE') { throw 'Advisory did not fail closed' }
if (-not [string]::IsNullOrEmpty([string]$advisory.AdvisoryText) -or ($null -ne $advisory.ReferencedExcerptIDs -and @($advisory.ReferencedExcerptIDs).Count -ne 0)) { throw 'Unavailable advisory contained generated content or citations' }
$unauthenticated = Invoke-StatusRequest "$BaseURL/api/advisory" @{} $advisoryBody
if ($unauthenticated.StatusCode -ne 401) { throw "Unauthenticated advisory status=$($unauthenticated.StatusCode)" }
$injectedBody = @{message = 'authority-injection'; session_id = $focus[0]; workspace_id = $focus[1]; ActorID = 'attacker'} | ConvertTo-Json -Compress
$injected = Invoke-StatusRequest "$BaseURL/api/advisory" $authorization $injectedBody
if ($injected.StatusCode -ne 400) { throw "Authority-field injection status=$($injected.StatusCode)" }
$cognitiveInspect = ((& docker.exe --context $DockerContext inspect $CognitiveContainer) -join "`n") | ConvertFrom-Json
$forbiddenEnvironment = @($cognitiveInspect[0].Config.Env | Where-Object { ($_ -split '=', 2)[0] -match '(?i)(POSTGRES|DATABASE|COMMIT|EXECUTOR|CREATOR|PASSWORD|TOKEN|PROVIDER|API_KEY)' })
if ($forbiddenEnvironment.Count -ne 0) { throw 'Cognitive worker has forbidden privileged/provider environment' }
$forbiddenMounts = @($cognitiveInspect[0].Mounts | Where-Object { $_.Destination -match '(?i)(secret|credential|token|postgres|docker)' })
if ($forbiddenMounts.Count -ne 0) { throw 'Cognitive worker has a forbidden credential or infrastructure mount' }
$recentLogs = ((& docker.exe --context $DockerContext logs --since 5m $CognitiveContainer 2>&1) -join "`n")
if ($recentLogs.Contains($sentinelPrefix)) { throw 'Advisory content leaked into cognitive-worker logs' }
$issued.Token = $null
$authorization.Clear()
$message = $null
$advisoryBody = $null
Write-Output 'DEV-000049 production Creator advisory boundary audit: PASS'