Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65d0f8f3c | ||
|
|
8318194cd0 | ||
|
|
bd38e1e2d3 | ||
|
|
61f349ec40 | ||
|
|
417641dbf4 | ||
|
|
0ee9e4a8a1 | ||
|
|
3b009033d6 | ||
|
|
00965f20e0 | ||
|
|
b61612f543 | ||
|
|
835c62cc1d | ||
|
|
9818f06e05 | ||
|
|
67390c8297 | ||
|
|
1dc9f40595 | ||
|
|
4de34ad8f8 | ||
|
|
f6dba0d39b | ||
|
|
59f5b4ce57 | ||
|
|
6440cbff81 | ||
|
|
d0255558e2 | ||
|
|
861438a4ca | ||
|
|
fbc6538d18 | ||
|
|
4b8832766a |
@@ -0,0 +1,269 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAIAdvisoryBindingID capability.CapabilityBindingID = "openai-responses-gpt-5.4-mini-2026-03-17"
|
||||
OpenAIAdvisoryBindingRevision int64 = 1
|
||||
openAIResponsesURL = "https://api.openai.com/v1/responses"
|
||||
openAIModel = "gpt-5.4-mini-2026-03-17"
|
||||
openAIMaxOutputTokens = 4096
|
||||
openAIResponseLimitBytes = 1 << 20
|
||||
openAITimeout = 30 * time.Second
|
||||
openAIInstructions = "Provide non-canonical advisory text only. State uncertainty when appropriate. Never claim that an action occurred, never request or expose secrets, and never emit tool or executable instructions."
|
||||
)
|
||||
|
||||
type OpenAIAdvisoryAdapter struct {
|
||||
apiKey []byte
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAIAdvisoryAdapter(apiKey []byte, transport http.RoundTripper) (*OpenAIAdvisoryAdapter, error) {
|
||||
if len(apiKey) == 0 || bytes.IndexAny(apiKey, "\r\n") >= 0 {
|
||||
return nil, errors.New("invalid OpenAI credential")
|
||||
}
|
||||
if transport == nil {
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("default HTTP transport is unavailable")
|
||||
}
|
||||
cloned := base.Clone()
|
||||
cloned.Proxy = nil
|
||||
transport = cloned
|
||||
}
|
||||
return &OpenAIAdvisoryAdapter{
|
||||
apiKey: append([]byte(nil), apiKey...),
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: openAITimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type openAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
Input string `json:"input"`
|
||||
Store bool `json:"store"`
|
||||
Reasoning openAIReasoning `json:"reasoning"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
}
|
||||
|
||||
type openAIReasoning struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
|
||||
type openAIResponse struct {
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
Store *bool `json:"store"`
|
||||
Output []json.RawMessage `json:"output"`
|
||||
Usage *openAIUsage `json:"usage"`
|
||||
IncompleteDetails *openAIIncompleteDetails `json:"incomplete_details"`
|
||||
}
|
||||
|
||||
type openAIIncompleteDetails struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type openAIUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type openAIOutputHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Content []json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
type openAIContentHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type openAIOutputText struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Annotations []json.RawMessage `json:"annotations"`
|
||||
}
|
||||
|
||||
type openAIRefusal struct {
|
||||
Type string `json:"type"`
|
||||
Refusal string `json:"refusal"`
|
||||
}
|
||||
|
||||
type openAIReasoningItem struct {
|
||||
Type string `json:"type"`
|
||||
Summary []json.RawMessage `json:"summary"`
|
||||
}
|
||||
|
||||
func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
if request.ProviderBindingID != OpenAIAdvisoryBindingID || request.ProviderBindingRevision != OpenAIAdvisoryBindingRevision || len(request.Context) != 0 {
|
||||
return cognitive.AdvisoryResponse{}, fmt.Errorf("%w: OpenAI Mode A binding", ErrInvalidAdvisory)
|
||||
}
|
||||
payload, err := json.Marshal(openAIRequest{
|
||||
Model: openAIModel,
|
||||
Instructions: openAIInstructions,
|
||||
Input: request.Message,
|
||||
Store: false,
|
||||
Reasoning: openAIReasoning{Effort: "low"},
|
||||
MaxOutputTokens: openAIMaxOutputTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, openAIResponsesURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+string(a.apiKey))
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResponse, err := a.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return advisoryFailure(request, cognitive.FinishTimeout), nil
|
||||
}
|
||||
var networkError net.Error
|
||||
if errors.As(err, &networkError) && networkError.Timeout() {
|
||||
return advisoryFailure(request, cognitive.FinishTimeout), nil
|
||||
}
|
||||
return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil
|
||||
}
|
||||
defer httpResponse.Body.Close()
|
||||
if httpResponse.StatusCode != http.StatusOK {
|
||||
return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil
|
||||
}
|
||||
if contentType := httpResponse.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "application/json") {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(httpResponse.Body, openAIResponseLimitBytes+1))
|
||||
if err != nil || len(body) > openAIResponseLimitBytes {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
var providerResponse openAIResponse
|
||||
if err := json.Unmarshal(body, &providerResponse); err != nil || providerResponse.Model != openAIModel || (providerResponse.Store != nil && *providerResponse.Store) || providerResponse.Usage == nil || providerResponse.Usage.InputTokens < 0 || providerResponse.Usage.OutputTokens < 0 {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
if providerResponse.Status == "incomplete" && providerResponse.IncompleteDetails != nil && providerResponse.IncompleteDetails.Reason == "max_output_tokens" {
|
||||
return advisoryFailure(request, cognitive.FinishLengthLimit), nil
|
||||
}
|
||||
if providerResponse.Status != "completed" || providerResponse.IncompleteDetails != nil {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
|
||||
text, refused, ok := extractOpenAIText(providerResponse.Output)
|
||||
if !ok {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
if refused {
|
||||
return advisoryFailure(request, cognitive.FinishContentRejected), nil
|
||||
}
|
||||
response := cognitive.AdvisoryResponse{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: request.RequestID,
|
||||
ProviderBindingID: request.ProviderBindingID,
|
||||
ProviderBindingRevision: request.ProviderBindingRevision,
|
||||
AdvisoryText: text,
|
||||
FinishReason: cognitive.FinishCompleted,
|
||||
Usage: &cognitive.ProviderUsage{
|
||||
InputUnits: providerResponse.Usage.InputTokens,
|
||||
OutputUnits: providerResponse.Usage.OutputTokens,
|
||||
},
|
||||
}
|
||||
if err := ValidateAdvisoryResponse(request, response); err != nil {
|
||||
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func extractOpenAIText(output []json.RawMessage) (string, bool, bool) {
|
||||
var text string
|
||||
refused := false
|
||||
messages := 0
|
||||
for _, raw := range output {
|
||||
var header openAIOutputHeader
|
||||
if json.Unmarshal(raw, &header) != nil {
|
||||
return "", false, false
|
||||
}
|
||||
switch header.Type {
|
||||
case "reasoning":
|
||||
var item openAIReasoningItem
|
||||
if json.Unmarshal(raw, &item) != nil || len(item.Summary) != 0 {
|
||||
return "", false, false
|
||||
}
|
||||
case "message":
|
||||
messages++
|
||||
if messages != 1 {
|
||||
return "", false, false
|
||||
}
|
||||
var message openAIMessage
|
||||
if json.Unmarshal(raw, &message) != nil || message.Role != "assistant" || message.Status != "completed" || len(message.Content) != 1 {
|
||||
return "", false, false
|
||||
}
|
||||
var contentHeader openAIContentHeader
|
||||
if json.Unmarshal(message.Content[0], &contentHeader) != nil {
|
||||
return "", false, false
|
||||
}
|
||||
switch contentHeader.Type {
|
||||
case "output_text":
|
||||
var content openAIOutputText
|
||||
if json.Unmarshal(message.Content[0], &content) != nil || content.Text == "" || !utf8.ValidString(content.Text) || len(content.Annotations) != 0 {
|
||||
return "", false, false
|
||||
}
|
||||
text = content.Text
|
||||
case "refusal":
|
||||
var content openAIRefusal
|
||||
if json.Unmarshal(message.Content[0], &content) != nil || content.Refusal == "" {
|
||||
return "", false, false
|
||||
}
|
||||
refused = true
|
||||
default:
|
||||
return "", false, false
|
||||
}
|
||||
default:
|
||||
return "", false, false
|
||||
}
|
||||
}
|
||||
if messages != 1 || (refused && text != "") || (!refused && text == "") {
|
||||
return "", false, false
|
||||
}
|
||||
return text, refused, true
|
||||
}
|
||||
|
||||
func advisoryFailure(request cognitive.AdvisoryRequest, reason cognitive.FinishReason) cognitive.AdvisoryResponse {
|
||||
return cognitive.AdvisoryResponse{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: request.RequestID,
|
||||
ProviderBindingID: request.ProviderBindingID,
|
||||
ProviderBindingRevision: request.ProviderBindingRevision,
|
||||
FinishReason: reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func validOpenAIAdvisoryRequest() cognitive.AdvisoryRequest {
|
||||
return cognitive.AdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-1",
|
||||
ActorID: identity.ActorID("actor-private"),
|
||||
PrincipalID: identity.PrincipalID("principal-private"),
|
||||
ScopeID: authority.ScopeID("scope-private"),
|
||||
Message: "What should I verify?",
|
||||
Locale: "en",
|
||||
MaxOutputBytes: MaxAdvisoryResponseBytes,
|
||||
ProviderBindingID: OpenAIAdvisoryBindingID,
|
||||
ProviderBindingRevision: OpenAIAdvisoryBindingRevision,
|
||||
}
|
||||
}
|
||||
|
||||
func providerHTTPResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func validOpenAIResponse(text string) string {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"status": "completed",
|
||||
"model": openAIModel,
|
||||
"store": false,
|
||||
"output": []any{
|
||||
map[string]any{"type": "reasoning", "summary": []any{}},
|
||||
map[string]any{"type": "message", "role": "assistant", "status": "completed", "content": []any{
|
||||
map[string]any{"type": "output_text", "text": text, "annotations": []any{}},
|
||||
}},
|
||||
},
|
||||
"usage": map[string]any{"input_tokens": 12, "output_tokens": 7},
|
||||
})
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisorySendsOnlyModeAPayload(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if request.Method != http.MethodPost || request.URL.String() != openAIResponsesURL || request.Host != "api.openai.com" {
|
||||
t.Fatalf("unexpected target: %s %s host=%s", request.Method, request.URL, request.Host)
|
||||
}
|
||||
if request.Header.Get("Authorization") != "Bearer test-key" || request.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatal("provider authentication or content type is missing")
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload) != 6 || payload["model"] != openAIModel || payload["input"] != "What should I verify?" || payload["store"] != false || payload["instructions"] != openAIInstructions || payload["max_output_tokens"] != float64(openAIMaxOutputTokens) {
|
||||
t.Fatalf("unexpected provider payload keys/values: %v", payload)
|
||||
}
|
||||
reasoning, ok := payload["reasoning"].(map[string]any)
|
||||
if !ok || len(reasoning) != 1 || reasoning["effort"] != "low" {
|
||||
t.Fatalf("unexpected reasoning payload: %v", payload["reasoning"])
|
||||
}
|
||||
serialized := string(body)
|
||||
for _, forbidden := range []string{"actor-private", "principal-private", "scope-private", "request-1", "tools", "previous_response_id", "conversation", "background"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("provider payload leaked forbidden field %q", forbidden)
|
||||
}
|
||||
}
|
||||
return providerHTTPResponse(http.StatusOK, validOpenAIResponse("Check the current evidence.")), nil
|
||||
})
|
||||
adapter, err := NewOpenAIAdvisoryAdapter([]byte("test-key"), transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || response.FinishReason != cognitive.FinishCompleted || response.AdvisoryText != "Check the current evidence." || response.Usage == nil || response.Usage.InputUnits != 12 || response.Usage.OutputUnits != 7 {
|
||||
t.Fatalf("unexpected response: calls=%d response=%+v", calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsContextBeforeTransport(t *testing.T) {
|
||||
called := false
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return nil, errors.New("must not call")
|
||||
}))
|
||||
request := validOpenAIAdvisoryRequest()
|
||||
request.Context = []cognitive.ContextExcerpt{{ID: "excerpt", SourceKind: "task", SourceID: "task", Revision: 1, Sensitivity: cognitive.SensitivityPublic, Text: "public"}}
|
||||
if _, err := adapter.Advise(context.Background(), request); err == nil || called {
|
||||
t.Fatalf("error=%v called=%t", err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryDoesNotFollowRedirectOrRetry(t *testing.T) {
|
||||
calls := 0
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
response := providerHTTPResponse(http.StatusFound, "{}")
|
||||
response.Header.Set("Location", "https://attacker.invalid/steal")
|
||||
return response, nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishProviderUnavailable {
|
||||
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (timeoutError) Error() string { return "timeout" }
|
||||
func (timeoutError) Timeout() bool { return true }
|
||||
func (timeoutError) Temporary() bool { return true }
|
||||
|
||||
func TestOpenAIAdvisoryMapsTimeoutAndDoesNotRetry(t *testing.T) {
|
||||
calls := 0
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return nil, timeoutError{}
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishTimeout {
|
||||
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryMapsRefusal(t *testing.T) {
|
||||
body := `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":"cannot comply"}]}],"usage":{"input_tokens":3,"output_tokens":0}}`
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishContentRejected || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryMapsOutputLimit(t *testing.T) {
|
||||
body := `{"status":"incomplete","model":"gpt-5.4-mini-2026-03-17","store":false,"incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":3,"output_tokens":4096}}`
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishLengthLimit || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsExecutableOrUntrustedOutput(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"tool call": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"function_call","name":"mutate"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
"citation": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer","annotations":[{"type":"url_citation"}]}]}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
"wrong model": strings.Replace(validOpenAIResponse("answer"), openAIModel, "moving-alias", 1),
|
||||
"oversize": validOpenAIResponse(strings.Repeat("a", MaxAdvisoryResponseBytes+1)),
|
||||
"malformed": `{`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishInvalidProviderResponse || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsInvalidCredential(t *testing.T) {
|
||||
for _, key := range [][]byte{nil, []byte("line\nbreak")} {
|
||||
if _, err := NewOpenAIAdvisoryAdapter(key, nil); err == nil {
|
||||
t.Fatalf("credential %q was accepted", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR-0007 — OpenAI Creator advisory binding
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-16
|
||||
Accepted under: ARCH-000010
|
||||
|
||||
## Decision
|
||||
|
||||
1. The first real provider class for `creator-advisory-v1` is OpenAI at the fixed HTTPS origin `https://api.openai.com`, using `POST /v1/responses`.
|
||||
2. Binding revision 1 targets the immutable model snapshot `gpt-5.4-mini-2026-03-17` with reasoning effort `low`. An alias or another snapshot is a different binding revision and receives no inherited promotion.
|
||||
3. Requests set `store: false`, are synchronous and single-turn, and contain no tools, functions, conversations, background mode, streaming, files, images, web/file search, MCP, hosted execution, `previous_response_id` or provider memory.
|
||||
4. Mode A permits only the Creator message explicitly entered for that request plus a fixed data-free system instruction. Automatic excerpts and `PRIVATE_ALLOWED` context are prohibited until a separately evidenced ZDR/MAM binding revision is accepted and promoted.
|
||||
5. Before each Mode A request the Creator UI must disclose that the message leaves the private network and provider abuse-monitoring retention may be up to 30 days. Submission acknowledges that request only and is not durable blanket consent.
|
||||
6. `SECRET`, `UNKNOWN`, credentials, authorization/routing identifiers and ambient context never enter the provider payload. Trusted Actor, Principal, Scope, session, workspace, task and binding identifiers remain local authorization/routing data and are not model context.
|
||||
7. The API key belongs to a dedicated OpenAI project and may be mounted read-only only into `cognitive-worker` as a secret file. It is absent from Git, databases, images, environment variables, logs, browser state and other containers.
|
||||
8. Adapter implementation must use the standard library, fixed origin, normal TLS verification, disabled redirects and ambient proxies, strict request/response types, a 30-second deadline, no automatic retry and no request/response body logging.
|
||||
9. Provider output is advisory text only. Tool/executable/unknown output items, invalid JSON, origin changes, oversize output or malformed text fail closed and are never partially displayed.
|
||||
10. Initial canary limits are one in-flight request, five accepted requests per minute and fifty per day for the single Creator. A provider project spend limit and alert are required before any production key is mounted.
|
||||
11. Production activation requires adversarial fake-server tests, a synthetic/public evaluation record, shadow evidence and explicit promotion of the exact binding revision. This ADR alone does not authorize a real key, network egress or production call.
|
||||
|
||||
## Rationale
|
||||
|
||||
The existing provider-neutral boundary makes cognition replaceable and non-canonical. A dated snapshot preserves reproducibility and prevents silent model drift. Mode A enables a narrow explicit advisory use while making the provider's default retention boundary visible and forbidding automatic private context. Isolation and fail-closed mapping preserve the Assistant when cognition is absent or malformed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A provider adapter may be implemented under `adapters/models` and exercised only against adversarial local fake servers before promotion.
|
||||
- The cognitive worker still has no canonical owner, database role, Commit/Executor credential or external mutation path.
|
||||
- Existing `creator-advisory-v1` contracts do not change.
|
||||
- The deterministic fake remains the only production binding until a separate evaluation/credential/promotion task succeeds.
|
||||
- A ChatGPT or Codex subscription does not substitute for a dedicated API project, API billing controls or API key.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable or retire the OpenAI binding and resolve all requests to `deterministic-fake`, returning `PROVIDER_UNAVAILABLE`. No canonical repair or migration is needed because advisory request/response state is not persisted.
|
||||
|
||||
## Revisit triggers
|
||||
|
||||
- OpenAI publishes or withdraws a model snapshot.
|
||||
- ZDR/MAM is approved for the exact API project.
|
||||
- Context excerpts, conversation persistence, tools, streaming or multimodal input are requested.
|
||||
- Evaluation shows the snapshot, effort, limits, latency or cost are unsuitable.
|
||||
- Provider policy, retention, region, endpoint or origin changes.
|
||||
@@ -4,4 +4,4 @@ Accepted architecture decisions live here. Normal implementation tasks may read
|
||||
|
||||
Use `ADR-XXXX-title.md` with: problem, evidence, alternatives, decision, rationale, consequences, validity conditions, revisit triggers, migration/rollback, status.
|
||||
|
||||
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary.
|
||||
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary, and `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class.
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type stubAdvisoryProvider struct {
|
||||
called bool
|
||||
request cognitive.AdvisoryRequest
|
||||
response cognitive.AdvisoryResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubAdvisoryProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
s.called = true
|
||||
s.request = request
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func TestDeterministicAdvisoryRejectsUnknownJSONField(t *testing.T) {
|
||||
body := `{"Version":"creator-advisory-v1","Unexpected":"provider override"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
|
||||
@@ -36,3 +53,90 @@ func TestDeterministicAdvisoryReturnsUnavailableContract(t *testing.T) {
|
||||
t.Fatalf("unexpected response: %+v", advisory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryIsUnavailableBeforeBodyParsingWhenDisabled(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`not-json`))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(nil)(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryStrictlyDecodesBeforeProvider(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`{"Version":"creator-advisory-v1","Unexpected":"provider override"}`))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(provider)(response, request)
|
||||
if response.Code != http.StatusBadRequest || provider.called {
|
||||
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryReturnsProviderNeutralResponse(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-1",
|
||||
ProviderBindingID: models.OpenAIAdvisoryBindingID,
|
||||
ProviderBindingRevision: models.OpenAIAdvisoryBindingRevision,
|
||||
AdvisoryText: "advice",
|
||||
FinishReason: cognitive.FinishCompleted,
|
||||
}}
|
||||
body := `{"Version":"creator-advisory-v1","RequestID":"request-1","ActorID":"actor","PrincipalID":"principal","ScopeID":"scope","Message":"help","Context":[],"Locale":"en","MaxOutputBytes":4096,"ProviderBindingID":"openai-responses-gpt-5.4-mini-2026-03-17","ProviderBindingRevision":1}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(provider)(response, request)
|
||||
if response.Code != http.StatusOK || !provider.called || provider.request.Message != "help" {
|
||||
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
|
||||
}
|
||||
var advisory cognitive.AdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&advisory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if advisory.AdvisoryText != "advice" || advisory.FinishReason != cognitive.FinishCompleted {
|
||||
t.Fatalf("unexpected response: %+v", advisory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderDefaultsDisabled(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
provider, err := loadOpenAIAdvisoryProvider("")
|
||||
if err != nil || provider != nil {
|
||||
t.Fatalf("provider=%v error=%v", provider, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderRejectsDirectEnvironmentCredential(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "must-not-be-read")
|
||||
if _, err := loadOpenAIAdvisoryProvider(""); err == nil || strings.Contains(err.Error(), "must-not-be-read") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderUsesBoundedSecretFile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
directory := t.TempDir()
|
||||
validPath := filepath.Join(directory, "valid")
|
||||
if err := os.WriteFile(validPath, []byte("test-project-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, err := loadOpenAIAdvisoryProvider(validPath)
|
||||
if err != nil || provider == nil {
|
||||
t.Fatalf("provider=%v error=%v", provider, err)
|
||||
}
|
||||
for name, content := range map[string][]byte{
|
||||
"empty": nil,
|
||||
"embedded whitespace": []byte("invalid key"),
|
||||
"oversized": []byte(strings.Repeat("x", maxOpenAISecretBytes+1)),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadOpenAIAdvisoryProvider(path); err == nil {
|
||||
t.Fatal("invalid secret file was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
@@ -11,15 +15,26 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxOpenAISecretBytes = 8 << 10
|
||||
|
||||
type advisoryProvider interface {
|
||||
Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8083"
|
||||
}
|
||||
openAIProvider, err := loadOpenAIAdvisoryProvider(os.Getenv("OPENAI_API_KEY_FILE"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
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)
|
||||
mux.HandleFunc("POST /v1/advisories/openai", openAIAdvisory(openAIProvider))
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -27,16 +42,8 @@ func main() {
|
||||
}
|
||||
|
||||
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)
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := models.DeterministicAdvisoryUnavailable(request)
|
||||
@@ -48,6 +55,78 @@ func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func openAIAdvisory(provider advisoryProvider) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if provider == nil {
|
||||
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := provider.Advise(r.Context(), 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 decodeAdvisoryRequest(w http.ResponseWriter, r *http.Request) (cognitive.AdvisoryRequest, bool) {
|
||||
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 cognitive.AdvisoryRequest{}, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return cognitive.AdvisoryRequest{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func loadOpenAIAdvisoryProvider(secretPath string) (advisoryProvider, error) {
|
||||
if os.Getenv("OPENAI_API_KEY") != "" {
|
||||
return nil, errors.New("direct OpenAI credential environment is forbidden; use OPENAI_API_KEY_FILE")
|
||||
}
|
||||
if secretPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
file, err := os.Open(secretPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open OpenAI credential file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxOpenAISecretBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read OpenAI credential file: %w", err)
|
||||
}
|
||||
defer clear(raw)
|
||||
if len(raw) > maxOpenAISecretBytes {
|
||||
return nil, errors.New("OpenAI credential file is oversized")
|
||||
}
|
||||
key := bytes.TrimSpace(raw)
|
||||
if len(key) == 0 {
|
||||
return nil, errors.New("OpenAI credential file is empty")
|
||||
}
|
||||
for _, character := range key {
|
||||
if character < 0x21 || character > 0x7e {
|
||||
return nil, errors.New("OpenAI credential file contains invalid characters")
|
||||
}
|
||||
}
|
||||
provider, err := models.NewOpenAIAdvisoryAdapter(key, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("OpenAI credential file is invalid")
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func deterministicProposal(w http.ResponseWriter, r *http.Request) {
|
||||
var request models.DeterministicRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -9,11 +9,13 @@ 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 {
|
||||
log.Fatal(err)
|
||||
|
||||
+238
-9
@@ -9,22 +9,36 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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
|
||||
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
|
||||
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
|
||||
@@ -35,7 +49,14 @@ 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) {
|
||||
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
|
||||
@@ -48,6 +69,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 +84,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, advisoryMode: advisoryMode, client: client, static: http.FileServer(http.FS(assets))}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -73,6 +102,14 @@ 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
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +123,198 @@ 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"`
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
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 || !validAdvisoryResponse(advisory, trusted, g.advisoryMode) {
|
||||
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 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
|
||||
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,8 @@ 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(), "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)
|
||||
@@ -230,3 +236,215 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
t.Fatal("content security policy is missing")
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
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,34 @@ 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-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; }
|
||||
.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 +256,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 +275,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"];
|
||||
@@ -13,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);
|
||||
@@ -79,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();
|
||||
@@ -96,6 +99,10 @@ function endSession(message = "Сессия завершена.") {
|
||||
pendingCommand = null;
|
||||
pendingNotification = null;
|
||||
lastAuthorizedActionID = "";
|
||||
advisoryForm.reset();
|
||||
advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
|
||||
renderAdvisoryPolicy();
|
||||
byId("advisory-result").hidden = true;
|
||||
notify(message);
|
||||
}
|
||||
|
||||
@@ -142,6 +149,65 @@ 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;
|
||||
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;
|
||||
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"], 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;
|
||||
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"];
|
||||
|
||||
@@ -92,6 +92,34 @@
|
||||
<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 id="advisory-egress" class="advisor-egress" hidden>
|
||||
<p class="advisor-egress-kicker">ВНЕШНИЙ ПРОВАЙДЕР · MODE A</p>
|
||||
<p>Текст этого вопроса будет отправлен в OpenAI и может храниться до 30 дней для мониторинга злоупотреблений. Контекст, идентификаторы и секреты Домового не передаются.</p>
|
||||
<label for="advisory-egress-ack">
|
||||
<input id="advisory-egress-ack" type="checkbox">
|
||||
<span>Подтверждаю передачу этого вопроса в OpenAI</span>
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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/DEV-000046-cognitive-advisory-contract.json",
|
||||
"task_id": "DEV-000046",
|
||||
"task_file": "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json",
|
||||
"task_id": "DEV-000053",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "ARCH-000009",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0006",
|
||||
"base_repo_revision": "67390c82976aa9ff69c9de5a88e974836c89bc87",
|
||||
"risk_tier": "E",
|
||||
"intent": "Produce a proposal-only decision for an initial OpenAI Responses API binding for Creator advisory, with an immutable model snapshot, explicit default-retention disclosure, no tools or provider memory, bounded data egress, isolated credential, evaluation/canary gates and fail-closed rollback.",
|
||||
"requirement_ids": ["ADR-0006", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["docs/OPENAI_ADVISORY_BINDING_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000009-openai-advisory-binding-proposal.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "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-0006"], "may_change": []},
|
||||
"semantic_owner": "none-proposal-only",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["select this proposal in the active task pointer", "no API key creation, provider call, egress, production mutation or ADR acceptance"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["proposal preserves deterministic PROVIDER_UNAVAILABLE fallback", "no automatic retry", "provider timeout or invalid response cannot fabricate advisory text"],
|
||||
"assumptions": ["OpenAI is the recommended initial provider but is not accepted by this proposal alone", "default API abuse-monitoring retention may be up to 30 days unless the account is approved for ZDR/MAM", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["proposal names fixed API origin, endpoint and immutable model snapshot", "proposal defines exact request features and forbidden tools/state", "default-retention and ZDR behavior are explicit", "data classes permitted before and after ZDR are separated", "credential isolation and log redaction are explicit", "evaluation, canary, spend/rate, health and rollback gates are defined", "implementation and production egress remain unapproved"],
|
||||
"required_tests": ["official-source verification", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "ARCH-000010",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0006",
|
||||
"base_repo_revision": "835c62cc1d63348b11e0035ceda68965be0d760a",
|
||||
"risk_tier": "E",
|
||||
"intent": "Accept the ARCH-000009 recommendation as the first real Creator advisory provider binding class: OpenAI Responses API, immutable gpt-5.4-mini snapshot and Mode A per-request disclosure, while keeping production egress and credential activation subject to separate implementation, evaluation and promotion tasks.",
|
||||
"requirement_ids": ["ADR-0006", "ARCH-000009", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["architecture/adrs/ADR-0007-openai-advisory-binding.md", "architecture/adrs/README.md", "docs/OPENAI_ADVISORY_BINDING_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000010-accept-openai-advisory-binding.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/CORE_SEMANTIC_MODEL_V1.md", "architecture/architecture-registry.*", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "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-0006", "ARCH-000009 proposal"], "may_change": []},
|
||||
"semantic_owner": "none-architecture-decision",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["accept ADR-0007", "authorize a separately scoped fake-server adapter implementation", "no API key, provider call, egress or production activation"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["deterministic fake remains production fallback", "no automatic provider retry", "invalid or unavailable provider response fails closed"],
|
||||
"assumptions": ["the Creator's continuation after the explicit recommendation accepts OpenAI, the fixed snapshot and Mode A retention disclosure", "a later production promotion still requires a dedicated OpenAI project/key and spend controls", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["ADR fixes provider origin, endpoint, model snapshot and Mode A data policy", "tools, provider memory and automatic context remain forbidden", "implementation may use only adversarial local fake transport until a later promotion task", "real key and production egress remain disabled", "rollback preserves deterministic PROVIDER_UNAVAILABLE"],
|
||||
"required_tests": ["architecture preflight", "exact diff scope", "CI", "cold-readable ADR review"],
|
||||
"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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000050",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0007",
|
||||
"base_repo_revision": "00965f20e0c9a0e15a9444c72c4208f328834e08",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement the fixed OpenAI Responses API Creator advisory adapter with standard-library HTTP and adversarial fake-transport tests, without wiring runtime configuration, adding a real credential, enabling external egress or changing the provider-neutral contract.",
|
||||
"requirement_ids": ["ADR-0006", "ADR-0007", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["adapters/models/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000050-openai-advisory-adapter.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "cmd/**", "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"], "may_change": []},
|
||||
"semantic_owner": "none-provider-adapter",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["adapter can construct one fixed-origin HTTPS request when invoked by a later authorized runtime task", "this task invokes only local fake transports and grants no runtime/provider credential or production egress"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["one call and no retry", "deadline maps to TIMEOUT", "transport/status failure maps to PROVIDER_UNAVAILABLE", "refusal maps to CONTENT_REJECTED", "invalid or executable provider response maps to INVALID_PROVIDER_RESPONSE", "no request or response is persisted"],
|
||||
"assumptions": ["Mode A permits message-only payload and forbids all context excerpts", "fixed binding is openai-responses-gpt-5.4-mini-2026-03-17 revision 1", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["request uses only fixed origin/path/model/instructions/message/store=false/reasoning low and output limit", "authority/routing fields and context are absent from provider JSON", "redirects and ambient proxies are disabled", "unknown/tool output, citation annotations, wrong model and oversize/malformed responses fail closed", "refusal, timeout and unavailable mappings are explicit", "tests prove no automatic retry and no body logging", "no SDK or third-party dependency is added", "runtime remains deterministic fake only"],
|
||||
"required_tests": ["unit", "adversarial fake transport", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000051",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0007",
|
||||
"base_repo_revision": "0ee9e4a8a11e4bf216007d288b07d74b4049cac5",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a fail-closed optional OpenAI advisory endpoint to cognitive-worker that can only initialize from a bounded secret file and remains unavailable by default, with no production compose, credential, egress or active Creator Web binding change.",
|
||||
"requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000050", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["cmd/cognitive-worker/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000051-openai-advisory-runtime-gate.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/creator-web/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["creator-advisory-v1", "OpenAIAdvisoryAdapter", "ADR-0007"], "may_change": []},
|
||||
"semantic_owner": "none-cognitive-runtime-gate",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["cognitive-worker code may read one explicitly configured OpenAI secret file and invoke the fixed adapter when a later deployment mounts it", "current production compose and Creator Web binding remain unchanged, so no provider call is activated"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["missing configuration keeps endpoint unavailable", "configured unreadable/empty/oversized secret fails startup", "invalid request fails before provider", "provider failures remain typed and are not retried", "restart loses no advisory state"],
|
||||
"assumptions": ["the OpenAI secret file is absent from current production", "Creator Web continues to call deterministic endpoint", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["default startup has no provider and deterministic fake remains usable", "OpenAI endpoint returns 503 before parsing/requesting when provider is disabled", "only a bounded secret file can initialize the adapter", "direct environment credential is unsupported", "strict request decoding and provider-neutral response encoding are preserved", "health/log responses expose no secret", "no deploy or Creator Web path changes activate egress"],
|
||||
"required_tests": ["unit", "handler adversarial", "secret-file boundary", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000052",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0007",
|
||||
"base_repo_revision": "61f349ec409ea399149184ba3bdef79e52409ba0",
|
||||
"risk_tier": "E",
|
||||
"intent": "Promote the exact merged disabled-by-default OpenAI runtime gate to docker-prod, refreshing all project image provenance while proving no OpenAI credential/mount/egress activation, the OpenAI endpoint remains unavailable and deterministic advisory plus production health remain intact.",
|
||||
"requirement_ids": ["ADR-0007", "DEV-000045", "DEV-000051", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000052-promote-disabled-openai-gate.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "docs/**", "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", "production artifact provenance"], "may_change": []},
|
||||
"semantic_owner": "none-promotion-only",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["rebuild and replace the seven project-owned containers on docker-prod from exact merged main", "refresh external deployment manifests", "no OpenAI secret, provider call, egress route or Creator Web binding"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["existing canonical volumes and secrets remain mounted unchanged", "failed promotion does not publish a new manifest", "OpenAI endpoint remains 503 and deterministic advisory remains available after restart"],
|
||||
"assumptions": ["current production contains no OPENAI_API_KEY or OPENAI_API_KEY_FILE configuration", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["all seven project images carry exact merged main revision", "production health remains healthy", "OpenAI endpoint returns 503", "deterministic advisory returns PROVIDER_UNAVAILABLE", "cognitive worker has no OpenAI/provider credential environment or mount", "Creator Web continues routing to deterministic endpoint"],
|
||||
"required_tests": ["architecture preflight", "exact diff scope", "CI", "exact artifact confirmation", "production health", "disabled OpenAI endpoint smoke", "deterministic advisory smoke", "cognitive isolation inspection"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# OpenAI advisory binding proposal V1
|
||||
|
||||
Status: **ACCEPTED by ADR-0007 — not production-enabled**
|
||||
|
||||
Task: `ARCH-000009` revision 1
|
||||
Parent boundary: `ADR-0006`
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use OpenAI only as the first replaceable provider behind the existing `creator-advisory-v1` Cognitive Gateway boundary:
|
||||
|
||||
- fixed origin: `https://api.openai.com`;
|
||||
- endpoint: `POST /v1/responses`;
|
||||
- model snapshot: `gpt-5.4-mini-2026-03-17`;
|
||||
- reasoning effort: `low` for the initial evaluation and canary;
|
||||
- `store: false`, synchronous response only, no background mode;
|
||||
- no tools, function calls, web/file search, MCP, code interpreter, hosted shell, images, audio, files, conversations or `previous_response_id`;
|
||||
- one request produces one ephemeral advisory response and no provider-side memory.
|
||||
|
||||
OpenAI's current guidance recommends the Responses API for modern reasoning workflows and describes `gpt-5.6-terra` as the balance of capability and cost. This proposal nevertheless selects the published immutable `gpt-5.4-mini-2026-03-17` snapshot: ADR-0006 requires a model revision not to inherit promotion silently, while the current Terra page does not publish a dated snapshot. A later Terra snapshot requires a new binding revision and evaluation rather than alias drift.
|
||||
|
||||
Official sources checked on 2026-08-16:
|
||||
|
||||
- [OpenAI model guidance](https://developers.openai.com/api/docs/guides/latest-model)
|
||||
- [GPT-5.4 mini model and snapshot](https://developers.openai.com/api/docs/models/gpt-5.4-mini)
|
||||
- [OpenAI API data controls](https://developers.openai.com/api/docs/guides/your-data#default-usage-policies-by-endpoint)
|
||||
|
||||
## Data decision
|
||||
|
||||
OpenAI states that API data is not used for training unless the customer opts in. Under default controls, however, abuse-monitoring logs can include prompts and responses and may be retained for up to 30 days. Zero Data Retention and Modified Abuse Monitoring require OpenAI approval. `store: false` avoids Responses application-state storage but does not by itself remove default abuse-monitoring retention.
|
||||
|
||||
Therefore the initial binding has two distinct, non-interchangeable modes:
|
||||
|
||||
### Mode A — default retention, first canary
|
||||
|
||||
Permitted:
|
||||
|
||||
- the Creator's text entered explicitly for this single advisory request;
|
||||
- a fixed system instruction containing no user or project data;
|
||||
- locale and output-size controls.
|
||||
|
||||
Forbidden:
|
||||
|
||||
- automatic context excerpts, even when marked `PRIVATE_ALLOWED`;
|
||||
- credentials, tokens, passwords, private keys, connection strings or authentication headers;
|
||||
- Actor, Principal, Scope, session, workspace, Goal, Task, provider-binding or internal request identifiers;
|
||||
- files, images, database rows, logs, browser history, conversation history and ambient machine context.
|
||||
|
||||
The UI must disclose before each request that the explicitly entered text leaves the private network and may be retained by the provider for abuse monitoring for up to 30 days. Submission is the per-request acknowledgement; it is not durable blanket consent.
|
||||
|
||||
### Mode B — approved ZDR/MAM project
|
||||
|
||||
Enabling `PRIVATE_ALLOWED` context requires separate evidence that the exact OpenAI project has the approved control enabled, a new binding revision, a new evaluation, and explicit Creator promotion. Even then, `SECRET` and `UNKNOWN` remain forbidden, and tools/background/conversations remain disabled.
|
||||
|
||||
## Provider request mapping
|
||||
|
||||
The adapter constructs provider JSON itself. Provider-specific types stay under `adapters/models`; no provider type crosses into contracts or domain packages.
|
||||
|
||||
The request contains only:
|
||||
|
||||
- fixed snapshot model;
|
||||
- fixed non-executable system instruction: answer as non-canonical advice, state uncertainty, never claim an action occurred, never request or expose secrets;
|
||||
- the single Creator message;
|
||||
- `store: false`;
|
||||
- reasoning effort `low`;
|
||||
- an output limit consistent with the existing 16 KiB response contract.
|
||||
|
||||
The adapter must not serialize trusted routing/authority fields into the provider prompt. Those fields authorize local scope selection only; they are not model context.
|
||||
|
||||
## Response mapping
|
||||
|
||||
The adapter accepts text output only. It rejects:
|
||||
|
||||
- tool calls or executable items;
|
||||
- unknown output item kinds;
|
||||
- response identifiers offered as future conversation state;
|
||||
- output larger than the contract limit;
|
||||
- invalid UTF-8, malformed JSON, redirects or an origin change.
|
||||
|
||||
Provider refusal maps to `CONTENT_REJECTED`; deadline expiry to `TIMEOUT`; transport or availability failure to `PROVIDER_UNAVAILABLE`; malformed/forbidden provider output to `INVALID_PROVIDER_RESPONSE`. No failure is retried automatically and no partial output is shown after a failed validation.
|
||||
|
||||
## Isolation and transport
|
||||
|
||||
- The API key is created in a dedicated OpenAI project and mounted read-only only into `cognitive-worker` as a Docker secret file.
|
||||
- The secret is absent from Git, PostgreSQL, image layers, environment variables, logs, health responses, browser storage and other containers.
|
||||
- Standard-library HTTPS client, normal certificate validation, fixed SNI/Host, disabled redirects and disabled ambient proxy variables.
|
||||
- Egress is limited operationally to the fixed OpenAI API origin; DNS/IP changes are treated as infrastructure, not browser-controlled routing.
|
||||
- Request bodies, response bodies, authorization headers and message hashes are not logged. Metrics contain binding revision, latency bucket, finish reason and token counters only.
|
||||
|
||||
## Limits and cost controls
|
||||
|
||||
- 30-second hard deadline inherited from ADR-0006;
|
||||
- no automatic retry;
|
||||
- 5 requests per minute and 50 requests per day for the single Creator during canary;
|
||||
- maximum five simultaneous in-flight requests is reduced to one during canary;
|
||||
- provider output is capped by both provider token limit and the 16 KiB validated gateway limit;
|
||||
- OpenAI project spend limit and alert must be configured before the key is mounted;
|
||||
- any rate, model, prompt or spend-limit change creates a new binding revision.
|
||||
|
||||
Official pricing is mutable and is operational metadata rather than architecture. At review time the documented GPT-5.4 mini price is USD 0.75 per million input tokens and USD 4.50 per million output tokens; deployment must re-check the provider dashboard and project limit.
|
||||
|
||||
## Evaluation and promotion
|
||||
|
||||
1. Implement the adapter against an adversarial local fake server; do not add the production key.
|
||||
2. Test fixed origin, redirect denial, proxy denial, strict JSON, timeout, body limits, forbidden output kinds and log redaction.
|
||||
3. Run an offline corpus containing only synthetic/public advisory cases. Record exact snapshot, prompt revision, expected safety properties, latency and estimated cost.
|
||||
4. Mount a dedicated project key only after Mode A disclosure and spend controls exist.
|
||||
5. Run shadow calls whose results are never shown, using synthetic/public inputs only.
|
||||
6. Creator explicitly promotes the exact binding revision to a small canary.
|
||||
7. Canary permits only explicit message text, one in-flight request, no automatic context and immediate rollback.
|
||||
8. Broader use requires recorded evaluation evidence and a separate promotion decision.
|
||||
|
||||
## Rollback and degraded operation
|
||||
|
||||
Rollback disables/retires the OpenAI binding and restores `deterministic-fake` as the only active binding. The UI returns explicit `PROVIDER_UNAVAILABLE`. Creator authentication, Situation, Continuity, Commit, notification delivery and all canonical state remain available. Removing provider cognition never requires a database migration or state repair because advisory requests and responses are not persisted.
|
||||
|
||||
## Decisions still requiring explicit acceptance
|
||||
|
||||
Before implementation:
|
||||
|
||||
1. accept OpenAI Responses API as the first provider;
|
||||
2. accept `gpt-5.4-mini-2026-03-17` as the first immutable model snapshot;
|
||||
3. accept Mode A's possible provider abuse-monitoring retention up to 30 days, or require ZDR approval before any real call;
|
||||
4. accept the per-request disclosure wording and initial rate/spend policy.
|
||||
|
||||
Before production egress, a dedicated OpenAI API project and API key must exist. A ChatGPT/Codex subscription is not evidence that an API key or API billing project is available.
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-16. Active audit: DEV-000044 revision 3.
|
||||
Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1.
|
||||
|
||||
## Delivered
|
||||
|
||||
@@ -26,4 +26,14 @@ 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.
|
||||
|
||||
ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026-03-17` and Mode A per-request retention disclosure as the first real provider binding class. DEV-000050 implements its standard-library adapter and adversarial fake-transport tests: fixed origin/payload, message-only egress, no tools or provider memory, redirect/proxy denial, no retry, strict output mapping and fail-closed timeout/refusal/unavailable/invalid states. The adapter is not wired into the runtime; production remains on `deterministic-fake` with no provider credential or egress.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user