From bb0b74978fa2a954501a84d830f6a4eb8aab7211 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 11 Sep 2026 01:05:33 -0700 Subject: [PATCH] refactor: organize runtime code into internal packages Signed-off-by: Sertac Ozercan --- .github/workflows/ci.yml | 2 +- Dockerfile | 2 +- Dockerfile.acp | 2 +- Makefile | 2 +- README.md | 22 + acp_config.go | 196 ------ acp_protocol.go | 274 --------- acp_responses.go | 280 --------- acp_responses_test.go | 310 ---------- cmd/agent-runtime-foundry/main.go | 35 ++ internal/acp/config.go | 140 +++++ .../acp/config_test.go | 35 +- .../acp/folded_response_test.go | 27 +- acp_mcp.go => internal/acp/mcp.go | 35 +- .../acp/mcp_metadata_test.go | 8 +- .../acp/prompt_test.go | 32 +- internal/acp/protocol.go | 144 +++++ .../acp/protocol_test.go | 6 +- internal/acp/responses.go | 59 ++ internal/acp/responses_test.go | 131 ++++ acp_run.go => internal/acp/run.go | 39 +- acp.go => internal/acp/server.go | 17 +- acp_test.go => internal/acp/server_test.go | 12 +- .../acp/tool_queue_test.go | 10 +- internal/acp/transport_test.go | 7 + adapter.go => internal/adapter/adapter.go | 145 ++--- .../adapter/adapter_test.go | 248 ++++---- config.go => internal/adapter/config.go | 136 +---- .../adapter/config_test.go | 53 +- context.go => internal/adapter/context.go | 2 +- internal/adapter/responses.go | 250 ++++++++ internal/adapter/responses_test.go | 131 ++++ main.go => internal/adapter/run.go | 35 +- server.go => internal/adapter/server.go | 2 +- broker.go => internal/broker/broker.go | 71 +-- .../broker/broker_test.go | 75 +-- .../broker/byte_capacity_test.go | 93 +-- .../broker/create_ack_test.go | 12 +- .../broker/create_lease_test.go | 20 +- .../broker/evidence_test.go | 41 +- .../broker/folded_authority_test.go | 15 +- .../broker/guards_test.go | 45 +- internal/broker/json_fields_test.go | 28 + .../broker/lease_admission_test.go | 12 +- .../broker/legacy_recovery_test.go | 9 +- .../broker/preflight_recovery_test.go | 23 +- .../broker/protocol.go | 67 +- .../broker/recovery_test.go | 63 +- broker_remote.go => internal/broker/remote.go | 45 +- .../broker/remote_header_preflight_test.go | 56 +- .../broker/renewal_create_test.go | 45 +- .../broker/response_identity_write_test.go | 53 +- .../response_storage_precedence_test.go | 2 +- .../broker/responses.go | 172 +++--- .../broker/retirement_capacity_test.go | 75 +-- .../broker/review_lifecycle_test.go | 55 +- broker_main.go => internal/broker/run.go | 28 +- .../broker/schema_case_test.go | 67 +- .../broker/settlement_test.go | 21 +- internal/broker/sse_test.go | 7 + .../broker/storage_poison_test.go | 13 +- broker_store.go => internal/broker/store.go | 22 +- internal/broker/store_initialization_test.go | 57 ++ .../broker/store_test.go | 21 +- .../broker/store_validation.go | 27 +- .../broker/transport_deadline_test.go | 31 +- .../broker/unsent_dispatch_test.go | 2 +- .../broker/zero_invocation_test.go | 35 +- internal/brokerapi/paths.go | 9 + .../durablestore/directory.go | 18 +- .../durablestore/directory_retry_test.go | 16 +- .../durablestore/directory_test.go | 14 +- internal/durablestore/file.go | 14 + .../durablestore/storetest/initialization.go | 358 +++++++++++ auth.go => internal/foundry/auth.go | 12 +- internal/foundry/config.go | 82 +++ internal/foundry/digest.go | 30 + internal/foundry/endpoints.go | 54 ++ internal/foundry/endpoints_test.go | 47 ++ internal/foundry/limits.go | 14 + internal/foundry/responses.go | 333 ++++++++++ internal/foundry/responses_test.go | 116 ++++ internal/foundry/session.go | 30 + internal/foundry/strict_responses.go | 235 +++++++ internal/foundry/strict_responses_test.go | 223 +++++++ internal/foundry/tools.go | 68 +++ internal/foundry/tools_test.go | 15 + .../hosted/boundary_test.go | 41 +- hosted_config.go => internal/hosted/config.go | 15 +- .../hosted/create_rejection_test.go | 5 +- .../hosted/gateway.go | 12 +- .../hosted/gateway_test.go | 43 +- .../hosted/handshake_deadline_test.go | 2 +- .../hosted/lifecycle_test.go | 3 +- .../hosted/observation_test.go | 2 +- .../hosted/process_linux.go | 2 +- .../hosted/process_linux_test.go | 2 +- .../hosted/process_other.go | 2 +- .../hosted/protocol.go | 31 +- .../hosted/protocol_test.go | 34 +- hosted_proxy.go => internal/hosted/proxy.go | 11 +- hosted_remote.go => internal/hosted/remote.go | 28 +- .../hosted/remote_header_preflight_test.go | 60 ++ .../hosted/review_pairing_test.go | 2 +- .../hosted/review_shutdown_test.go | 10 +- hosted_main.go => internal/hosted/run.go | 12 +- hosted_server.go => internal/hosted/server.go | 20 +- .../hosted/server_test.go | 39 +- .../hosted/startup_config_test.go | 8 +- .../hosted/startup_exit_test.go | 5 +- .../hosted/startup_review_test.go | 12 +- hosted_store.go => internal/hosted/store.go | 21 +- internal/hosted/store_initialization_test.go | 48 ++ .../hosted/transport.go | 5 +- .../hosted/transport_test.go | 2 +- internal/strictjson/decode.go | 142 +++++ .../strictjson/fields.go | 24 +- .../strictjson/fields_test.go | 50 +- responses.go | 571 ------------------ responses_test.go | 241 -------- store_initialization_test.go | 185 ------ store_initializer_race_test.go | 224 ------- 122 files changed, 4123 insertions(+), 3716 deletions(-) delete mode 100644 acp_config.go delete mode 100644 acp_protocol.go delete mode 100644 acp_responses.go delete mode 100644 acp_responses_test.go create mode 100644 cmd/agent-runtime-foundry/main.go create mode 100644 internal/acp/config.go rename acp_config_test.go => internal/acp/config_test.go (83%) rename acp_folded_response_test.go => internal/acp/folded_response_test.go (61%) rename acp_mcp.go => internal/acp/mcp.go (77%) rename acp_mcp_metadata_test.go => internal/acp/mcp_metadata_test.go (92%) rename acp_prompt_test.go => internal/acp/prompt_test.go (92%) create mode 100644 internal/acp/protocol.go rename acp_protocol_test.go => internal/acp/protocol_test.go (94%) create mode 100644 internal/acp/responses.go create mode 100644 internal/acp/responses_test.go rename acp_run.go => internal/acp/run.go (70%) rename acp.go => internal/acp/server.go (91%) rename acp_test.go => internal/acp/server_test.go (96%) rename acp_tool_queue_test.go => internal/acp/tool_queue_test.go (83%) create mode 100644 internal/acp/transport_test.go rename adapter.go => internal/adapter/adapter.go (91%) rename adapter_test.go => internal/adapter/adapter_test.go (79%) rename config.go => internal/adapter/config.go (62%) rename config_test.go => internal/adapter/config_test.go (61%) rename context.go => internal/adapter/context.go (97%) create mode 100644 internal/adapter/responses.go create mode 100644 internal/adapter/responses_test.go rename main.go => internal/adapter/run.go (55%) rename server.go => internal/adapter/server.go (99%) rename broker.go => internal/broker/broker.go (87%) rename broker_test.go => internal/broker/broker_test.go (87%) rename broker_byte_capacity_test.go => internal/broker/byte_capacity_test.go (81%) rename broker_create_ack_test.go => internal/broker/create_ack_test.go (86%) rename broker_create_lease_test.go => internal/broker/create_lease_test.go (81%) rename broker_evidence_test.go => internal/broker/evidence_test.go (86%) rename broker_folded_authority_test.go => internal/broker/folded_authority_test.go (93%) rename broker_guards_test.go => internal/broker/guards_test.go (85%) create mode 100644 internal/broker/json_fields_test.go rename broker_lease_admission_test.go => internal/broker/lease_admission_test.go (87%) rename broker_legacy_recovery_test.go => internal/broker/legacy_recovery_test.go (89%) rename broker_preflight_recovery_test.go => internal/broker/preflight_recovery_test.go (88%) rename broker_protocol.go => internal/broker/protocol.go (70%) rename broker_recovery_test.go => internal/broker/recovery_test.go (85%) rename broker_remote.go => internal/broker/remote.go (85%) rename remote_header_preflight_test.go => internal/broker/remote_header_preflight_test.go (61%) rename broker_renewal_create_test.go => internal/broker/renewal_create_test.go (85%) rename broker_response_identity_write_test.go => internal/broker/response_identity_write_test.go (81%) rename broker_response_storage_precedence_test.go => internal/broker/response_storage_precedence_test.go (99%) rename broker_responses.go => internal/broker/responses.go (75%) rename broker_retirement_capacity_test.go => internal/broker/retirement_capacity_test.go (79%) rename broker_review_lifecycle_test.go => internal/broker/review_lifecycle_test.go (84%) rename broker_main.go => internal/broker/run.go (78%) rename broker_schema_case_test.go => internal/broker/schema_case_test.go (69%) rename broker_settlement_test.go => internal/broker/settlement_test.go (81%) create mode 100644 internal/broker/sse_test.go rename broker_storage_poison_test.go => internal/broker/storage_poison_test.go (89%) rename broker_store.go => internal/broker/store.go (90%) create mode 100644 internal/broker/store_initialization_test.go rename broker_store_test.go => internal/broker/store_test.go (91%) rename broker_store_validation.go => internal/broker/store_validation.go (81%) rename broker_transport_deadline_test.go => internal/broker/transport_deadline_test.go (90%) rename broker_unsent_dispatch_test.go => internal/broker/unsent_dispatch_test.go (99%) rename broker_zero_invocation_test.go => internal/broker/zero_invocation_test.go (78%) create mode 100644 internal/brokerapi/paths.go rename store_directory.go => internal/durablestore/directory.go (82%) rename store_directory_retry_test.go => internal/durablestore/directory_retry_test.go (88%) rename store_directory_test.go => internal/durablestore/directory_test.go (88%) create mode 100644 internal/durablestore/file.go create mode 100644 internal/durablestore/storetest/initialization.go rename auth.go => internal/foundry/auth.go (75%) create mode 100644 internal/foundry/config.go create mode 100644 internal/foundry/digest.go create mode 100644 internal/foundry/endpoints.go create mode 100644 internal/foundry/endpoints_test.go create mode 100644 internal/foundry/limits.go create mode 100644 internal/foundry/responses.go create mode 100644 internal/foundry/responses_test.go create mode 100644 internal/foundry/session.go create mode 100644 internal/foundry/strict_responses.go create mode 100644 internal/foundry/strict_responses_test.go create mode 100644 internal/foundry/tools.go create mode 100644 internal/foundry/tools_test.go rename hosted_boundary_test.go => internal/hosted/boundary_test.go (93%) rename hosted_config.go => internal/hosted/config.go (91%) rename hosted_create_rejection_test.go => internal/hosted/create_rejection_test.go (97%) rename hosted_gateway.go => internal/hosted/gateway.go (94%) rename hosted_gateway_test.go => internal/hosted/gateway_test.go (95%) rename hosted_handshake_deadline_test.go => internal/hosted/handshake_deadline_test.go (99%) rename hosted_lifecycle_test.go => internal/hosted/lifecycle_test.go (99%) rename hosted_observation_test.go => internal/hosted/observation_test.go (99%) rename hosted_process_linux.go => internal/hosted/process_linux.go (99%) rename hosted_process_linux_test.go => internal/hosted/process_linux_test.go (99%) rename hosted_process_other.go => internal/hosted/process_other.go (91%) rename hosted_protocol.go => internal/hosted/protocol.go (88%) rename hosted_protocol_test.go => internal/hosted/protocol_test.go (95%) rename hosted_proxy.go => internal/hosted/proxy.go (89%) rename hosted_remote.go => internal/hosted/remote.go (89%) create mode 100644 internal/hosted/remote_header_preflight_test.go rename hosted_review_pairing_test.go => internal/hosted/review_pairing_test.go (99%) rename hosted_review_shutdown_test.go => internal/hosted/review_shutdown_test.go (94%) rename hosted_main.go => internal/hosted/run.go (92%) rename hosted_server.go => internal/hosted/server.go (94%) rename hosted_server_test.go => internal/hosted/server_test.go (93%) rename hosted_startup_config_test.go => internal/hosted/startup_config_test.go (93%) rename hosted_startup_exit_test.go => internal/hosted/startup_exit_test.go (94%) rename hosted_startup_review_test.go => internal/hosted/startup_review_test.go (95%) rename hosted_store.go => internal/hosted/store.go (79%) create mode 100644 internal/hosted/store_initialization_test.go rename hosted_transport.go => internal/hosted/transport.go (99%) rename hosted_transport_test.go => internal/hosted/transport_test.go (99%) create mode 100644 internal/strictjson/decode.go rename acp_json_fields.go => internal/strictjson/fields.go (73%) rename acp_json_fields_test.go => internal/strictjson/fields_test.go (50%) delete mode 100644 responses.go delete mode 100644 responses_test.go delete mode 100644 store_initialization_test.go delete mode 100644 store_initializer_race_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d55c2c9..d55d970 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,4 @@ jobs: - name: Test run: go test ./... - name: Build - run: go build -o /tmp/agent-runtime-foundry . + run: go build -o /tmp/agent-runtime-foundry ./cmd/agent-runtime-foundry diff --git a/Dockerfile b/Dockerfile index 6b7957b..513531d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN mkdir -p /out && CGO_ENABLED=0 GOOS=linux go build -o /out/agent-runtime-foundry . +RUN mkdir -p /out && CGO_ENABLED=0 GOOS=linux go build -o /out/agent-runtime-foundry ./cmd/agent-runtime-foundry FROM gcr.io/distroless/static:nonroot COPY --from=build /out/agent-runtime-foundry /agent-runtime-foundry diff --git a/Dockerfile.acp b/Dockerfile.acp index f88a9d3..6f28830 100644 --- a/Dockerfile.acp +++ b/Dockerfile.acp @@ -7,7 +7,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ - go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/agent-runtime-foundry . + go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/agent-runtime-foundry ./cmd/agent-runtime-foundry ARG FOUNDRY_CONFIG=examples/foundry-acp.json COPY ${FOUNDRY_CONFIG} /out/rootfs/agent/foundry.json RUN chmod 0555 /out/rootfs/agent && chmod 0444 /out/rootfs/agent/foundry.json diff --git a/Makefile b/Makefile index 59e978e..facd6cf 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test: build: mkdir -p bin - go build -o bin/agent-runtime-foundry . + go build -o bin/agent-runtime-foundry ./cmd/agent-runtime-foundry verify: vet test build @test -z "$$(gofmt -l .)" diff --git a/README.md b/README.md index da8723b..65d51ff 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,28 @@ make build docker build -t ghcr.io/orka-agents/agent-runtime-foundry:latest . ``` +## Code layout + +The binary entry point is in `cmd/agent-runtime-foundry`. The default mode serves +harness v1 HTTP. Use `--protocol` to select `acp`, `broker`, `hosted`, or +`hosted-gateway`. + +| Directory | Responsibility | +| --- | --- | +| `internal/adapter` | Harness v1 HTTP server, turn lifecycle, and Responses client. | +| `internal/acp` | ACP child process, prompt execution, and loopback MCP client. | +| `internal/broker` | Durable Foundry session ownership and response settlement. | +| `internal/hosted` | Hosted supervisor, Kubernetes gateway, and relay transport. | +| `internal/foundry` | Shared agent configuration, Azure authentication, Responses parsing, and validation. | +| `internal/brokerapi` | Broker routes shared with the hosted gateway. | +| `internal/strictjson` | Duplicate-field and Unicode validation for JSON envelopes. | +| `internal/durablestore` | Private files, lock ownership, and directory durability. | +| `internal/harness`, `internal/events`, `internal/redact` | Harness contract and redaction helpers. | +| `conformance` | Harness v1 conformance probes. | + +Tests live beside the implementation they exercise. The broker and hosted store +tests share initialization checks in `internal/durablestore/storetest`. + ## Orka facade Deploy this adapter and its Kubernetes `Service` separately, then point an Orka diff --git a/acp_config.go b/acp_config.go deleted file mode 100644 index bba21cf..0000000 --- a/acp_config.go +++ /dev/null @@ -1,196 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "crypto/subtle" - "encoding/hex" - "errors" - "flag" - "io" - "net" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "time" - "unicode" -) - -const ( - acpConfigPath = "/agent/foundry.json" - acpProviderBaseEnv = "ORKA_FOUNDRY_ACP_PROVIDER_BASE_URL" - acpProviderTokenEnv = "ORKA_FOUNDRY_ACP_PROVIDER_TOKEN" - acpModelEnv = "ORKA_FOUNDRY_ACP_MODEL" - acpConfigDigestEnv = "ORKA_FOUNDRY_ACP_AGENT_CONFIGURATION_DIGEST" - acpMaxConfigBytes = 64 << 10 - acpMaxMessageBytes = 8 << 20 - acpTextChunkBytes = 32 << 10 - acpHTTPTimeout = 120 * time.Second -) - -var ( - errACPConfiguration = errors.New("invalid Foundry ACP configuration") - errACPProvider = errors.New("Foundry ACP provider request failed") - errACPMCP = errors.New("Foundry ACP MCP request failed") - errACPTransport = errors.New("Foundry ACP transport failed") -) - -type acpAgentConfiguration struct { - Model string `json:"model"` - ToolSchemaMode string `json:"toolSchemaMode"` - HostedTarget acpHostedTarget `json:"hostedTarget"` -} - -type acpHostedTarget struct { - ProjectEndpoint string `json:"projectEndpoint"` - AgentName string `json:"agentName"` - AgentVersion string `json:"agentVersion"` -} - -type acpConfiguration struct { - agent acpAgentConfiguration - providerURL string - token string -} - -// The ACP entry point deliberately precedes Azure credential initialization. -// Its only network authority is the two supervisor-owned loopback proxies. -func maybeServeACP(args []string, input io.ReadCloser, output io.WriteCloser) (bool, error) { - selected := false - for _, arg := range args { - if arg == "--protocol" || strings.HasPrefix(arg, "--protocol=") { - selected = true - break - } - } - if !selected { - return false, nil - } - flags := flag.NewFlagSet("foundry-acp", flag.ContinueOnError) - flags.SetOutput(io.Discard) - protocol := flags.String("protocol", "", "") - path := flags.String("config", acpConfigPath, "") - if flags.Parse(args) != nil || flags.NArg() != 0 || *protocol != "acp" { - return true, errACPConfiguration - } - cfg, err := loadACPConfiguration(*path, os.Getenv) - if err != nil { - return true, err - } - return true, serveACP(context.Background(), cfg, input, output) -} - -func loadACPConfiguration(path string, getenv func(string) string) (acpConfiguration, error) { - file, err := os.Open(path) - if err != nil { - return acpConfiguration{}, errACPConfiguration - } - defer file.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(file, acpMaxConfigBytes+1)) - if err != nil || len(data) > acpMaxConfigBytes { - return acpConfiguration{}, errACPConfiguration - } - return verifyACPConfiguration(data, getenv) -} - -func verifyACPConfiguration(data []byte, getenv func(string) string) (acpConfiguration, error) { - agent, err := decodeACPAgentConfiguration(data, getenv(acpConfigDigestEnv), getenv(acpModelEnv)) - if err != nil { - return acpConfiguration{}, err - } - base, err := acpLoopbackURL(getenv(acpProviderBaseEnv)) - token := getenv(acpProviderTokenEnv) - if err != nil || !acpSafeString(token, 16<<10) || strings.ContainsAny(token, " \t") { - return acpConfiguration{}, errACPConfiguration - } - base.Path = strings.TrimRight(base.Path, "/") + "/responses" - return acpConfiguration{agent: agent, providerURL: base.String(), token: token}, nil -} - -// Both entry points verify one immutable buffer, while only the privileged -// broker uses HostedTarget. No child proxy credential is needed to parse it. -func decodeACPAgentConfiguration(data []byte, expectedDigest, expectedModel string) (acpAgentConfiguration, error) { - actual := sha256.Sum256(data) - encoded := "sha256:" + hex.EncodeToString(actual[:]) - if len(data) > acpMaxConfigBytes || subtle.ConstantTimeCompare([]byte(expectedDigest), []byte(encoded)) != 1 { - return acpAgentConfiguration{}, errACPConfiguration - } - var agent acpAgentConfiguration - if acpDecode(data, &agent, true) != nil || !acpSafeString(agent.Model, 512) || agent.Model != expectedModel { - return acpAgentConfiguration{}, errACPConfiguration - } - if agent.ToolSchemaMode != toolSchemaModeRequest && agent.ToolSchemaMode != toolSchemaModeProviderStatic { - return acpAgentConfiguration{}, errACPConfiguration - } - if strings.TrimSpace(agent.HostedTarget.ProjectEndpoint) != agent.HostedTarget.ProjectEndpoint || - !foundryEndpointIsSafe(agent.HostedTarget.ProjectEndpoint) || - validateAgentName(agent.HostedTarget.AgentName) != nil || agent.HostedTarget.AgentVersion == "" || - strings.EqualFold(agent.HostedTarget.AgentVersion, "latest") || validateAgentVersion(agent.HostedTarget.AgentVersion) != nil { - return acpAgentConfiguration{}, errACPConfiguration - } - return agent, nil -} - -func acpSafeString(value string, limit int) bool { - if value == "" || len(value) > limit { - return false - } - for _, ch := range value { - if unicode.IsControl(ch) { - return false - } - } - return true -} - -func acpLoopbackURL(value string) (*url.URL, error) { - u, err := url.Parse(value) - if err != nil || !acpSafeString(value, 8<<10) || strings.TrimSpace(value) != value || u == nil || - (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || - u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(value, "#") || u.RawPath != "" { - return nil, errACPConfiguration - } - ip := net.ParseIP(u.Hostname()) - if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { - return nil, errACPConfiguration - } - if port := u.Port(); port != "" { - value, err := strconv.Atoi(port) - if err != nil || value < 1 || value > 65535 { - return nil, errACPConfiguration - } - } - return u, nil -} - -func newACPHTTPClient() *http.Client { - dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} - return &http.Client{ - Timeout: acpHTTPTimeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return errACPTransport - }, - Transport: &http.Transport{ - Proxy: nil, - DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, errACPTransport - } - if host == "localhost" { - host = "127.0.0.1" - } - if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { - return nil, errACPTransport - } - return dialer.DialContext(ctx, network, net.JoinHostPort(host, port)) - }, - MaxIdleConns: 4, - MaxIdleConnsPerHost: 2, - MaxConnsPerHost: 2, - IdleConnTimeout: 30 * time.Second, - }, - } -} diff --git a/acp_protocol.go b/acp_protocol.go deleted file mode 100644 index f62d964..0000000 --- a/acp_protocol.go +++ /dev/null @@ -1,274 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "io" - "reflect" - "strconv" - "strings" - "unicode/utf8" -) - -type acpRPCError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (e *acpRPCError) Error() string { return e.Message } - -var ( - acpInvalidRequest = &acpRPCError{-32600, "invalid ACP request"} - acpInvalidParams = &acpRPCError{-32602, "invalid ACP parameters"} - acpInternalError = &acpRPCError{-32603, "Foundry ACP prompt failed"} -) - -type acpRequest struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id,omitempty"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` -} - -type acpResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result any `json:"result,omitempty"` - Error *acpRPCError `json:"error,omitempty"` -} - -type acpMCPServer struct { - Type string `json:"type"` - Name string `json:"name"` - URL string `json:"url"` - Headers []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"headers"` - Meta json.RawMessage `json:"_meta,omitempty"` -} - -type acpNewSession struct { - CWD string `json:"cwd"` - AdditionalDirectories []string `json:"additionalDirectories,omitempty"` - MCPServers []acpMCPServer `json:"mcpServers"` - Meta json.RawMessage `json:"_meta,omitempty"` -} - -type acpPrompt struct { - SessionID string `json:"sessionId"` - Prompt []struct { - Type string `json:"type"` - Text *string `json:"text,omitempty"` - Name string `json:"name,omitempty"` - URI string `json:"uri,omitempty"` - MIMEType string `json:"mimeType,omitempty"` - Meta json.RawMessage `json:"_meta,omitempty"` - } `json:"prompt"` - Meta json.RawMessage `json:"_meta,omitempty"` -} - -func (p acpPrompt) text() (string, error) { - if !acpSafeString(p.SessionID, 512) || len(p.Prompt) == 0 { - return "", acpInvalidParams - } - var blocks []string - for _, block := range p.Prompt { - switch block.Type { - case "text": - if block.Text == nil || block.Name != "" || block.URI != "" || block.MIMEType != "" { - return "", acpInvalidParams - } - blocks = append(blocks, *block.Text) - case "resource_link": - if block.Text != nil || !acpSafeString(block.Name, 1024) || !acpSafeString(block.URI, 8<<10) { - return "", acpInvalidParams - } - text := "Resource link: " + block.Name + "\nURI: " + block.URI - if block.MIMEType != "" { - if !acpSafeString(block.MIMEType, 256) { - return "", acpInvalidParams - } - text += "\nMIME type: " + block.MIMEType - } - blocks = append(blocks, text) - default: - return "", acpInvalidParams - } - } - text := strings.Join(blocks, "\n") - if len(text) > maxFoundryPromptBytes { - return "", acpInvalidParams - } - return text, nil -} - -func acpRequestKey(raw json.RawMessage) (string, error) { - if len(raw) == 0 || len(raw) > 1024 { - return "", acpInvalidRequest - } - if raw[0] == '"' { - var value string - if json.Unmarshal(raw, &value) != nil || !acpSafeString(value, 512) { - return "", acpInvalidRequest - } - return "s:" + value, nil - } - value, err := strconv.ParseInt(string(raw), 10, 64) - if err != nil { - return "", acpInvalidRequest - } - return "n:" + strconv.FormatInt(value, 10), nil -} - -func acpReadLine(reader *bufio.Reader) ([]byte, error) { - var line []byte - for { - part, err := reader.ReadSlice('\n') - if len(line)+len(part) > acpMaxMessageBytes { - return nil, acpInvalidRequest - } - line = append(line, part...) - if err == nil { - return bytes.TrimSpace(line), nil - } - if errors.Is(err, bufio.ErrBufferFull) { - continue - } - if len(line) != 0 { - return nil, acpInvalidRequest - } - return nil, err - } -} - -// Go's ordinary JSON decoder accepts duplicate object members. Reject them at -// every depth before interpreting authority-bearing envelopes or tool arguments. -func acpDecode(data []byte, value any, strictFields bool) error { - return acpDecodeJSON(data, value, strictFields, nil) -} - -// Remote authority and Responses structs accept single case-folded field names, -// as encoding/json does, but two names must never replace or merge one field. -// Maps, interfaces and custom JSON values remain opaque to field folding. -func acpDecodeStruct(data []byte, value any, strictFields bool) error { - return acpDecodeJSON(data, value, strictFields, reflect.TypeOf(value)) -} - -func acpDecodeJSON(data []byte, value any, strictFields bool, shape reflect.Type) error { - if !utf8.Valid(data) || !json.Valid(data) || !acpValidStringEscapes(data) { - return acpInvalidParams - } - check := json.NewDecoder(bytes.NewReader(data)) - check.UseNumber() - if acpJSONValue(check, 0, shape) != nil { - return acpInvalidParams - } - if _, err := check.Token(); !errors.Is(err, io.EOF) { - return acpInvalidParams - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - if strictFields { - decoder.DisallowUnknownFields() - } - if err := decoder.Decode(value); err != nil { - return acpInvalidParams - } - return nil -} - -// encoding/json replaces unpaired UTF-16 escapes with U+FFFD. Tool arguments -// must retain their exact Unicode value, so reject malformed surrogate pairs. -func acpValidStringEscapes(data []byte) bool { - quoted := false - for i := 0; i < len(data); i++ { - if data[i] == '"' { - quoted = !quoted - continue - } - if !quoted || data[i] != '\\' { - continue - } - i++ - if data[i] != 'u' { - continue - } - value, err := strconv.ParseUint(string(data[i+1:i+5]), 16, 16) - if err != nil { - return false - } - i += 4 - if value >= 0xdc00 && value <= 0xdfff { - return false - } - if value < 0xd800 || value > 0xdbff { - continue - } - if i+6 >= len(data) || data[i+1] != '\\' || data[i+2] != 'u' { - return false - } - low, err := strconv.ParseUint(string(data[i+3:i+7]), 16, 16) - if err != nil || low < 0xdc00 || low > 0xdfff { - return false - } - i += 6 - } - return true -} - -func acpJSONValue(decoder *json.Decoder, depth int, shape reflect.Type) error { - if depth > 64 { - return acpInvalidParams - } - token, err := decoder.Token() - if err != nil { - return err - } - delim, ok := token.(json.Delim) - if !ok { - return nil - } - switch delim { - case '{': - fields, err := acpJSONStructFields(shape, 0) - if err != nil { - return err - } - seen := make(map[string]bool) - for decoder.More() { - key, err := decoder.Token() - if err != nil { - return err - } - name, ok := key.(string) - if !ok { - return acpInvalidParams - } - name, fieldType := acpJSONMatchField(fields, name) - if seen[name] { - return acpInvalidParams - } - seen[name] = true - if err := acpJSONValue(decoder, depth+1, fieldType); err != nil { - return err - } - } - case '[': - var element reflect.Type - if shape := acpJSONStructType(shape); shape != nil && (shape.Kind() == reflect.Slice || shape.Kind() == reflect.Array) { - element = shape.Elem() - } - for decoder.More() { - if err := acpJSONValue(decoder, depth+1, element); err != nil { - return err - } - } - default: - return acpInvalidParams - } - _, err = decoder.Token() - return err -} diff --git a/acp_responses.go b/acp_responses.go deleted file mode 100644 index b600d08..0000000 --- a/acp_responses.go +++ /dev/null @@ -1,280 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "io" - "mime" - "net/http" - "strings" -) - -type acpResponseRequest struct { - foundryResponseRequest - Model string `json:"model"` -} - -// ACP never constructs a Foundry SDK client: the privileged supervisor owns the -// remote agent session and rewrites that binding outside this child process. -func acpCreateResponse(ctx context.Context, cfg acpConfiguration, client *http.Client, request foundryResponseRequest) (foundryStreamSummary, error) { - request.Stream, request.Store = true, true - request.AgentSessionID = "" - body, err := json.Marshal(acpResponseRequest{foundryResponseRequest: request, Model: cfg.agent.Model}) - if err != nil { - return foundryStreamSummary{}, errACPProvider - } - httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.providerURL, bytes.NewReader(body)) - if err != nil { - return foundryStreamSummary{}, errACPProvider - } - httpRequest.Header.Set("Authorization", "Bearer "+cfg.token) - httpRequest.Header.Set("Content-Type", "application/json") - httpRequest.Header.Set("Accept", "text/event-stream, application/json") - response, err := client.Do(httpRequest) - if err != nil { - return foundryStreamSummary{}, errACPProvider - } - defer response.Body.Close() //nolint:errcheck - mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) - if err != nil || response.StatusCode != http.StatusOK { - return foundryStreamSummary{}, errACPProvider - } - switch mediaType { - case "text/event-stream": - return acpParseFoundrySSE(response.Body) - case "application/json": - data, err := io.ReadAll(io.LimitReader(response.Body, defaultMaxStreamBytes+1)) - if err != nil || len(data) > defaultMaxStreamBytes { - return foundryStreamSummary{}, errACPProvider - } - document, err := acpDecodeFoundryResponse(data) - if err != nil || document.Status != "completed" { - return foundryStreamSummary{}, errACPProvider - } - summary, err := processCompletedResponse(document, responseCallbacks{}) - if err != nil || acpValidateSummary(summary) != nil { - return foundryStreamSummary{}, errACPProvider - } - return summary, nil - default: - return foundryStreamSummary{}, errACPProvider - } -} - -func acpDecodeFoundryResponse(data []byte) (foundryResponse, error) { - var response foundryResponse - if acpDecodeStruct(data, &response, false) != nil || response.ID == "" || validateProviderIdentifier("response", response.ID) != nil || - response.Error != nil || response.Incomplete != nil { - return foundryResponse{}, errACPProvider - } - var fields map[string]json.RawMessage - if json.Unmarshal(data, &fields) != nil { - return foundryResponse{}, errACPProvider - } - var rawOutput json.RawMessage - for name, value := range fields { - if strings.EqualFold(name, "output") { - if rawOutput != nil { - return foundryResponse{}, errACPProvider - } - rawOutput = value - } - } - var output []json.RawMessage - if rawOutput != nil && json.Unmarshal(rawOutput, &output) != nil { - return foundryResponse{}, errACPProvider - } - // Return the validated objects, never a separately decoded typed slice - // whose elements could retain fields across folded output aliases. - response.Output = nil - for _, rawItem := range output { - item, err := acpDecodeFoundryItem(rawItem, true) - if err != nil { - return foundryResponse{}, err - } - response.Output = append(response.Output, item) - } - return response, nil -} - -func acpDecodeFoundryItem(data []byte, done bool) (foundryOutputItem, error) { - var item struct { - foundryOutputItem - Status string `json:"status"` - Role string `json:"role"` - } - if acpDecodeStruct(data, &item, false) != nil || (done && item.Status != "" && item.Status != "completed") { - return foundryOutputItem{}, errACPProvider - } - switch item.Type { - case "message": - if item.Role != "" && item.Role != "assistant" { - return foundryOutputItem{}, errACPProvider - } - for _, content := range item.Content { - if content.Type != "output_text" { - return foundryOutputItem{}, errACPProvider - } - } - case "function_call", "reasoning": - default: - // Hosted/native tools have no authority in the child. Only ordinary - // function calls, later checked against tools/list, may execute. - return foundryOutputItem{}, errACPProvider - } - return item.foundryOutputItem, nil -} - -func acpValidateSummary(summary foundryStreamSummary) error { - if summary.Status != "completed" || summary.ResponseID == "" || - validateProviderIdentifier("response", summary.ResponseID) != nil || - summary.Error != nil || summary.Incomplete != nil || len(summary.Text) > defaultMaxOutputBytes || - len(summary.FunctionCalls) > defaultMaxBrokeredCalls { - return errACPProvider - } - return nil -} - -// Keep the existing Responses types and terminal-output reconciliation, but -// require one explicit, coherent terminal event. A created/in-progress status, -// [DONE] alone, or a complete function-call item does not settle a response. -func acpParseFoundrySSE(reader io.Reader) (foundryStreamSummary, error) { - limited := &io.LimitedReader{R: reader, N: defaultMaxStreamBytes + 1} - scanner := bufio.NewScanner(limited) - scanner.Buffer(make([]byte, 32<<10), defaultMaxEventBytes) - var summary foundryStreamSummary - var data []byte - terminal, done := false, false - events := 0 - pending := map[string]bool{} - apply := func() error { - if len(data) == 0 { - return nil - } - events++ - if events > defaultMaxEvents || done { - return errACPProvider - } - if bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { - if !terminal { - return errACPProvider - } - done = true - return nil - } - if terminal { - return errACPProvider - } - var event foundryResponseEvent - var rawFields map[string]json.RawMessage - if acpDecodeStruct(data, &event, false) != nil || json.Unmarshal(data, &rawFields) != nil { - return errACPProvider - } - // Match the struct decoder's Unicode field folding without allowing two - // envelope members to validate one value and apply another. Tool argument - // objects keep their case-sensitive keys. - fields := make(map[string]json.RawMessage, 7) - for key, value := range rawFields { - for _, name := range []string{"type", "delta", "sequence_number", "response", "item", "error", "item_id"} { - if strings.EqualFold(key, name) { - if _, exists := fields[name]; exists { - return errACPProvider - } - fields[name] = value - break - } - } - } - if event.Response != nil { - response, err := acpDecodeFoundryResponse(fields["response"]) - if err != nil || (summary.ResponseID != "" && summary.ResponseID != response.ID) { - return errACPProvider - } - event.Response = &response - } - switch event.Type { - case "response.created", "response.in_progress", "response.queued": - if event.Response == nil || (event.Response.Status != "in_progress" && event.Response.Status != "queued") { - return errACPProvider - } - case "response.completed": - if event.Response == nil || event.Response.Status != "completed" { - return errACPProvider - } - for _, item := range event.Response.Output { - if item.Type == "function_call" { - delete(pending, item.ID) - } - } - if len(pending) != 0 { - return errACPProvider - } - terminal = true - case "response.output_item.added", "response.output_item.done": - item, err := acpDecodeFoundryItem(fields["item"], event.Type == "response.output_item.done") - if err != nil { - return err - } - event.Item = &item - if item.Type == "function_call" { - if event.Type == "response.output_item.added" { - if !acpSafeString(item.ID, maxProviderIdentifierBytes) { - return errACPProvider - } - pending[item.ID] = true - } else { - delete(pending, item.ID) - } - } - case "response.function_call_arguments.delta", "response.function_call_arguments.done": - var itemID string - if json.Unmarshal(fields["item_id"], &itemID) != nil || !acpSafeString(itemID, maxProviderIdentifierBytes) { - return errACPProvider - } - pending[itemID] = true - case "response.output_text.delta": - if len(fields["delta"]) == 0 || fields["delta"][0] != '"' || len(summary.Text)+len(event.Delta) > defaultMaxOutputBytes { - return errACPProvider - } - case "response.output_text.done", "response.content_part.added", "response.content_part.done", - "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done", - "response.reasoning_summary_part.added", "response.reasoning_summary_part.done", - "response.reasoning_text.delta", "response.reasoning_text.done": - default: - return errACPProvider - } - if err := applyFoundryEvent(&summary, event, responseCallbacks{}); err != nil || len(summary.Text) > defaultMaxOutputBytes { - return errACPProvider - } - return nil - } - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - if err := apply(); err != nil { - return foundryStreamSummary{}, err - } - data = nil - continue - } - if part, ok := bytes.CutPrefix(line, []byte("data:")); ok { - part = bytes.TrimPrefix(part, []byte(" ")) - if len(data)+len(part)+1 > defaultMaxEventBytes { - return foundryStreamSummary{}, errACPProvider - } - if len(data) != 0 { - data = append(data, '\n') - } - data = append(data, part...) - } else if !bytes.HasPrefix(line, []byte(":")) && !strings.HasPrefix(string(line), "event:") && - !strings.HasPrefix(string(line), "id:") && !strings.HasPrefix(string(line), "retry:") { - return foundryStreamSummary{}, errACPProvider - } - } - if scanner.Err() != nil || limited.N <= 0 || len(data) != 0 || !terminal || acpValidateSummary(summary) != nil { - return foundryStreamSummary{}, errACPProvider - } - return summary, nil -} diff --git a/acp_responses_test.go b/acp_responses_test.go deleted file mode 100644 index 1a2c899..0000000 --- a/acp_responses_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "sync/atomic" - "testing" -) - -func acpTestSSE(events ...string) string { - return "data: " + strings.Join(events, "\n\ndata: ") + "\n\n" -} - -func TestACPResponsesRequireExplicitCoherentCompletion(t *testing.T) { - const created = `{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}` - const delta = `{"type":"response.output_text.delta","delta":"héllo"}` - const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"héllo"}]}]}}` - for name, stream := range map[string]string{ - "streamed text": acpTestSSE(created, delta, completed, "[DONE]"), - "terminal fallback": acpTestSSE(completed), - } { - t.Run(name, func(t *testing.T) { - summary, err := acpParseFoundrySSE(strings.NewReader(stream)) - if err != nil || summary.Text != "héllo" || summary.ResponseID != "response-1" || summary.Status != "completed" { - t.Fatal("valid terminal response rejected") - } - }) - } - for name, stream := range map[string]string{ - "created only": acpTestSSE(created), - "partial text": acpTestSSE(created, delta), - "done without terminal": acpTestSSE(created, delta, "[DONE]"), - "created lies completed": acpTestSSE(`{"type":"response.created","response":{"id":"response-1","status":"completed"}}`), - "error after completed": acpTestSSE(created, delta, completed, `{"type":"error","error":{"message":"test-only-private-detail"}}`), - "duplicate terminal": acpTestSSE(completed, completed), - "missing terminal response": acpTestSSE(created, `{"type":"response.completed"}`), - "wrong terminal status": acpTestSSE(created, `{"type":"response.completed","response":{"id":"response-1","status":"in_progress"}}`), - "terminal error": acpTestSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed","error":{"message":"private"}}}`), - "terminal incomplete": acpTestSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed","incomplete_details":{"reason":"max_output_tokens"}}}`), - "wrong response identity": acpTestSSE(created, strings.Replace(completed, "response-1", "response-2", 1)), - "changed streamed text": acpTestSSE(created, strings.Replace(delta, "héllo", "wrong", 1), completed), - "native tool event": acpTestSSE(created, `{"type":"response.web_search_call.completed"}`, completed), - "native tool item": acpTestSSE(created, `{"type":"response.output_item.done","item":{"type":"web_search_call","id":"native"}}`, completed), - "duplicate JSON field": acpTestSSE(`{"type":"error","type":"response.completed","response":{"id":"response-1","status":"completed"}}`), - "truncated JSON": acpTestSSE(created, `{"type":"response.completed","response":`), - "truncated event": strings.TrimSuffix(acpTestSSE(completed), "\n"), - "missing delta": acpTestSSE(created, `{"type":"response.output_text.delta"}`, completed), - "null delta": acpTestSSE(created, `{"type":"response.output_text.delta","delta":null}`, completed), - "failed terminal": acpTestSSE(created, `{"type":"response.failed","response":{"id":"response-1","status":"failed"}}`), - "cancelled terminal": acpTestSSE(created, `{"type":"response.cancelled","response":{"id":"response-1","status":"cancelled"}}`), - "oversized output": acpTestSSE(created, `{"type":"response.output_text.delta","delta":"`+strings.Repeat("x", defaultMaxOutputBytes+1)+`"}`, completed), - } { - t.Run(name, func(t *testing.T) { - if _, err := acpParseFoundrySSE(strings.NewReader(stream)); err == nil { - t.Fatal("incomplete or malformed provider stream settled successfully") - } - }) - } -} - -func TestACPResponsesWaitForCompleteFunctionCallItems(t *testing.T) { - const added = `{"type":"response.output_item.added","item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"","status":"in_progress"}}` - const delta = `{"type":"response.function_call_arguments.delta","item_id":"item-1","delta":"{"}` - const argsDone = `{"type":"response.function_call_arguments.done","item_id":"item-1","arguments":"{}"}` - const itemDone = `{"type":"response.output_item.done","item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"completed"}}` - const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}` - summary, err := acpParseFoundrySSE(strings.NewReader(acpTestSSE(added, delta, argsDone, itemDone, completed))) - if err != nil || len(summary.FunctionCalls) != 1 || summary.FunctionCalls[0].CallID != "call-1" { - t.Fatal("complete function call stream rejected") - } - for name, stream := range map[string]string{ - "only added": acpTestSSE(added, completed), - "partial arguments": acpTestSSE(added, delta, completed), - "arguments done without item": acpTestSSE(added, delta, argsDone, completed), - "item done without response terminal": acpTestSSE(itemDone), - "item still incomplete": acpTestSSE(strings.Replace(itemDone, `"status":"completed"`, `"status":"in_progress"`, 1), completed), - "terminal omits pending item": acpTestSSE(added, strings.Replace(itemDone, "item-1", "item-2", 1), completed), - } { - t.Run(name, func(t *testing.T) { - if _, err := acpParseFoundrySSE(strings.NewReader(stream)); err == nil { - t.Fatal("partial tool-call stream accepted") - } - }) - } -} - -func TestACPResponsesEventAndStreamBounds(t *testing.T) { - for name, stream := range map[string]string{ - "event count": strings.Repeat(acpTestSSE(`{"type":"response.reasoning_text.delta","delta":"x"}`), defaultMaxEvents+1), - "single event": acpTestSSE(`{"type":"response.reasoning_text.delta","delta":"` + strings.Repeat("x", defaultMaxEventBytes) + `"}`), - "stream bytes": strings.Repeat(":"+strings.Repeat("x", 1<<20)+"\n", 17), - } { - t.Run(name, func(t *testing.T) { - if _, err := acpParseFoundrySSE(strings.NewReader(stream)); err == nil { - t.Fatal("unbounded provider stream accepted") - } - }) - } -} - -func TestACPInvalidProviderCallsNeverReachMCP(t *testing.T) { - valid := acpTestCall("probe", "call-1", `{}`) - for name, calls := range map[string][]foundryOutputItem{ - "unknown tool": {acpTestCall("forbidden", "call-1", `{}`)}, - "duplicate call": {valid, valid}, - "missing call ID": {acpTestCall("probe", "", `{}`)}, - "array arguments": {acpTestCall("probe", "call-1", `[]`)}, - "null arguments": {acpTestCall("probe", "call-1", `null`)}, - "malformed arguments": {acpTestCall("probe", "call-1", `{`)}, - "duplicate arguments": {acpTestCall("probe", "call-1", `{"key":1,"key":2}`)}, - "unpaired surrogate": {acpTestCall("probe", "call-1", `{"key":"\ud800"}`)}, - "missing arguments": {{Type: "function_call", Name: "probe", CallID: "call-1"}}, - "bad sibling": {valid, acpTestCall("forbidden", "call-2", `{}`)}, - "native tool": {{Type: "web_search_call", ID: "native"}}, - } { - t.Run(name, func(t *testing.T) { - var requests atomic.Int32 - mcp := &acpTestMCP{ - tools: func() []map[string]any { return acpTestTools("probe") }, - execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { - acpTestToolResult(w, id, "unexpected", false) - }, - } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { - acpTestReadProvider(t, r) - requests.Add(1) - acpTestCompleted(w, "invalid-calls", "", calls...) - }, mcp) - acpAssertFailure(t, peer.reply(peer.prompt("invalid call"))) - if mcp.calls.Load() != 0 || requests.Load() != 1 || len(peer.events) != 0 { - t.Fatal("malformed batch admitted a tool or model replay") - } - }) - } -} - -func TestACPTruncatedStreamNeverExecutesCompleteToolItem(t *testing.T) { - var requests atomic.Int32 - mcp := &acpTestMCP{ - tools: func() []map[string]any { return acpTestTools("probe") }, - execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { - acpTestToolResult(w, id, "unexpected", false) - }, - } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { - acpTestReadProvider(t, r) - requests.Add(1) - w.Header().Set("Content-Type", "text/event-stream") - _, _ = fmt.Fprint(w, acpTestSSE(`{"type":"response.output_item.done","item":{"type":"function_call","name":"probe","call_id":"call-1","arguments":"{}"}}`)) - }, mcp) - acpAssertFailure(t, peer.reply(peer.prompt("truncated"))) - if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { - t.Fatal("truncated response admitted a tool call") - } -} - -func TestACPResponsesRejectAmbiguousFoldedEventFields(t *testing.T) { - const response = `{"id":"response-1","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checked"}]}]}` - const changed = `{"id":"response-2","status":"completed","output":[{"type":"message","role":"user","content":[{"type":"output_text","text":"unchecked"}]}]}` - const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}` - const call = `{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"in_progress"}` - for name, stream := range map[string]string{ - "response identity and role": acpTestSSE(`{"type":"response.completed","response":` + response + `,"Response":` + changed + `}`), - "response reverse order": acpTestSSE(`{"type":"response.completed","Response":` + changed + `,"response":` + response + `}`), - "Unicode folded response": acpTestSSE(`{"type":"response.completed","response":` + response + `,"reſponſe":` + changed + `}`), - "escaped folded response": acpTestSSE(`{"type":"response.completed","response":` + response + `,"re\u017fpon\u017fe":` + changed + `}`), - "item skips status validation": acpTestSSE(`{"type":"response.output_item.done","item":{"type":"message"},"Item":`+call+`}`, completed), - "item reverse order": acpTestSSE(`{"type":"response.output_item.done","Item":`+call+`,"item":{"type":"message"}}`, completed), - "delta changes value": acpTestSSE(`{"type":"response.output_text.delta","delta":"checked","Delta":"unchecked"}`, completed), - "type changes terminal": acpTestSSE(`{"type":"error","Type":"response.completed","response":` + response + `}`), - "item identity aliases": acpTestSSE(`{"type":"response.function_call_arguments.done","item_id":"item-1","Item_ID":"other","arguments":"{}"}`, `{"type":"response.output_item.done","item":`+strings.Replace(call, "in_progress", "completed", 1)+`}`, completed), - } { - t.Run(name, func(t *testing.T) { - summary, err := acpParseFoundrySSE(strings.NewReader(stream)) - if err == nil || summary.ResponseID != "" || summary.Text != "" || len(summary.FunctionCalls) != 0 { - t.Fatal("ambiguous provider event exposed a response or tool call") - } - }) - } -} - -func TestACPResponsesFoldedEventCannotExecuteTool(t *testing.T) { - var requests atomic.Int32 - mcp := &acpTestMCP{ - tools: func() []map[string]any { return acpTestTools("probe") }, - execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { - acpTestToolResult(w, id, "unexpected", false) - }, - } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { - acpTestReadProvider(t, r) - if requests.Add(1) > 1 { - acpTestCompleted(w, "response-2", "unexpected") - return - } - w.Header().Set("Content-Type", "text/event-stream") - _, _ = fmt.Fprint(w, acpTestSSE( - `{"type":"response.output_item.done","item":{"type":"message"},"Item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"in_progress"}}`, - `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}`)) - }, mcp) - reply := peer.reply(peer.prompt("folded event validation")) - if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { - t.Fatal("ambiguous provider item admitted a tool or another model request") - } - acpAssertFailure(t, reply) -} - -func TestACPResponsesMatchSingleFoldedEnvelopeFields(t *testing.T) { - const response = `{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"checked"}]}]}` - for name, stream := range map[string]string{ - "response": acpTestSSE(`{"Type":"response.completed","Reſponſe":` + response + `}`), - "delta": acpTestSSE(`{"type":"response.output_text.delta","Delta":"checked"}`, `{"type":"response.completed","response":`+response+`}`), - } { - t.Run(name, func(t *testing.T) { - summary, err := acpParseFoundrySSE(strings.NewReader(stream)) - if err != nil || summary.ResponseID != "response-1" || summary.Text != "checked" || len(summary.FunctionCalls) != 0 { - t.Fatal("unambiguous folded event fields changed the response") - } - }) - } - stream := acpTestSSE( - `{"type":"response.function_call_arguments.done","Item_ID":"item-1","arguments":"{\"Key\":1,\"key\":2}"}`, - `{"type":"response.output_item.done","Item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{\"Key\":1,\"key\":2}","status":"completed"}}`, - `{"Type":"response.completed","Response":{"id":"response-1","status":"completed"}}`) - summary, err := acpParseFoundrySSE(strings.NewReader(stream)) - if err != nil || len(summary.FunctionCalls) != 1 { - t.Fatal("unambiguous folded item fields rejected a complete tool call") - } - var arguments string - if json.Unmarshal(summary.FunctionCalls[0].Arguments, &arguments) != nil || arguments != `{"Key":1,"key":2}` { - t.Fatal("case-sensitive tool argument keys changed") - } -} - -func TestACPResponsesRejectAmbiguousFoldedOutput(t *testing.T) { - const userMessage = `[{"type":"message","role":"user","content":[{"type":"output_text","text":"unchecked"}]}]` - const emptyAssistant = `[{"type":"message","role":"assistant"}]` - const incompleteCall = `[{"id":"item-1","type":"function_call","status":"in_progress","name":"probe","call_id":"call-1","arguments":"{}"}]` - const emptyCall = `[{"type":"function_call"}]` - for name, fields := range map[string]string{ - "message content preserved": `"output":` + userMessage + `,"Output":` + emptyAssistant, - "message reverse casing": `"Output":` + userMessage + `,"output":` + emptyAssistant, - "escaped output alias": `"output":` + userMessage + `,"\u004futput":` + emptyAssistant, - "incomplete call preserved": `"output":` + incompleteCall + `,"Output":` + emptyCall, - "call reverse casing": `"Output":` + incompleteCall + `,"output":` + emptyCall, - } { - t.Run(name, func(t *testing.T) { - response := `{"id":"response-1","status":"completed",` + fields + `}` - document, err := acpDecodeFoundryResponse([]byte(response)) - if err == nil || document.ID != "" || len(document.Output) != 0 { - t.Error("ambiguous response output exposed a decoded document") - } - summary, err := acpParseFoundrySSE(strings.NewReader(acpTestSSE(`{"type":"response.completed","response":` + response + `}`))) - if err == nil || summary.ResponseID != "" || summary.Text != "" || len(summary.FunctionCalls) != 0 { - t.Error("ambiguous response output exposed a response or tool call") - } - }) - } -} - -func TestACPResponsesFoldedOutputCannotExecuteTool(t *testing.T) { - const response = `{"id":"response-1","status":"completed","output":[{"id":"item-1","type":"function_call","status":"in_progress","name":"probe","call_id":"call-1","arguments":"{}"}],"Output":[{"type":"function_call"}]}` - for _, mediaType := range []string{"application/json", "text/event-stream"} { - t.Run(mediaType, func(t *testing.T) { - var requests atomic.Int32 - mcp := &acpTestMCP{ - tools: func() []map[string]any { return acpTestTools("probe") }, - execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { - acpTestToolResult(w, id, "unexpected", false) - }, - } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { - acpTestReadProvider(t, r) - if requests.Add(1) > 1 { - acpTestCompleted(w, "response-2", "unexpected") - return - } - w.Header().Set("Content-Type", mediaType) - if mediaType == "application/json" { - _, _ = fmt.Fprint(w, response) - } else { - _, _ = fmt.Fprint(w, acpTestSSE(`{"type":"response.completed","response":`+response+`}`)) - } - }, mcp) - reply := peer.reply(peer.prompt("folded output validation")) - if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { - t.Fatalf("ambiguous response output admitted effects: provider requests=%d, tool calls=%d, events=%d", requests.Load(), mcp.calls.Load(), len(peer.events)) - } - acpAssertFailure(t, reply) - }) - } -} - -func TestACPResponsesSingleFoldedOutputPreservesValidatedItems(t *testing.T) { - const response = `{"id":"response-1","status":"completed","OuTpUt":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"checked"}]},{"id":"item-1","type":"function_call","status":"completed","name":"probe","call_id":"call-1","arguments":"{\"Key\":1,\"key\":2}"}]}` - document, err := acpDecodeFoundryResponse([]byte(response)) - if err != nil || len(document.Output) != 2 || document.Output[0].Type != "message" || document.Output[1].Type != "function_call" { - t.Fatal("single folded output lost validated items") - } - summary, err := acpParseFoundrySSE(strings.NewReader(acpTestSSE(`{"type":"response.completed","response":` + response + `}`))) - if err != nil || summary.ResponseID != "response-1" || summary.Text != "checked" || len(summary.FunctionCalls) != 1 || summary.FunctionCalls[0].Name != "probe" || summary.FunctionCalls[0].CallID != "call-1" { - t.Fatal("single folded output changed text or function-call identity") - } - var arguments string - if json.Unmarshal(summary.FunctionCalls[0].Arguments, &arguments) != nil || arguments != `{"Key":1,"key":2}` { - t.Fatal("single folded output changed case-sensitive tool arguments") - } -} diff --git a/cmd/agent-runtime-foundry/main.go b/cmd/agent-runtime-foundry/main.go new file mode 100644 index 0000000..02565f2 --- /dev/null +++ b/cmd/agent-runtime-foundry/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "os" + + "github.com/orka-agents/agent-runtime-foundry/internal/acp" + "github.com/orka-agents/agent-runtime-foundry/internal/adapter" + "github.com/orka-agents/agent-runtime-foundry/internal/broker" + "github.com/orka-agents/agent-runtime-foundry/internal/hosted" +) + +func main() { + if handled, err := hosted.MaybeServe(os.Args[1:]); handled { + if err != nil { + log.Fatal("Foundry hosted lifetime unavailable; inspect the ownership ledger before replacement") + } + return + } + if handled, err := broker.MaybeServe(os.Args[1:]); handled { + if err != nil { + log.Fatal("Foundry lifecycle broker failed") + } + return + } + if handled, err := acp.MaybeServe(os.Args[1:], os.Stdin, os.Stdout); handled { + if err != nil { + log.Fatal("Foundry ACP bridge failed") + } + return + } + if err := adapter.Serve(); err != nil { + log.Fatal(err) + } +} diff --git a/internal/acp/config.go b/internal/acp/config.go new file mode 100644 index 0000000..78a1949 --- /dev/null +++ b/internal/acp/config.go @@ -0,0 +1,140 @@ +package acp + +import ( + "context" + "errors" + "flag" + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +const ( + acpProviderBaseEnv = "ORKA_FOUNDRY_ACP_PROVIDER_BASE_URL" + acpProviderTokenEnv = "ORKA_FOUNDRY_ACP_PROVIDER_TOKEN" + acpMaxMessageBytes = 8 << 20 + acpTextChunkBytes = 32 << 10 + acpHTTPTimeout = 120 * time.Second +) + +var ( + errACPMCP = errors.New("Foundry ACP MCP request failed") + errACPTransport = errors.New("Foundry ACP transport failed") +) + +type acpConfiguration struct { + agent foundry.AgentConfig + providerURL string + token string +} + +// The ACP entry point deliberately precedes Azure credential initialization. +// Its only network authority is the two supervisor-owned loopback proxies. +func MaybeServe(args []string, input io.ReadCloser, output io.WriteCloser) (bool, error) { + selected := false + for _, arg := range args { + if arg == "--protocol" || strings.HasPrefix(arg, "--protocol=") { + selected = true + break + } + } + if !selected { + return false, nil + } + flags := flag.NewFlagSet("foundry-acp", flag.ContinueOnError) + flags.SetOutput(io.Discard) + protocol := flags.String("protocol", "", "") + path := flags.String("config", foundry.AgentConfigPath, "") + if flags.Parse(args) != nil || flags.NArg() != 0 || *protocol != "acp" { + return true, foundry.ErrAgentConfig + } + cfg, err := loadACPConfiguration(*path, os.Getenv) + if err != nil { + return true, err + } + return true, serveACP(context.Background(), cfg, input, output) +} + +func loadACPConfiguration(path string, getenv func(string) string) (acpConfiguration, error) { + file, err := os.Open(path) + if err != nil { + return acpConfiguration{}, foundry.ErrAgentConfig + } + defer file.Close() //nolint:errcheck + data, err := io.ReadAll(io.LimitReader(file, foundry.MaxAgentConfigBytes+1)) + if err != nil || len(data) > foundry.MaxAgentConfigBytes { + return acpConfiguration{}, foundry.ErrAgentConfig + } + return verifyACPConfiguration(data, getenv) +} + +func verifyACPConfiguration(data []byte, getenv func(string) string) (acpConfiguration, error) { + agent, err := foundry.DecodeAgentConfig(data, getenv(foundry.AgentConfigDigestEnv), getenv(foundry.ModelEnv)) + if err != nil { + return acpConfiguration{}, err + } + base, err := acpLoopbackURL(getenv(acpProviderBaseEnv)) + token := getenv(acpProviderTokenEnv) + if err != nil || !foundry.SafeString(token, 16<<10) || strings.ContainsAny(token, " \t") { + return acpConfiguration{}, foundry.ErrAgentConfig + } + base.Path = strings.TrimRight(base.Path, "/") + "/responses" + return acpConfiguration{agent: agent, providerURL: base.String(), token: token}, nil +} + +func acpLoopbackURL(value string) (*url.URL, error) { + u, err := url.Parse(value) + if err != nil || !foundry.SafeString(value, 8<<10) || strings.TrimSpace(value) != value || u == nil || + (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(value, "#") || u.RawPath != "" { + return nil, foundry.ErrAgentConfig + } + ip := net.ParseIP(u.Hostname()) + if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { + return nil, foundry.ErrAgentConfig + } + if port := u.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return nil, foundry.ErrAgentConfig + } + } + return u, nil +} + +func newACPHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + return &http.Client{ + Timeout: acpHTTPTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errACPTransport + }, + Transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, errACPTransport + } + if host == "localhost" { + host = "127.0.0.1" + } + if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { + return nil, errACPTransport + } + return dialer.DialContext(ctx, network, net.JoinHostPort(host, port)) + }, + MaxIdleConns: 4, + MaxIdleConnsPerHost: 2, + MaxConnsPerHost: 2, + IdleConnTimeout: 30 * time.Second, + }, + } +} diff --git a/acp_config_test.go b/internal/acp/config_test.go similarity index 83% rename from acp_config_test.go rename to internal/acp/config_test.go index 034f5e5..4f3a182 100644 --- a/acp_config_test.go +++ b/internal/acp/config_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "crypto/sha256" @@ -10,12 +10,15 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) func acpTestConfigBytes(mode string) []byte { - data, _ := json.Marshal(acpAgentConfiguration{ + data, _ := json.Marshal(foundry.AgentConfig{ Model: "test-model", ToolSchemaMode: mode, - HostedTarget: acpHostedTarget{ProjectEndpoint: "https://foundry.example/api/projects/test", AgentName: "test-agent", AgentVersion: "7"}, + HostedTarget: foundry.HostedTarget{ProjectEndpoint: "https://foundry.example/api/projects/test", AgentName: "test-agent", AgentVersion: "7"}, }) return append(data, '\n') } @@ -27,22 +30,24 @@ func acpTestDigest(data []byte) string { func acpTestEnvironment(data []byte) map[string]string { return map[string]string{ - acpConfigDigestEnv: acpTestDigest(data), acpModelEnv: "test-model", + foundry.AgentConfigDigestEnv: acpTestDigest(data), foundry.ModelEnv: "test-model", acpProviderBaseEnv: "http://127.0.0.1:1234/local/v1", acpProviderTokenEnv: "test-only-proxy-token", } } func TestACPConfigurationPinsExactBytesAndHostedTarget(t *testing.T) { - data := acpTestConfigBytes(toolSchemaModeProviderStatic) + data := acpTestConfigBytes(foundry.ToolSchemaModeProviderStatic) env := acpTestEnvironment(data) cfg, err := verifyACPConfiguration(data, func(key string) string { return env[key] }) if err != nil || cfg.providerURL != "http://127.0.0.1:1234/local/v1/responses" || cfg.agent.HostedTarget.AgentVersion != "7" { t.Fatal("valid pinned configuration rejected") } for name, mutate := range map[string]func(map[string]string){ - "wrong digest": func(e map[string]string) { e[acpConfigDigestEnv] = "sha256:" + strings.Repeat("0", 64) }, - "uppercase digest": func(e map[string]string) { e[acpConfigDigestEnv] = strings.ToUpper(e[acpConfigDigestEnv]) }, - "wrong model": func(e map[string]string) { e[acpModelEnv] = "other" }, + "wrong digest": func(e map[string]string) { e[foundry.AgentConfigDigestEnv] = "sha256:" + strings.Repeat("0", 64) }, + "uppercase digest": func(e map[string]string) { + e[foundry.AgentConfigDigestEnv] = strings.ToUpper(e[foundry.AgentConfigDigestEnv]) + }, + "wrong model": func(e map[string]string) { e[foundry.ModelEnv] = "other" }, "missing token": func(e map[string]string) { delete(e, acpProviderTokenEnv) }, "newline token": func(e map[string]string) { e[acpProviderTokenEnv] = "test\nvalue" }, "public provider": func(e map[string]string) { e[acpProviderBaseEnv] = "https://foundry.example/v1" }, @@ -79,7 +84,7 @@ func TestACPMCPServerRejectsUnauthenticatedAndNonlocalTargets(t *testing.T) { } { t.Run(name, func(t *testing.T) { var server acpMCPServer - if err := acpDecode([]byte(data), &server, true); err != nil { + if err := strictjson.Decode([]byte(data), &server, true); err != nil { return } if _, err := newACPMCPClient(server, newACPHTTPClient()); err == nil { @@ -90,7 +95,7 @@ func TestACPMCPServerRejectsUnauthenticatedAndNonlocalTargets(t *testing.T) { } func TestACPConfigurationRejectsBakedToolsAndUnpinnedTargets(t *testing.T) { - base := string(acpTestConfigBytes(toolSchemaModeRequest)) + base := string(acpTestConfigBytes(foundry.ToolSchemaModeRequest)) cases := map[string]string{ "tools": strings.Replace(base, `"model":`, `"tools":[],"model":`, 1), "brokeredTools": strings.Replace(base, `"model":`, `"brokeredTools":[],"model":`, 1), @@ -106,7 +111,7 @@ func TestACPConfigurationRejectsBakedToolsAndUnpinnedTargets(t *testing.T) { for name, raw := range cases { t.Run(name, func(t *testing.T) { data := []byte(raw) - if _, err := decodeACPAgentConfiguration(data, acpTestDigest(data), "test-model"); err == nil { + if _, err := foundry.DecodeAgentConfig(data, acpTestDigest(data), "test-model"); err == nil { t.Fatal("unsupported configuration accepted") } }) @@ -156,13 +161,13 @@ func TestACPStrictJSONAndFraming(t *testing.T) { `"\ud800"`, `"\udfff"`, `"\ud800\u0000"`, } { var value any - if acpDecode([]byte(data), &value, false) == nil { + if strictjson.Decode([]byte(data), &value, false) == nil { t.Fatal("ambiguous or unbounded JSON accepted") } } for _, raw := range []string{`"\ud83c\udf0d"`, `"\\ud800"`, `"\ufffd"`, `"héllo 🌍"`} { var value string - if acpDecode([]byte(raw), &value, false) != nil { + if strictjson.Decode([]byte(raw), &value, false) != nil { t.Fatal("valid Unicode rejected") } } @@ -180,10 +185,10 @@ func TestACPStrictJSONAndFraming(t *testing.T) { func TestACPProtocolSelectionDoesNotInitializeAzure(t *testing.T) { in := io.NopCloser(strings.NewReader("")) - if handled, err := maybeServeACP(nil, in, nil); handled || err != nil { + if handled, err := MaybeServe(nil, in, nil); handled || err != nil { t.Fatal("legacy entry point was claimed") } - if handled, err := maybeServeACP([]string{"--protocol", "unsupported"}, in, nil); !handled || err == nil { + if handled, err := MaybeServe([]string{"--protocol", "unsupported"}, in, nil); !handled || err == nil { t.Fatal("unsupported protocol was not rejected") } } diff --git a/acp_folded_response_test.go b/internal/acp/folded_response_test.go similarity index 61% rename from acp_folded_response_test.go rename to internal/acp/folded_response_test.go index c801685..75e333e 100644 --- a/acp_folded_response_test.go +++ b/internal/acp/folded_response_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "encoding/json" @@ -7,6 +7,8 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestACPFoldedResponseFieldsCannotExecuteTool(t *testing.T) { @@ -27,7 +29,7 @@ func TestACPFoldedResponseFieldsCannotExecuteTool(t *testing.T) { acpTestToolResult(w, id, "unexpected", false) }, } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) if requests.Add(1) > 1 { acpTestCompleted(w, "response-2", "unexpected") @@ -49,24 +51,3 @@ func TestACPFoldedResponseFieldsCannotExecuteTool(t *testing.T) { } } } - -func TestACPFoldedResponseNestedFieldsRejected(t *testing.T) { - for name, document := range map[string]string{ - "response identity": `{"id":"wrong","ID":"response-1","status":"completed"}`, - "Session identity": `{"id":"response-1","status":"completed","agent_session_id":"wrong","Agent_Session_ID":"owned"}`, - "item identity": `{"id":"response-1","status":"completed","output":[{"id":"wrong","ID":"item-1","type":"message"}]}`, - "item role": `{"id":"response-1","status":"completed","output":[{"type":"message","role":"user","Role":"assistant","content":[{"type":"output_text","text":"fixture"}]}]}`, - "content type": `{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"refusal","Type":"output_text","text":"fixture"}]}]}`, - "Unicode status": `{"id":"response-1","status":"in_progress","ſtatus":"completed"}`, - "escaped status": `{"id":"response-1","status":"in_progress","\u0053tatus":"completed"}`, - } { - t.Run(name, func(t *testing.T) { - if _, err := acpDecodeFoundryResponse([]byte(document)); err == nil { - t.Error("ambiguous response or nested field accepted") - } - if _, err := acpParseFoundrySSE(strings.NewReader(acpTestSSE(`{"type":"response.completed","response":` + document + `}`))); err == nil { - t.Error("ambiguous SSE response or nested field accepted") - } - }) - } -} diff --git a/acp_mcp.go b/internal/acp/mcp.go similarity index 77% rename from acp_mcp.go rename to internal/acp/mcp.go index 1f97558..2f15897 100644 --- a/acp_mcp.go +++ b/internal/acp/mcp.go @@ -1,4 +1,4 @@ -package main +package acp import ( "bytes" @@ -9,6 +9,9 @@ import ( "net/http" "strings" "sync/atomic" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) const acpMCPVersion = "2025-06-18" @@ -24,7 +27,7 @@ type acpMCPClient struct { } func newACPMCPClient(server acpMCPServer, client *http.Client) (*acpMCPClient, error) { - if server.Type != "http" || !acpSafeString(server.Name, 128) || len(server.Headers) != 1 { + if server.Type != "http" || !foundry.SafeString(server.Name, 128) || len(server.Headers) != 1 { return nil, acpInvalidParams } if _, err := acpLoopbackURL(server.URL); err != nil { @@ -33,7 +36,7 @@ func newACPMCPClient(server acpMCPServer, client *http.Client) (*acpMCPClient, e header := server.Headers[0] value, ok := strings.CutPrefix(header.Value, "Bearer ") if !strings.EqualFold(header.Name, "Authorization") || !ok || - !acpSafeString(value, 16<<10) || strings.ContainsAny(value, " \t") { + !foundry.SafeString(value, 16<<10) || strings.ContainsAny(value, " \t") { return nil, acpInvalidParams } return &acpMCPClient{url: server.URL, bearer: header.Value, client: client}, nil @@ -54,7 +57,7 @@ func (m *acpMCPClient) initialize(ctx context.Context) error { Tools json.RawMessage `json:"tools"` } `json:"capabilities"` } - if acpDecode(result, &reply, false) != nil || reply.ProtocolVersion != acpMCPVersion || + if strictjson.Decode(result, &reply, false) != nil || reply.ProtocolVersion != acpMCPVersion || len(reply.Capabilities.Tools) == 0 || reply.Capabilities.Tools[0] != '{' { return errACPMCP } @@ -70,7 +73,7 @@ func (m *acpMCPClient) initialize(ctx context.Context) error { return nil } -func (m *acpMCPClient) tools(ctx context.Context) ([]foundryToolSchema, error) { +func (m *acpMCPClient) tools(ctx context.Context) ([]foundry.ToolSchema, error) { result, err := m.call(ctx, "tools/list", map[string]any{}) if err != nil { return nil, err @@ -83,25 +86,25 @@ func (m *acpMCPClient) tools(ctx context.Context) ([]foundryToolSchema, error) { } `json:"tools"` NextCursor json.RawMessage `json:"nextCursor"` } - if acpDecode(result, &reply, false) != nil || reply.Tools == nil || len(reply.Tools) > defaultMaxBrokeredCalls || + if strictjson.Decode(result, &reply, false) != nil || reply.Tools == nil || len(reply.Tools) > foundry.DefaultMaxBrokeredCalls || (len(reply.NextCursor) != 0 && !bytes.Equal(reply.NextCursor, []byte("null"))) { return nil, errACPMCP } - tools := make([]foundryToolSchema, 0, len(reply.Tools)) + tools := make([]foundry.ToolSchema, 0, len(reply.Tools)) seen := make(map[string]bool) for _, tool := range reply.Tools { - if validateFoundryFunctionName(tool.Name) != nil || seen[tool.Name] || len(tool.InputSchema) > maxFoundryToolSchemaBytes { + if foundry.ValidateFunctionName(tool.Name) != nil || seen[tool.Name] || len(tool.InputSchema) > foundry.MaxToolSchemaBytes { return nil, errACPMCP } var schema map[string]any - if acpDecode(tool.InputSchema, &schema, false) != nil || schema == nil || schema["type"] != "object" { + if strictjson.Decode(tool.InputSchema, &schema, false) != nil || schema == nil || schema["type"] != "object" { return nil, errACPMCP } seen[tool.Name] = true - tools = append(tools, foundryToolSchema{Type: "function", Name: tool.Name, Description: tool.Description, Parameters: tool.InputSchema}) + tools = append(tools, foundry.ToolSchema{Type: "function", Name: tool.Name, Description: tool.Description, Parameters: tool.InputSchema}) } encoded, err := json.Marshal(tools) - if err != nil || len(encoded) > maxFoundryToolSchemaBytes { + if err != nil || len(encoded) > foundry.MaxToolSchemaBytes { return nil, errACPMCP } return tools, nil @@ -120,7 +123,7 @@ func (m *acpMCPClient) execute(ctx context.Context, name string, args json.RawMe IsError *bool `json:"isError,omitempty"` StructuredContent json.RawMessage `json:"structuredContent,omitempty"` } - if acpDecode(result, &reply, false) != nil || reply.Content == nil { + if strictjson.Decode(result, &reply, false) != nil || reply.Content == nil { return "", false, errACPMCP } for _, content := range reply.Content { @@ -143,7 +146,7 @@ func (m *acpMCPClient) execute(ctx context.Context, name string, args json.RawMe func (m *acpMCPClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { id, _ := json.Marshal(m.nextID.Add(1)) encoded, err := json.Marshal(params) - if err != nil || len(encoded) > defaultMaxBrokeredBytes { + if err != nil || len(encoded) > foundry.DefaultMaxBrokeredBytes { return nil, errACPMCP } body, _ := json.Marshal(acpRequest{JSONRPC: "2.0", ID: id, Method: method, Params: encoded}) @@ -156,8 +159,8 @@ func (m *acpMCPClient) call(ctx context.Context, method string, params any) (jso if err != nil || response.StatusCode != http.StatusOK || mediaType != "application/json" { return nil, errACPMCP } - data, err := io.ReadAll(io.LimitReader(response.Body, defaultMaxBrokeredBytes+1)) - if err != nil || len(data) > defaultMaxBrokeredBytes { + data, err := io.ReadAll(io.LimitReader(response.Body, foundry.DefaultMaxBrokeredBytes+1)) + if err != nil || len(data) > foundry.DefaultMaxBrokeredBytes { return nil, errACPMCP } var reply struct { @@ -166,7 +169,7 @@ func (m *acpMCPClient) call(ctx context.Context, method string, params any) (jso Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` } - if acpDecode(data, &reply, true) != nil || reply.JSONRPC != "2.0" || !bytes.Equal(reply.ID, id) || + if strictjson.Decode(data, &reply, true) != nil || reply.JSONRPC != "2.0" || !bytes.Equal(reply.ID, id) || len(reply.Error) != 0 || len(reply.Result) == 0 || reply.Result[0] != '{' { return nil, errACPMCP } diff --git a/acp_mcp_metadata_test.go b/internal/acp/mcp_metadata_test.go similarity index 92% rename from acp_mcp_metadata_test.go rename to internal/acp/mcp_metadata_test.go index 63683fa..dfa28fc 100644 --- a/acp_mcp_metadata_test.go +++ b/internal/acp/mcp_metadata_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "encoding/json" @@ -7,6 +7,8 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestACPToolOutputForwardsOnlyValidatedModelContent(t *testing.T) { @@ -39,13 +41,13 @@ func TestACPToolOutputForwardsOnlyValidatedModelContent(t *testing.T) { acpTestMCPResult(w, id, result) } var requests atomic.Int32 - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { body := acpTestReadProvider(t, r) switch requests.Add(1) { case 1: acpTestCompleted(w, "tool-response", "", acpTestCall("probe", "call-probe", `{}`)) case 2: - var outputs []foundryFunctionOutput + var outputs []foundry.FunctionOutput if json.Unmarshal(body["input"], &outputs) != nil || len(outputs) != 1 { t.Error("invalid function output continuation") w.WriteHeader(http.StatusBadRequest) diff --git a/acp_prompt_test.go b/internal/acp/prompt_test.go similarity index 92% rename from acp_prompt_test.go rename to internal/acp/prompt_test.go index f19ffbc..f53123d 100644 --- a/acp_prompt_test.go +++ b/internal/acp/prompt_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "encoding/json" @@ -8,18 +8,20 @@ import ( "sync/atomic" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) -func acpTestCall(name, id, arguments string) foundryOutputItem { +func acpTestCall(name, id, arguments string) foundry.OutputItem { encoded, _ := json.Marshal(arguments) - return foundryOutputItem{Type: "function_call", CallID: id, Name: name, Arguments: encoded} + return foundry.OutputItem{Type: "function_call", CallID: id, Name: name, Arguments: encoded} } func TestACPStdioSuccessfulContinuationAndLargeUnicodeOutput(t *testing.T) { large := strings.Repeat("héllo 世界 🔒\n", 18000) var requests atomic.Int32 mcp := &acpTestMCP{} - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { body := acpTestReadProvider(t, r) switch requests.Add(1) { case 1: @@ -97,7 +99,7 @@ func TestACPStaticToolsExactArgumentsConcurrentOutputAndFreshAllowlist(t *testin } }, } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { body := acpTestReadProvider(t, r) if _, exists := body["tools"]; exists { t.Error("static mode sent request-level tool schemas") @@ -111,7 +113,7 @@ func TestACPStaticToolsExactArgumentsConcurrentOutputAndFreshAllowlist(t *testin default: t.Error("provider resumed before all tool calls joined") } - var outputs []foundryFunctionOutput + var outputs []foundry.FunctionOutput if json.Unmarshal(body["input"], &outputs) != nil || len(outputs) != 2 { t.Error("invalid function output continuation") } @@ -155,7 +157,7 @@ func TestACPMCPAdmittedErrorRemainsRecoverable(t *testing.T) { failed := mcp.calls.Load() == 1 acpTestToolResult(w, id, `{"status":"fixture"}`, failed) } - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { body := acpTestReadProvider(t, r) if _, present := body["tools"]; !present { t.Error("request schema mode omitted discovered tool") @@ -164,7 +166,7 @@ func TestACPMCPAdmittedErrorRemainsRecoverable(t *testing.T) { case 1: acpTestCompleted(w, "response-a", "", acpTestCall("probe", "call-a", `{}`)) case 2: - var outputs []foundryFunctionOutput + var outputs []foundry.FunctionOutput if json.Unmarshal(body["input"], &outputs) != nil || len(outputs) != 1 || !strings.Contains(outputs[0].Output, `"isError":true`) { t.Error("admitted error did not reach model as function output") } @@ -222,7 +224,7 @@ func TestACPFatalMCPFailureCancelsAndJoinsSiblingWithoutContinuation(t *testing. _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{}, "error": map[string]any{"code": -1}}) } } - peer := newACPTestPeer(t, toolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) acpTestCompleted(w, "fatal-batch", "", acpTestCall("probe", "private-fatal", `{"slot":0}`), acpTestCall("probe", "private-slow", `{"slot":1}`)) @@ -262,7 +264,7 @@ func TestACPCancelJoinsToolsUnderStdoutBackpressure(t *testing.T) { cancelled <- struct{}{} }, } - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) acpTestCompleted(w, "held", "", acpTestCall("hold", "hold-a", `{}`), acpTestCall("hold", "hold-b", `{}`)) @@ -328,7 +330,7 @@ func TestACPFatalMCPFailureCancelsSiblingBeforeBlockedEventWrite(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32002, "message": "test-only-protocol-failure"}}) } - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) acpTestCompleted(w, "fatal-paused", "", acpTestCall("probe", "fatal", `{"slot":0}`), acpTestCall("probe", "held", `{"slot":1}`)) @@ -367,7 +369,7 @@ func TestACPProviderFailureAfterToolDoesNotReplayOrCommitOutput(t *testing.T) { acpTestToolResult(w, id, "side effect completed", false) }, } - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) if requests.Add(1) == 1 { acpTestCompleted(w, "before-failure", "uncommitted draft", acpTestCall("probe", "once", `{}`)) @@ -385,7 +387,7 @@ func TestACPProviderFailureAfterToolDoesNotReplayOrCommitOutput(t *testing.T) { func TestACPRejectsUnsupportedPromptAndSessionCapabilities(t *testing.T) { var requests atomic.Int32 - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) acpTestCompleted(w, "valid", "ok") @@ -410,7 +412,7 @@ func TestACPRejectsUnsupportedPromptAndSessionCapabilities(t *testing.T) { func TestACPCancellationClosesProviderStream(t *testing.T) { started, cancelled := make(chan struct{}), make(chan struct{}) var requests atomic.Int32 - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) w.Header().Set("Content-Type", "text/event-stream") @@ -439,7 +441,7 @@ func TestACPCancellationClosesProviderStream(t *testing.T) { } func TestACPResourceLinkProjectsTextWithoutFilesystemOrHTTPAccess(t *testing.T) { - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { body := acpTestReadProvider(t, r) var input string if json.Unmarshal(body["input"], &input) != nil || input != "inspect\nResource link: file\nURI: file:///workspace/file\nMIME type: text/plain" { diff --git a/internal/acp/protocol.go b/internal/acp/protocol.go new file mode 100644 index 0000000..a8db785 --- /dev/null +++ b/internal/acp/protocol.go @@ -0,0 +1,144 @@ +package acp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "strconv" + "strings" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +type acpRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *acpRPCError) Error() string { return e.Message } + +var ( + acpInvalidRequest = &acpRPCError{-32600, "invalid ACP request"} + acpInvalidParams = &acpRPCError{-32602, "invalid ACP parameters"} + acpInternalError = &acpRPCError{-32603, "Foundry ACP prompt failed"} +) + +type acpRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type acpResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *acpRPCError `json:"error,omitempty"` +} + +type acpMCPServer struct { + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + Meta json.RawMessage `json:"_meta,omitempty"` +} + +type acpNewSession struct { + CWD string `json:"cwd"` + AdditionalDirectories []string `json:"additionalDirectories,omitempty"` + MCPServers []acpMCPServer `json:"mcpServers"` + Meta json.RawMessage `json:"_meta,omitempty"` +} + +type acpPrompt struct { + SessionID string `json:"sessionId"` + Prompt []struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + URI string `json:"uri,omitempty"` + MIMEType string `json:"mimeType,omitempty"` + Meta json.RawMessage `json:"_meta,omitempty"` + } `json:"prompt"` + Meta json.RawMessage `json:"_meta,omitempty"` +} + +func (p acpPrompt) text() (string, error) { + if !foundry.SafeString(p.SessionID, 512) || len(p.Prompt) == 0 { + return "", acpInvalidParams + } + var blocks []string + for _, block := range p.Prompt { + switch block.Type { + case "text": + if block.Text == nil || block.Name != "" || block.URI != "" || block.MIMEType != "" { + return "", acpInvalidParams + } + blocks = append(blocks, *block.Text) + case "resource_link": + if block.Text != nil || !foundry.SafeString(block.Name, 1024) || !foundry.SafeString(block.URI, 8<<10) { + return "", acpInvalidParams + } + text := "Resource link: " + block.Name + "\nURI: " + block.URI + if block.MIMEType != "" { + if !foundry.SafeString(block.MIMEType, 256) { + return "", acpInvalidParams + } + text += "\nMIME type: " + block.MIMEType + } + blocks = append(blocks, text) + default: + return "", acpInvalidParams + } + } + text := strings.Join(blocks, "\n") + if len(text) > foundry.MaxPromptBytes { + return "", acpInvalidParams + } + return text, nil +} + +func acpRequestKey(raw json.RawMessage) (string, error) { + if len(raw) == 0 || len(raw) > 1024 { + return "", acpInvalidRequest + } + if raw[0] == '"' { + var value string + if json.Unmarshal(raw, &value) != nil || !foundry.SafeString(value, 512) { + return "", acpInvalidRequest + } + return "s:" + value, nil + } + value, err := strconv.ParseInt(string(raw), 10, 64) + if err != nil { + return "", acpInvalidRequest + } + return "n:" + strconv.FormatInt(value, 10), nil +} + +func acpReadLine(reader *bufio.Reader) ([]byte, error) { + var line []byte + for { + part, err := reader.ReadSlice('\n') + if len(line)+len(part) > acpMaxMessageBytes { + return nil, acpInvalidRequest + } + line = append(line, part...) + if err == nil { + return bytes.TrimSpace(line), nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if len(line) != 0 { + return nil, acpInvalidRequest + } + return nil, err + } +} diff --git a/acp_protocol_test.go b/internal/acp/protocol_test.go similarity index 94% rename from acp_protocol_test.go rename to internal/acp/protocol_test.go index becbb4d..193a6b2 100644 --- a/acp_protocol_test.go +++ b/internal/acp/protocol_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "bufio" @@ -8,6 +8,8 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestACPReadLineRequiresBoundedNewlineFrames(t *testing.T) { @@ -49,7 +51,7 @@ func TestACPReadLineRequiresBoundedNewlineFrames(t *testing.T) { func TestACPInvalidEnvelopesCannotReachProvider(t *testing.T) { var requests atomic.Int32 - peer := newACPTestPeer(t, toolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { + peer := newACPTestPeer(t, foundry.ToolSchemaModeRequest, func(w http.ResponseWriter, r *http.Request) { acpTestReadProvider(t, r) requests.Add(1) acpTestCompleted(w, "valid", "ok") diff --git a/internal/acp/responses.go b/internal/acp/responses.go new file mode 100644 index 0000000..323ae97 --- /dev/null +++ b/internal/acp/responses.go @@ -0,0 +1,59 @@ +package acp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime" + "net/http" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +// ACP never constructs a Foundry SDK client: the privileged supervisor owns the +// remote agent session and rewrites that binding outside this child process. +func acpCreateResponse(ctx context.Context, cfg acpConfiguration, client *http.Client, request foundry.ResponseRequest) (foundry.StreamSummary, error) { + request.Stream, request.Store = true, true + request.AgentSessionID = "" + body, err := json.Marshal(foundry.ModelResponseRequest{ResponseRequest: request, Model: cfg.agent.Model}) + if err != nil { + return foundry.StreamSummary{}, foundry.ErrResponse + } + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.providerURL, bytes.NewReader(body)) + if err != nil { + return foundry.StreamSummary{}, foundry.ErrResponse + } + httpRequest.Header.Set("Authorization", "Bearer "+cfg.token) + httpRequest.Header.Set("Content-Type", "application/json") + httpRequest.Header.Set("Accept", "text/event-stream, application/json") + response, err := client.Do(httpRequest) + if err != nil { + return foundry.StreamSummary{}, foundry.ErrResponse + } + defer response.Body.Close() //nolint:errcheck + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || response.StatusCode != http.StatusOK { + return foundry.StreamSummary{}, foundry.ErrResponse + } + switch mediaType { + case "text/event-stream": + return foundry.ParseStrictSSE(response.Body) + case "application/json": + data, err := io.ReadAll(io.LimitReader(response.Body, foundry.DefaultMaxStreamBytes+1)) + if err != nil || len(data) > foundry.DefaultMaxStreamBytes { + return foundry.StreamSummary{}, foundry.ErrResponse + } + document, err := foundry.DecodeResponse(data) + if err != nil || document.Status != "completed" { + return foundry.StreamSummary{}, foundry.ErrResponse + } + summary, err := foundry.CompleteResponse(document, foundry.ResponseCallbacks{}) + if err != nil || foundry.ValidateSummary(summary) != nil { + return foundry.StreamSummary{}, foundry.ErrResponse + } + return summary, nil + default: + return foundry.StreamSummary{}, foundry.ErrResponse + } +} diff --git a/internal/acp/responses_test.go b/internal/acp/responses_test.go new file mode 100644 index 0000000..7285b91 --- /dev/null +++ b/internal/acp/responses_test.go @@ -0,0 +1,131 @@ +package acp + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +func acpTestSSE(events ...string) string { + return "data: " + strings.Join(events, "\n\ndata: ") + "\n\n" +} + +func TestACPInvalidProviderCallsNeverReachMCP(t *testing.T) { + valid := acpTestCall("probe", "call-1", `{}`) + for name, calls := range map[string][]foundry.OutputItem{ + "unknown tool": {acpTestCall("forbidden", "call-1", `{}`)}, + "duplicate call": {valid, valid}, + "missing call ID": {acpTestCall("probe", "", `{}`)}, + "array arguments": {acpTestCall("probe", "call-1", `[]`)}, + "null arguments": {acpTestCall("probe", "call-1", `null`)}, + "malformed arguments": {acpTestCall("probe", "call-1", `{`)}, + "duplicate arguments": {acpTestCall("probe", "call-1", `{"key":1,"key":2}`)}, + "unpaired surrogate": {acpTestCall("probe", "call-1", `{"key":"\ud800"}`)}, + "missing arguments": {{Type: "function_call", Name: "probe", CallID: "call-1"}}, + "bad sibling": {valid, acpTestCall("forbidden", "call-2", `{}`)}, + "native tool": {{Type: "web_search_call", ID: "native"}}, + } { + t.Run(name, func(t *testing.T) { + var requests atomic.Int32 + mcp := &acpTestMCP{ + tools: func() []map[string]any { return acpTestTools("probe") }, + execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { + acpTestToolResult(w, id, "unexpected", false) + }, + } + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + acpTestReadProvider(t, r) + requests.Add(1) + acpTestCompleted(w, "invalid-calls", "", calls...) + }, mcp) + acpAssertFailure(t, peer.reply(peer.prompt("invalid call"))) + if mcp.calls.Load() != 0 || requests.Load() != 1 || len(peer.events) != 0 { + t.Fatal("malformed batch admitted a tool or model replay") + } + }) + } +} + +func TestACPTruncatedStreamNeverExecutesCompleteToolItem(t *testing.T) { + var requests atomic.Int32 + mcp := &acpTestMCP{ + tools: func() []map[string]any { return acpTestTools("probe") }, + execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { + acpTestToolResult(w, id, "unexpected", false) + }, + } + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + acpTestReadProvider(t, r) + requests.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, acpTestSSE(`{"type":"response.output_item.done","item":{"type":"function_call","name":"probe","call_id":"call-1","arguments":"{}"}}`)) + }, mcp) + acpAssertFailure(t, peer.reply(peer.prompt("truncated"))) + if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { + t.Fatal("truncated response admitted a tool call") + } +} + +func TestACPResponsesFoldedEventCannotExecuteTool(t *testing.T) { + var requests atomic.Int32 + mcp := &acpTestMCP{ + tools: func() []map[string]any { return acpTestTools("probe") }, + execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { + acpTestToolResult(w, id, "unexpected", false) + }, + } + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + acpTestReadProvider(t, r) + if requests.Add(1) > 1 { + acpTestCompleted(w, "response-2", "unexpected") + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, acpTestSSE( + `{"type":"response.output_item.done","item":{"type":"message"},"Item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"in_progress"}}`, + `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}`)) + }, mcp) + reply := peer.reply(peer.prompt("folded event validation")) + if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { + t.Fatal("ambiguous provider item admitted a tool or another model request") + } + acpAssertFailure(t, reply) +} + +func TestACPResponsesFoldedOutputCannotExecuteTool(t *testing.T) { + const response = `{"id":"response-1","status":"completed","output":[{"id":"item-1","type":"function_call","status":"in_progress","name":"probe","call_id":"call-1","arguments":"{}"}],"Output":[{"type":"function_call"}]}` + for _, mediaType := range []string{"application/json", "text/event-stream"} { + t.Run(mediaType, func(t *testing.T) { + var requests atomic.Int32 + mcp := &acpTestMCP{ + tools: func() []map[string]any { return acpTestTools("probe") }, + execute: func(w http.ResponseWriter, _ *http.Request, id json.RawMessage, _ string, _ json.RawMessage) { + acpTestToolResult(w, id, "unexpected", false) + }, + } + peer := newACPTestPeer(t, foundry.ToolSchemaModeProviderStatic, func(w http.ResponseWriter, r *http.Request) { + acpTestReadProvider(t, r) + if requests.Add(1) > 1 { + acpTestCompleted(w, "response-2", "unexpected") + return + } + w.Header().Set("Content-Type", mediaType) + if mediaType == "application/json" { + _, _ = fmt.Fprint(w, response) + } else { + _, _ = fmt.Fprint(w, acpTestSSE(`{"type":"response.completed","response":`+response+`}`)) + } + }, mcp) + reply := peer.reply(peer.prompt("folded output validation")) + if requests.Load() != 1 || mcp.calls.Load() != 0 || len(peer.events) != 0 { + t.Fatalf("ambiguous response output admitted effects: provider requests=%d, tool calls=%d, events=%d", requests.Load(), mcp.calls.Load(), len(peer.events)) + } + acpAssertFailure(t, reply) + }) + } +} diff --git a/acp_run.go b/internal/acp/run.go similarity index 70% rename from acp_run.go rename to internal/acp/run.go index acd3e82..27e45dd 100644 --- a/acp_run.go +++ b/internal/acp/run.go @@ -1,9 +1,12 @@ -package main +package acp import ( "context" "strings" "sync" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) const acpMaxResponseRounds = 32 @@ -17,8 +20,8 @@ func (s *acpServer) runPrompt(ctx context.Context, session *acpSession, prompt s for _, tool := range tools { allowed[tool.Name] = true } - request := foundryResponseRequest{Input: prompt, PreviousResponseID: session.previous} - if s.cfg.agent.ToolSchemaMode == toolSchemaModeRequest { + request := foundry.ResponseRequest{Input: prompt, PreviousResponseID: session.previous} + if s.cfg.agent.ToolSchemaMode == foundry.ToolSchemaModeRequest { request.Tools = tools } seen := make(map[string]bool) @@ -28,27 +31,27 @@ func (s *acpServer) runPrompt(ctx context.Context, session *acpSession, prompt s return "", "", err } summary, err := acpCreateResponse(ctx, s.cfg, s.client, request) - if err != nil || text.Len()+len(summary.Text) > defaultMaxOutputBytes { - return "", "", errACPProvider + if err != nil || text.Len()+len(summary.Text) > foundry.DefaultMaxOutputBytes { + return "", "", foundry.ErrResponse } text.WriteString(summary.Text) if len(summary.FunctionCalls) == 0 { return summary.ResponseID, text.String(), nil } - if len(seen)+len(summary.FunctionCalls) > defaultMaxBrokeredCalls { - return "", "", errACPProvider + if len(seen)+len(summary.FunctionCalls) > foundry.DefaultMaxBrokeredCalls { + return "", "", foundry.ErrResponse } // Validate the whole batch before admitting its first side effect. calls := summary.FunctionCalls for i, call := range calls { - if validateFoundryFunctionName(call.Name) != nil || !allowed[call.Name] || - validateFoundryCallID(call.CallID) != nil || validateProviderIdentifier("call", call.CallID) != nil || seen[call.CallID] || len(call.Arguments) == 0 { - return "", "", errACPProvider + if foundry.ValidateFunctionName(call.Name) != nil || !allowed[call.Name] || + foundry.ValidateCallID(call.CallID) != nil || foundry.ValidateIdentifier("call", call.CallID) != nil || seen[call.CallID] || len(call.Arguments) == 0 { + return "", "", foundry.ErrResponse } - arguments, err := normalizeFoundryToolArguments(call.Arguments) + arguments, err := foundry.NormalizeToolArguments(call.Arguments) var object map[string]any - if err != nil || len(arguments) > defaultMaxBrokeredBytes || acpDecode(arguments, &object, false) != nil || object == nil { - return "", "", errACPProvider + if err != nil || len(arguments) > foundry.DefaultMaxBrokeredBytes || strictjson.Decode(arguments, &object, false) != nil || object == nil { + return "", "", foundry.ErrResponse } calls[i].Arguments = arguments seen[call.CallID] = true @@ -60,10 +63,10 @@ func (s *acpServer) runPrompt(ctx context.Context, session *acpSession, prompt s request.Input = outputs request.PreviousResponseID = summary.ResponseID } - return "", "", errACPProvider + return "", "", foundry.ErrResponse } -func (s *acpServer) executeTools(ctx context.Context, session *acpSession, calls []foundryOutputItem) ([]foundryFunctionOutput, error) { +func (s *acpServer) executeTools(ctx context.Context, session *acpSession, calls []foundry.OutputItem) ([]foundry.FunctionOutput, error) { ids := make([]string, len(calls)) started := 0 for i, call := range calls { @@ -81,7 +84,7 @@ func (s *acpServer) executeTools(ctx context.Context, session *acpSession, calls } group, cancel := context.WithCancel(ctx) defer cancel() - outputs := make([]foundryFunctionOutput, len(calls)) + outputs := make([]foundry.FunctionOutput, len(calls)) var wg sync.WaitGroup var mu sync.Mutex var firstError error @@ -107,7 +110,7 @@ func (s *acpServer) executeTools(ctx context.Context, session *acpSession, calls } mu.Lock() total += len(output) - if total > defaultMaxBrokeredTurnBytes && err == nil { + if total > foundry.DefaultMaxBrokeredTurnBytes && err == nil { err = errACPMCP } if err != nil { @@ -135,7 +138,7 @@ func (s *acpServer) executeTools(ctx context.Context, session *acpSession, calls cancel() mu.Unlock() } - outputs[i] = foundryFunctionOutput{Type: "function_call_output", CallID: call.CallID, Output: output} + outputs[i] = foundry.FunctionOutput{Type: "function_call_output", CallID: call.CallID, Output: output} }) } wg.Wait() diff --git a/acp.go b/internal/acp/server.go similarity index 91% rename from acp.go rename to internal/acp/server.go index c899a41..b8731a8 100644 --- a/acp.go +++ b/internal/acp/server.go @@ -1,4 +1,4 @@ -package main +package acp import ( "bufio" @@ -13,6 +13,9 @@ import ( "path/filepath" "sync" "unicode/utf8" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) type acpSession struct { @@ -165,7 +168,7 @@ func (s *acpServer) accept(line []byte) { s.respond(nil, nil, &acpRPCError{-32700, "invalid ACP JSON"}) return } - if acpDecode(line, &request, true) != nil || request.JSONRPC != "2.0" || !acpSafeString(request.Method, 128) { + if strictjson.Decode(line, &request, true) != nil || request.JSONRPC != "2.0" || !foundry.SafeString(request.Method, 128) { s.respond(nil, nil, acpInvalidRequest) return } @@ -194,7 +197,7 @@ func (s *acpServer) accept(line []byte) { ClientInfo json.RawMessage `json:"clientInfo"` Meta json.RawMessage `json:"_meta,omitempty"` } - if s.initialized || acpDecode(request.Params, ¶ms, true) != nil || params.ProtocolVersion != 1 || + if s.initialized || strictjson.Decode(request.Params, ¶ms, true) != nil || params.ProtocolVersion != 1 || (len(params.ClientCapabilities) != 0 && params.ClientCapabilities[0] != '{') { s.respond(request.ID, nil, acpInvalidParams) return @@ -231,7 +234,7 @@ func (s *acpServer) notification(request acpRequest) { SessionID string `json:"sessionId"` Meta json.RawMessage `json:"_meta,omitempty"` } - if acpDecode(request.Params, ¶ms, true) == nil && s.session != nil && params.SessionID == s.session.id { + if strictjson.Decode(request.Params, ¶ms, true) == nil && s.session != nil && params.SessionID == s.session.id { s.active.cancel() } case "$/cancel_request": @@ -239,7 +242,7 @@ func (s *acpServer) notification(request acpRequest) { RequestID json.RawMessage `json:"requestId"` } key, err := "", error(nil) - if acpDecode(request.Params, ¶ms, true) == nil { + if strictjson.Decode(request.Params, ¶ms, true) == nil { key, err = acpRequestKey(params.RequestID) } if err == nil && key == s.active.key { @@ -254,7 +257,7 @@ func (s *acpServer) newSessionLocked(request acpRequest, key string) { return } var params acpNewSession - if acpDecode(request.Params, ¶ms, true) != nil || len(params.AdditionalDirectories) != 0 || len(params.MCPServers) != 1 || !filepath.IsAbs(params.CWD) { + if strictjson.Decode(request.Params, ¶ms, true) != nil || len(params.AdditionalDirectories) != 0 || len(params.MCPServers) != 1 || !filepath.IsAbs(params.CWD) { s.respond(request.ID, nil, acpInvalidParams) return } @@ -295,7 +298,7 @@ func (s *acpServer) newOperationLocked(request acpRequest, key string) *acpOpera func (s *acpServer) promptLocked(request acpRequest, key string) { var params acpPrompt - if acpDecode(request.Params, ¶ms, true) != nil { + if strictjson.Decode(request.Params, ¶ms, true) != nil { s.respond(request.ID, nil, acpInvalidParams) return } diff --git a/acp_test.go b/internal/acp/server_test.go similarity index 96% rename from acp_test.go rename to internal/acp/server_test.go index 966f822..f1a0d56 100644 --- a/acp_test.go +++ b/internal/acp/server_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "context" @@ -13,6 +13,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) type acpTestPeer struct { @@ -224,13 +226,13 @@ func (p *acpTestPeer) prompt(text string) int { return p.start("session/prompt", map[string]any{"sessionId": p.session, "prompt": []map[string]string{{"type": "text", "text": text}}}) } -func acpTestCompleted(w http.ResponseWriter, id, text string, calls ...foundryOutputItem) { - output := append([]foundryOutputItem(nil), calls...) +func acpTestCompleted(w http.ResponseWriter, id, text string, calls ...foundry.OutputItem) { + output := append([]foundry.OutputItem(nil), calls...) if text != "" { - output = append(output, foundryOutputItem{Type: "message", Content: []foundryOutputContent{{Type: "output_text", Text: text}}}) + output = append(output, foundry.OutputItem{Type: "message", Content: []foundry.OutputContent{{Type: "output_text", Text: text}}}) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(foundryResponse{ID: id, Status: "completed", Output: output}) + _ = json.NewEncoder(w).Encode(foundry.Response{ID: id, Status: "completed", Output: output}) } func acpTestReadProvider(t *testing.T, r *http.Request) map[string]json.RawMessage { diff --git a/acp_tool_queue_test.go b/internal/acp/tool_queue_test.go similarity index 83% rename from acp_tool_queue_test.go rename to internal/acp/tool_queue_test.go index 4f4aa97..657ac6d 100644 --- a/acp_tool_queue_test.go +++ b/internal/acp/tool_queue_test.go @@ -1,4 +1,4 @@ -package main +package acp import ( "context" @@ -9,6 +9,8 @@ import ( "sync/atomic" "testing" "testing/synctest" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestACPFatalToolBatchDoesNotAdmitQueuedCalls(t *testing.T) { @@ -17,7 +19,7 @@ func TestACPFatalToolBatchDoesNotAdmitQueuedCalls(t *testing.T) { defer cancel() started, release := make(chan struct{}), make(chan struct{}) var dispatched atomic.Int32 - client := &http.Client{Transport: brokerFixtureTransport(func(r *http.Request) (*http.Response, error) { + client := &http.Client{Transport: acpTestTransport(func(r *http.Request) (*http.Response, error) { if dispatched.Add(1) == 2 { close(started) } @@ -42,9 +44,9 @@ func TestACPFatalToolBatchDoesNotAdmitQueuedCalls(t *testing.T) { session := &acpSession{id: "queue-test", mcp: &acpMCPClient{ url: "http://127.0.0.1/mcp", client: client, }} - calls := make([]foundryOutputItem, 32) + calls := make([]foundry.OutputItem, 32) for i := range calls { - calls[i] = foundryOutputItem{Name: "probe", CallID: fmt.Sprintf("call-%d", i), Arguments: []byte("{}")} + calls[i] = foundry.OutputItem{Name: "probe", CallID: fmt.Sprintf("call-%d", i), Arguments: []byte("{}")} } done := make(chan error, 1) go func() { diff --git a/internal/acp/transport_test.go b/internal/acp/transport_test.go new file mode 100644 index 0000000..9e10cbe --- /dev/null +++ b/internal/acp/transport_test.go @@ -0,0 +1,7 @@ +package acp + +import "net/http" + +type acpTestTransport func(*http.Request) (*http.Response, error) + +func (f acpTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/adapter.go b/internal/adapter/adapter.go similarity index 91% rename from adapter.go rename to internal/adapter/adapter.go index ebd8771..7a3b584 100644 --- a/adapter.go +++ b/internal/adapter/adapter.go @@ -1,4 +1,4 @@ -package main +package adapter import ( "context" @@ -8,13 +8,12 @@ import ( "errors" "fmt" "net/http" - "regexp" "slices" "strings" "sync" "time" - "unicode" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" "github.com/orka-agents/agent-runtime-foundry/internal/harness" ) @@ -22,10 +21,8 @@ const ( maxTurnTombstones = 10_000 ) -var foundryToolNameRE = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) - type responsesBackend interface { - CreateResponse(context.Context, foundryResponseRequest, responseCallbacks) (foundryStreamSummary, error) + CreateResponse(context.Context, foundry.ResponseRequest, foundry.ResponseCallbacks) (foundry.StreamSummary, error) CreateSession(context.Context) (string, error) ValidateAgent(context.Context) error } @@ -34,7 +31,7 @@ type foundryBackend struct { client *foundryResponsesClient } -func (b foundryBackend) CreateResponse(ctx context.Context, request foundryResponseRequest, callbacks responseCallbacks) (foundryStreamSummary, error) { +func (b foundryBackend) CreateResponse(ctx context.Context, request foundry.ResponseRequest, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { return b.client.createResponse(ctx, request, callbacks) } @@ -51,6 +48,7 @@ type providerStartError struct { } func (e providerStartError) Error() string { return e.err.Error() } + func (e providerStartError) Unwrap() error { return e.err } type adapterValidationError struct { @@ -58,6 +56,7 @@ type adapterValidationError struct { } func (e adapterValidationError) Error() string { return e.err.Error() } + func (e adapterValidationError) Unwrap() error { return e.err } type adapterLimitError struct { @@ -65,6 +64,7 @@ type adapterLimitError struct { } func (e adapterLimitError) Error() string { return e.err.Error() } + func (e adapterLimitError) Unwrap() error { return e.err } type runtimeSessionState struct { @@ -277,7 +277,7 @@ func (a *adapter) startTurn(request harness.StartTurnRequest) (*turnState, strin close(turn.initDone) a.mu.Unlock() go a.watchDeadline(turn) - go a.runResponse(turn, foundryResponseRequest{ + go a.runResponse(turn, foundry.ResponseRequest{ Input: request.Input.Prompt, PreviousResponseID: turn.previousResponse, AgentSessionID: turn.agentSessionID, @@ -314,15 +314,15 @@ func (a *adapter) watchDeadline(turn *turnState) { } } -func (a *adapter) runResponse(turn *turnState, request foundryResponseRequest) { +func (a *adapter) runResponse(turn *turnState, request foundry.ResponseRequest) { var invocationMu sync.Mutex invocationResponseID := "" invocationSessionID := request.AgentSessionID recordInvocationIDs := func(responseID, sessionID string) error { - if err := validateProviderIdentifier("response id", responseID); err != nil { + if err := foundry.ValidateIdentifier("response id", responseID); err != nil { return err } - if err := validateProviderIdentifier("agent session id", sessionID); err != nil { + if err := foundry.ValidateIdentifier("agent session id", sessionID); err != nil { return err } invocationMu.Lock() @@ -365,8 +365,8 @@ func (a *adapter) runResponse(turn *turnState, request foundryResponseRequest) { a.maybeDeleteExpiredTurnLocked(turn) a.mu.Unlock() }() - summary, err := a.backend.CreateResponse(turn.ctx, request, responseCallbacks{ - OnCreated: func(response foundryResponse) error { + summary, err := a.backend.CreateResponse(turn.ctx, request, foundry.ResponseCallbacks{ + OnCreated: func(response foundry.Response) error { if err := recordInvocationIDs(response.ID, response.AgentSessionID); err != nil { return err } @@ -400,7 +400,7 @@ func (a *adapter) runResponse(turn *turnState, request foundryResponseRequest) { a.scheduleOutputFlushLocked(turn) return nil }, - OnFunctionCall: func(call foundryOutputItem) error { + OnFunctionCall: func(call foundry.OutputItem) error { return a.recordFunctionCall(turn, call) }, }) @@ -524,17 +524,17 @@ func (a *adapter) runResponse(turn *turnState, request foundryResponseRequest) { a.mu.Unlock() } -func (a *adapter) recordFunctionCall(turn *turnState, call foundryOutputItem) error { +func (a *adapter) recordFunctionCall(turn *turnState, call foundry.OutputItem) error { providerCallID := call.CallID name := call.Name - if err := validateFoundryCallID(providerCallID); err != nil { + if err := foundry.ValidateCallID(providerCallID); err != nil { return err } - if err := validateFoundryFunctionName(name); err != nil { + if err := foundry.ValidateFunctionName(name); err != nil { return err } callID := orkaToolCallID(turn.request.RuntimeSessionID, turn.request.TurnID, providerCallID) - arguments, err := normalizeFoundryToolArguments(call.Arguments) + arguments, err := foundry.NormalizeToolArguments(call.Arguments) if err != nil { return err } @@ -682,9 +682,9 @@ func (a *adapter) continueTurn(request harness.ContinueTurnRequest) error { return nil } -func (a *adapter) prepareContinuationLocked(turn *turnState) (foundryResponseRequest, error) { +func (a *adapter) prepareContinuationLocked(turn *turnState) (foundry.ResponseRequest, error) { if turn.continuationInFlight { - return foundryResponseRequest{}, errors.New("foundry continuation is already in flight") + return foundry.ResponseRequest{}, errors.New("foundry continuation is already in flight") } callIDs := make([]string, 0, len(turn.pendingTools)) for callID := range turn.pendingTools { @@ -692,9 +692,9 @@ func (a *adapter) prepareContinuationLocked(turn *turnState) (foundryResponseReq } slices.Sort(callIDs) if err := a.ensureNonterminalFrameCapacityLocked(turn, len(callIDs)); err != nil { - return foundryResponseRequest{}, err + return foundry.ResponseRequest{}, err } - outputs := make([]foundryFunctionOutput, 0, len(callIDs)) + outputs := make([]foundry.FunctionOutput, 0, len(callIDs)) for _, callID := range callIDs { result := turn.bufferedResults[callID] output := string(result.Output) @@ -703,7 +703,7 @@ func (a *adapter) prepareContinuationLocked(turn *turnState) (foundryResponseReq output = string(encoded) } providerCallID := turn.providerCallIDs[callID] - outputs = append(outputs, foundryFunctionOutput{ + outputs = append(outputs, foundry.FunctionOutput{ Type: "function_call_output", CallID: providerCallID, Output: output, @@ -717,14 +717,14 @@ func (a *adapter) prepareContinuationLocked(turn *turnState) (foundryResponseReq frame.Error = result.Error frame.Metadata = foundryFrameMetadata(turn) }); err != nil { - return foundryResponseRequest{}, err + return foundry.ResponseRequest{}, err } turn.emittedResults[callID] = struct{}{} } } - previousResponseID := firstNonBlank(turn.responseID, turn.previousResponse) + previousResponseID := foundry.FirstNonBlank(turn.responseID, turn.previousResponse) if previousResponseID == "" { - return foundryResponseRequest{}, errors.New("foundry continuation is missing previous_response_id") + return foundry.ResponseRequest{}, errors.New("foundry continuation is missing previous_response_id") } for _, callID := range callIDs { turn.submittedResultDigests[callID] = turn.bufferedResultDigests[callID] @@ -736,7 +736,7 @@ func (a *adapter) prepareContinuationLocked(turn *turnState) (foundryResponseReq } turn.waitingForTools = false turn.continuationInFlight = true - return foundryResponseRequest{ + return foundry.ResponseRequest{ Input: outputs, PreviousResponseID: previousResponseID, AgentSessionID: turn.agentSessionID, @@ -1139,28 +1139,6 @@ func prepareFoundryStartRequest(request harness.StartTurnRequest) harness.StartT return request } -func validateFoundryFunctionName(name string) error { - if len(name) == 0 || len(name) > 128 || !foundryToolNameRE.MatchString(name) { - return errors.New("foundry function call name is invalid") - } - return nil -} - -func validateFoundryCallID(callID string) error { - if callID == "" { - return errors.New("foundry function call omitted call_id") - } - if len([]rune(callID)) > 64 { - return errors.New("foundry function call id exceeds 64 characters") - } - for _, char := range callID { - if unicode.IsControl(char) { - return errors.New("foundry function call id contains control characters") - } - } - return nil -} - func orkaToolCallID( runtimeSessionID harness.RuntimeSessionID, turnID harness.HarnessTurnID, @@ -1250,17 +1228,17 @@ func validateAdapterStartRequest(request harness.StartTurnRequest) error { return fmt.Errorf("%s exceeds adapter identity limit", field) } } - if len(request.Input.Prompt) > maxFoundryPromptBytes { + if len(request.Input.Prompt) > foundry.MaxPromptBytes { return errors.New("prompt exceeds adapter limit") } - if len(request.Input.Tools) > defaultMaxBrokeredCalls { + if len(request.Input.Tools) > foundry.DefaultMaxBrokeredCalls { return errors.New("tool schema count exceeds adapter limit") } toolBytes := 0 for _, tool := range request.Input.Tools { toolBytes += len(tool.Name) + len(tool.Description) + len(tool.Parameters) } - if toolBytes > maxFoundryToolSchemaBytes { + if toolBytes > foundry.MaxToolSchemaBytes { return errors.New("tool schemas exceed adapter limit") } return nil @@ -1311,7 +1289,7 @@ func validateFoundryToolDefinitions(definitions []harness.ToolDefinition) error seen := make(map[string]struct{}, len(definitions)) for _, definition := range definitions { name := strings.TrimSpace(definition.Name) - if len(name) > 128 || !foundryToolNameRE.MatchString(name) { + if foundry.ValidateFunctionName(name) != nil { return fmt.Errorf("foundry tool name %q must contain 1-128 letters, numbers, underscores, or hyphens", name) } if _, duplicate := seen[name]; duplicate { @@ -1353,11 +1331,11 @@ func foundryToolClass(definitions []harness.ToolDefinition, name string) harness return "" } -func foundryToolSchemas(request harness.StartTurnRequest) []foundryToolSchema { +func foundryToolSchemas(request harness.StartTurnRequest) []foundry.ToolSchema { if request.ToolExecutionMode != harness.ToolExecutionModeBrokered { return nil } - tools := make([]foundryToolSchema, 0, len(request.Input.Tools)) + tools := make([]foundry.ToolSchema, 0, len(request.Input.Tools)) for _, definition := range request.Input.Tools { if !foundryToolClassSupported(definition.BrokeredClass) || strings.TrimSpace(definition.Name) == "" { continue @@ -1366,7 +1344,7 @@ func foundryToolSchemas(request harness.StartTurnRequest) []foundryToolSchema { if len(definition.Parameters) > 0 { parameters = slices.Clone(definition.Parameters) } - tools = append(tools, foundryToolSchema{ + tools = append(tools, foundry.ToolSchema{ Type: "function", Name: strings.TrimSpace(definition.Name), Description: strings.TrimSpace(definition.Description), @@ -1376,31 +1354,13 @@ func foundryToolSchemas(request harness.StartTurnRequest) []foundryToolSchema { return tools } -func (a *adapter) providerToolSchemas(request harness.StartTurnRequest) []foundryToolSchema { - if strings.EqualFold(strings.TrimSpace(a.cfg.toolSchemaMode), toolSchemaModeProviderStatic) { +func (a *adapter) providerToolSchemas(request harness.StartTurnRequest) []foundry.ToolSchema { + if strings.EqualFold(strings.TrimSpace(a.cfg.toolSchemaMode), foundry.ToolSchemaModeProviderStatic) { return nil } return foundryToolSchemas(request) } -func normalizeFoundryToolArguments(raw json.RawMessage) (json.RawMessage, error) { - if len(raw) == 0 { - return json.RawMessage(`{}`), nil - } - var encoded string - if err := json.Unmarshal(raw, &encoded); err == nil { - encoded = strings.TrimSpace(encoded) - if encoded != "" && json.Valid([]byte(encoded)) { - return json.RawMessage(encoded), nil - } - return nil, errors.New("foundry tool arguments are not valid JSON") - } - if json.Valid(raw) { - return slices.Clone(raw), nil - } - return nil, errors.New("foundry tool arguments are not valid JSON") -} - func validateBrokeredResultFrames(results []harness.ToolCallResult, maxBytes int64) error { for _, result := range results { encoded, err := json.Marshal(result) @@ -1418,26 +1378,11 @@ func validateBrokeredResultFrames(results []harness.ToolCallResult, maxBytes int return nil } -func validateFoundryStreamSummary(summary foundryStreamSummary) error { - if err := validateProviderIdentifier("response id", summary.ResponseID); err != nil { +func validateFoundryStreamSummary(summary foundry.StreamSummary) error { + if err := foundry.ValidateIdentifier("response id", summary.ResponseID); err != nil { return err } - return validateProviderIdentifier("agent session id", summary.AgentSessionID) -} - -func validateProviderIdentifier(label, value string) error { - if value == "" { - return nil - } - if len(value) > maxProviderIdentifierBytes { - return fmt.Errorf("foundry %s exceeds adapter limit", label) - } - for _, char := range value { - if unicode.IsSpace(char) || unicode.IsControl(char) { - return fmt.Errorf("foundry %s contains unsafe characters", label) - } - } - return nil + return foundry.ValidateIdentifier("agent session id", summary.AgentSessionID) } func providerSafeMessage(err error) string { @@ -1457,34 +1402,34 @@ func providerSafeMessage(err error) string { return "Foundry request failed" } -func providerFailureMessage(providerError *foundryError) string { +func providerFailureMessage(providerError *foundry.ResponseError) string { if providerError == nil { return "Foundry response failed" } - code := firstNonBlank(providerError.Code, providerError.Type) + code := foundry.FirstNonBlank(providerError.Code, providerError.Type) if code == "" { return "Foundry response failed" } return "Foundry response failed (" + code + ")" } -func incompleteFailureMessage(incomplete *foundryIncomplete) string { +func incompleteFailureMessage(incomplete *foundry.Incomplete) string { if incomplete == nil || strings.TrimSpace(incomplete.Reason) == "" { return "Foundry response was incomplete" } return "Foundry response was incomplete (" + strings.TrimSpace(incomplete.Reason) + ")" } -func retryableResponseFailure(providerError *foundryError) bool { +func retryableResponseFailure(providerError *foundry.ResponseError) bool { if providerError == nil { return false } - code := strings.ToLower(firstNonBlank(providerError.Code, providerError.Type)) + code := strings.ToLower(foundry.FirstNonBlank(providerError.Code, providerError.Type)) return code == "server_error" || code == "too_many_requests" || code == "rate_limit_exceeded" || code == "no_capacity" || code == "timeout" || code == "temporarily_unavailable" } -func providerSessionCheckpointInvalid(err error, request foundryResponseRequest) bool { +func providerSessionCheckpointInvalid(err error, request foundry.ResponseRequest) bool { if request.AgentSessionID == "" && request.PreviousResponseID == "" { return false } diff --git a/adapter_test.go b/internal/adapter/adapter_test.go similarity index 79% rename from adapter_test.go rename to internal/adapter/adapter_test.go index 6726ebe..ab68f08 100644 --- a/adapter_test.go +++ b/internal/adapter/adapter_test.go @@ -1,4 +1,4 @@ -package main +package adapter import ( "bytes" @@ -13,36 +13,37 @@ import ( "time" "github.com/orka-agents/agent-runtime-foundry/conformance" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" "github.com/orka-agents/agent-runtime-foundry/internal/harness" ) type scriptedResponse struct { - validate func(foundryResponseRequest) error - run func(context.Context, responseCallbacks) (foundryStreamSummary, error) + validate func(foundry.ResponseRequest) error + run func(context.Context, foundry.ResponseCallbacks) (foundry.StreamSummary, error) } type fakeResponsesBackend struct { mu sync.Mutex responses []scriptedResponse - requests []foundryResponseRequest + requests []foundry.ResponseRequest createSessionID string createSessions int validateErr error } -func (f *fakeResponsesBackend) CreateResponse(ctx context.Context, request foundryResponseRequest, callbacks responseCallbacks) (foundryStreamSummary, error) { +func (f *fakeResponsesBackend) CreateResponse(ctx context.Context, request foundry.ResponseRequest, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { f.mu.Lock() f.requests = append(f.requests, request) if len(f.responses) == 0 { f.mu.Unlock() - return foundryStreamSummary{}, errors.New("no scripted response") + return foundry.StreamSummary{}, errors.New("no scripted response") } response := f.responses[0] f.responses = f.responses[1:] f.mu.Unlock() if response.validate != nil { if err := response.validate(request); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } } return response.run(ctx, callbacks) @@ -66,7 +67,7 @@ func TestAdapterObservedTurnStreamsAndReusesSession(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{ textResponseScript("resp-1", "session-1", "first answer"), { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if request.PreviousResponseID != "resp-1" || request.AgentSessionID != "session-1" { return errors.New("continuation identifiers were not reused") } @@ -104,29 +105,29 @@ func TestAdapterObservedTurnStreamsAndReusesSession(t *testing.T) { func TestAdapterBrokeredFunctionCallContinuation(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{ { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if len(request.Tools) != 1 || request.Tools[0].Name != "lookup_ticket" { return errors.New("safe Orka tool schema missing") } return nil }, - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - if err := callbacks.OnCreated(foundryResponse{ID: "resp-tool", Status: "in_progress", AgentSessionID: "session-tool"}); err != nil { - return foundryStreamSummary{}, err + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + if err := callbacks.OnCreated(foundry.Response{ID: "resp-tool", Status: "in_progress", AgentSessionID: "session-tool"}); err != nil { + return foundry.StreamSummary{}, err } - call := foundryOutputItem{Type: "function_call", CallID: "call-1", Name: "lookup_ticket", Arguments: json.RawMessage(`"{\"ticket\":\"INC-1\"}"`)} + call := foundry.OutputItem{Type: "function_call", CallID: "call-1", Name: "lookup_ticket", Arguments: json.RawMessage(`"{\"ticket\":\"INC-1\"}"`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-tool", AgentSessionID: "session-tool", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-tool", AgentSessionID: "session-tool", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if request.PreviousResponseID != "resp-tool" || request.AgentSessionID != "session-tool" { return errors.New("tool continuation identifiers missing") } - outputs, ok := request.Input.([]foundryFunctionOutput) + outputs, ok := request.Input.([]foundry.FunctionOutput) if !ok || len(outputs) != 1 || outputs[0].CallID != "call-1" || outputs[0].Type != "function_call_output" { return errors.New("function_call_output missing") } @@ -185,32 +186,32 @@ func TestAdapterBrokeredFunctionCallContinuation(t *testing.T) { func TestAdapterProviderStaticBrokeredFunctionCallContinuation(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{ { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if len(request.Tools) != 0 { return errors.New("provider-static mode forwarded request tool schemas") } return nil }, - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - if err := callbacks.OnCreated(foundryResponse{ID: "resp-static", Status: "in_progress", AgentSessionID: "session-static"}); err != nil { - return foundryStreamSummary{}, err + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + if err := callbacks.OnCreated(foundry.Response{ID: "resp-static", Status: "in_progress", AgentSessionID: "session-static"}); err != nil { + return foundry.StreamSummary{}, err } - call := foundryOutputItem{Type: "function_call", CallID: "call-static", Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"INC-1"}`)} + call := foundry.OutputItem{Type: "function_call", CallID: "call-static", Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"INC-1"}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-static", AgentSessionID: "session-static", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-static", AgentSessionID: "session-static", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if request.PreviousResponseID != "resp-static" || request.AgentSessionID != "session-static" { return errors.New("provider-static continuation identifiers missing") } if len(request.Tools) != 0 { return errors.New("provider-static continuation forwarded request tool schemas") } - outputs, ok := request.Input.([]foundryFunctionOutput) + outputs, ok := request.Input.([]foundry.FunctionOutput) if !ok || len(outputs) != 1 || outputs[0].CallID != "call-static" || outputs[0].Type != "function_call_output" { return errors.New("provider-static function_call_output missing") } @@ -220,7 +221,7 @@ func TestAdapterProviderStaticBrokeredFunctionCallContinuation(t *testing.T) { }, }} cfg := testConfig("http://127.0.0.1") - cfg.toolSchemaMode = toolSchemaModeProviderStatic + cfg.toolSchemaMode = foundry.ToolSchemaModeProviderStatic adapter := newAdapter(cfg, backend) request := startRequest("provider-static", "provider-static-session") request.ToolExecutionMode = harness.ToolExecutionModeBrokered @@ -265,7 +266,7 @@ func TestAdapterProviderStaticBrokeredFunctionCallContinuation(t *testing.T) { func TestAdapterVersionPinCreatesSessionOnce(t *testing.T) { backend := &fakeResponsesBackend{createSessionID: "pinned-session", responses: []scriptedResponse{ { - validate: func(request foundryResponseRequest) error { + validate: func(request foundry.ResponseRequest) error { if request.AgentSessionID != "pinned-session" { return errors.New("pinned session id missing") } @@ -290,13 +291,13 @@ func TestAdapterVersionPinCreatesSessionOnce(t *testing.T) { func TestAdapterCancellationCancelsStreamAndResponse(t *testing.T) { started := make(chan struct{}) backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(ctx context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - if err := callbacks.OnCreated(foundryResponse{ID: "resp-cancel", Status: "in_progress"}); err != nil { - return foundryStreamSummary{}, err + run: func(ctx context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + if err := callbacks.OnCreated(foundry.Response{ID: "resp-cancel", Status: "in_progress"}); err != nil { + return foundry.StreamSummary{}, err } close(started) <-ctx.Done() - return foundryStreamSummary{}, ctx.Err() + return foundry.StreamSummary{}, ctx.Err() }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -323,17 +324,17 @@ func TestAdapterMapsFailedAndIncompleteResponses(t *testing.T) { }{ { name: "failed", - script: scriptedResponse{run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-failed", Status: "in_progress"}) - return foundryStreamSummary{ResponseID: "resp-failed", Status: "failed", Error: &foundryError{Code: "server_error"}}, nil + script: scriptedResponse{run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-failed", Status: "in_progress"}) + return foundry.StreamSummary{ResponseID: "resp-failed", Status: "failed", Error: &foundry.ResponseError{Code: "server_error"}}, nil }}, reason: "foundry_response_failed", }, { name: "incomplete", - script: scriptedResponse{run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-incomplete", Status: "in_progress"}) - return foundryStreamSummary{ResponseID: "resp-incomplete", Status: "incomplete", Incomplete: &foundryIncomplete{Reason: "max_output_tokens"}}, nil + script: scriptedResponse{run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-incomplete", Status: "in_progress"}) + return foundry.StreamSummary{ResponseID: "resp-incomplete", Status: "incomplete", Incomplete: &foundry.Incomplete{Reason: "max_output_tokens"}}, nil }}, reason: "foundry_response_incomplete", }, @@ -355,12 +356,12 @@ func TestAdapterMapsFailedAndIncompleteResponses(t *testing.T) { func TestAdapterRejectsUnsafeToolCallAndOversizedResult(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-unsafe", Status: "in_progress"}) - if err := callbacks.OnFunctionCall(foundryOutputItem{Type: "function_call", CallID: "call-1", Name: "not_allowed", Arguments: json.RawMessage(`{}`)}); err != nil { - return foundryStreamSummary{}, err + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-unsafe", Status: "in_progress"}) + if err := callbacks.OnFunctionCall(foundry.OutputItem{Type: "function_call", CallID: "call-1", Name: "not_allowed", Arguments: json.RawMessage(`{}`)}); err != nil { + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-unsafe", Status: "completed"}, nil + return foundry.StreamSummary{ResponseID: "resp-unsafe", Status: "completed"}, nil }, }}} cfg := testConfig("http://127.0.0.1") @@ -418,13 +419,13 @@ func conformanceBackend(class harness.BrokeredToolClass) *fakeResponsesBackend { } return &fakeResponsesBackend{responses: []scriptedResponse{ { - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-tool", Status: "in_progress", AgentSessionID: "session-tool"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-1", Name: "conformance_" + string(class), Arguments: json.RawMessage(`{"value":"probe"}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-tool", Status: "in_progress", AgentSessionID: "session-tool"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-1", Name: "conformance_" + string(class), Arguments: json.RawMessage(`{"value":"probe"}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-tool", AgentSessionID: "session-tool", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-tool", AgentSessionID: "session-tool", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, textResponseScript("resp-final", "session-tool", "ok"), @@ -432,14 +433,14 @@ func conformanceBackend(class harness.BrokeredToolClass) *fakeResponsesBackend { } func textResponseScript(responseID, sessionID, text string) scriptedResponse { - return scriptedResponse{run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - if err := callbacks.OnCreated(foundryResponse{ID: responseID, Status: "in_progress", AgentSessionID: sessionID}); err != nil { - return foundryStreamSummary{}, err + return scriptedResponse{run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + if err := callbacks.OnCreated(foundry.Response{ID: responseID, Status: "in_progress", AgentSessionID: sessionID}); err != nil { + return foundry.StreamSummary{}, err } if err := callbacks.OnTextDelta(text); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: responseID, AgentSessionID: sessionID, Status: "completed", Text: text}, nil + return foundry.StreamSummary{ResponseID: responseID, AgentSessionID: sessionID, Status: "completed", Text: text}, nil }} } @@ -516,13 +517,13 @@ func findFrame(frames []harness.HarnessEventFrame, typ harness.FrameType) *harne func TestAdapterDeadlineWhileWaitingForTool(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-wait", Status: "in_progress"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-wait", Name: "lookup", Arguments: json.RawMessage(`{}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-wait", Status: "in_progress"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-wait", Name: "lookup", Arguments: json.RawMessage(`{}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-wait", AgentSessionID: "session-wait", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-wait", AgentSessionID: "session-wait", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }}} cfg := testConfig("http://127.0.0.1") @@ -546,25 +547,25 @@ func TestAdapterContinuationRetryIsIdempotent(t *testing.T) { block := make(chan struct{}) backend := &fakeResponsesBackend{responses: []scriptedResponse{ { - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-idem", Status: "in_progress"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-idem", Name: "lookup", Arguments: json.RawMessage(`{}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-idem", Status: "in_progress"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-idem", Name: "lookup", Arguments: json.RawMessage(`{}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-idem", AgentSessionID: "session-idem", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-idem", AgentSessionID: "session-idem", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, { - run: func(ctx context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-idem-final", Status: "in_progress"}) + run: func(ctx context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-idem-final", Status: "in_progress"}) select { case <-block: case <-ctx.Done(): - return foundryStreamSummary{}, ctx.Err() + return foundry.StreamSummary{}, ctx.Err() } _ = callbacks.OnTextDelta("ok") - return foundryStreamSummary{ResponseID: "resp-idem-final", Status: "completed", Text: "ok"}, nil + return foundry.StreamSummary{ResponseID: "resp-idem-final", Status: "completed", Text: "ok"}, nil }, }, }} @@ -610,14 +611,14 @@ func TestAdapterContinuationRetryIsIdempotent(t *testing.T) { func TestAdapterRejectsConcurrentTurnsAndPrunesCompletedState(t *testing.T) { block := make(chan struct{}) backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(ctx context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-active", Status: "in_progress"}) + run: func(ctx context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-active", Status: "in_progress"}) select { case <-block: case <-ctx.Done(): - return foundryStreamSummary{}, ctx.Err() + return foundry.StreamSummary{}, ctx.Err() } - return foundryStreamSummary{ResponseID: "resp-active", Status: "completed"}, nil + return foundry.StreamSummary{ResponseID: "resp-active", Status: "completed"}, nil }, }}} cfg := testConfig("http://127.0.0.1") @@ -686,14 +687,14 @@ func TestAdapterQueuesEarlyToolResultUntilResponseCompletes(t *testing.T) { allowComplete := make(chan struct{}) backend := &fakeResponsesBackend{responses: []scriptedResponse{ { - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-early", Status: "in_progress"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-early", Name: "lookup", Arguments: json.RawMessage(`{}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-early", Status: "in_progress"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-early", Name: "lookup", Arguments: json.RawMessage(`{}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } <-allowComplete - return foundryStreamSummary{ResponseID: "resp-early", AgentSessionID: "session-early", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-early", AgentSessionID: "session-early", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, textResponseScript("resp-after-early", "session-early", "continued"), @@ -747,10 +748,10 @@ func TestFoundryFrameMetadataDoesNotExposeProviderHandles(t *testing.T) { } func TestRetryableResponseFailureIsConservative(t *testing.T) { - if !retryableResponseFailure(&foundryError{Code: "server_error"}) { + if !retryableResponseFailure(&foundry.ResponseError{Code: "server_error"}) { t.Fatal("server_error should be retryable") } - if retryableResponseFailure(&foundryError{Code: "content_filter"}) { + if retryableResponseFailure(&foundry.ResponseError{Code: "content_filter"}) { t.Fatal("content_filter should not be retryable") } if retryableResponseFailure(nil) { @@ -760,13 +761,13 @@ func TestRetryableResponseFailureIsConservative(t *testing.T) { func TestUnresolvedToolResponseDoesNotAdvanceSessionCheckpoint(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-unresolved", Status: "in_progress", AgentSessionID: "session-1"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-unresolved", Name: "lookup", Arguments: json.RawMessage(`{}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-unresolved", Status: "in_progress", AgentSessionID: "session-1"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-unresolved", Name: "lookup", Arguments: json.RawMessage(`{}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-unresolved", AgentSessionID: "session-1", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-unresolved", AgentSessionID: "session-1", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -798,13 +799,13 @@ func TestUnresolvedToolResponseDoesNotAdvanceSessionCheckpoint(t *testing.T) { func TestBrokeredTurnStateIsCumulativelyBounded(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-bounded", Status: "in_progress"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-bounded", Name: "lookup", Arguments: json.RawMessage(`{"key":"value"}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-bounded", Status: "in_progress"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-bounded", Name: "lookup", Arguments: json.RawMessage(`{"key":"value"}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-bounded", AgentSessionID: "session-bounded", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-bounded", AgentSessionID: "session-bounded", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }}} cfg := testConfig("http://127.0.0.1") @@ -881,23 +882,14 @@ func TestValidateFoundryToolDefinitionsRejectsDuplicateOrNonObjectSchema(t *test } } -func TestValidateProviderIdentifierBoundsAndRejectsWhitespace(t *testing.T) { - if err := validateProviderIdentifier("response id", strings.Repeat("x", maxProviderIdentifierBytes+1)); err == nil { - t.Fatal("oversized provider identifier was accepted") - } - if err := validateProviderIdentifier("response id", "response id"); err == nil { - t.Fatal("provider identifier with whitespace was accepted") - } -} - func TestBrokeredWriteFailureIsNotRetryable(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-write-failure", Status: "in_progress"}) - if err := callbacks.OnFunctionCall(foundryOutputItem{Type: "function_call", CallID: "call-write", Name: "write_ticket", Arguments: json.RawMessage(`{}`)}); err != nil { - return foundryStreamSummary{}, err + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-write-failure", Status: "in_progress"}) + if err := callbacks.OnFunctionCall(foundry.OutputItem{Type: "function_call", CallID: "call-write", Name: "write_ticket", Arguments: json.RawMessage(`{}`)}); err != nil { + return foundry.StreamSummary{}, err } - return foundryStreamSummary{}, errors.New("temporary provider failure") + return foundry.StreamSummary{}, errors.New("temporary provider failure") }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -918,18 +910,18 @@ func TestBrokeredWriteFailureIsNotRetryable(t *testing.T) { func TestCompletedContinuationMustAdvanceResponseID(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{ { - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-tool-id", Status: "in_progress", AgentSessionID: "session-id"}) - call := foundryOutputItem{Type: "function_call", CallID: "call-id", Name: "lookup", Arguments: json.RawMessage(`{}`)} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-tool-id", Status: "in_progress", AgentSessionID: "session-id"}) + call := foundry.OutputItem{Type: "function_call", CallID: "call-id", Name: "lookup", Arguments: json.RawMessage(`{}`)} if err := callbacks.OnFunctionCall(call); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-tool-id", AgentSessionID: "session-id", Status: "completed", FunctionCalls: []foundryOutputItem{call}}, nil + return foundry.StreamSummary{ResponseID: "resp-tool-id", AgentSessionID: "session-id", Status: "completed", FunctionCalls: []foundry.OutputItem{call}}, nil }, }, { - run: func(_ context.Context, _ responseCallbacks) (foundryStreamSummary, error) { - return foundryStreamSummary{Status: "completed", AgentSessionID: "session-id"}, nil + run: func(_ context.Context, _ foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + return foundry.StreamSummary{Status: "completed", AgentSessionID: "session-id"}, nil }, }, }} @@ -956,8 +948,8 @@ func TestCompletedContinuationMustAdvanceResponseID(t *testing.T) { func TestProviderFailureBeforeFirstEventIsNotRetryable(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, _ responseCallbacks) (foundryStreamSummary, error) { - return foundryStreamSummary{}, providerHTTPError{ + run: func(_ context.Context, _ foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + return foundry.StreamSummary{}, providerHTTPError{ StatusCode: http.StatusServiceUnavailable, Operation: "POST responses", } @@ -977,9 +969,9 @@ func TestProviderFailureBeforeFirstEventIsNotRetryable(t *testing.T) { func TestObservedFailureAfterStartIsNotRetryable(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-observed-failure", Status: "in_progress"}) - return foundryStreamSummary{}, providerHTTPError{StatusCode: http.StatusServiceUnavailable, Operation: "POST responses"} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-observed-failure", Status: "in_progress"}) + return foundry.StreamSummary{}, providerHTTPError{StatusCode: http.StatusServiceUnavailable, Operation: "POST responses"} }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -998,9 +990,9 @@ func TestCompletedContinuationMustUseNewResponseID(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{ textResponseScript("resp-same", "session-same", "first"), { - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-same", Status: "in_progress", AgentSessionID: "session-same"}) - return foundryStreamSummary{ResponseID: "resp-same", AgentSessionID: "session-same", Status: "completed"}, nil + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-same", Status: "in_progress", AgentSessionID: "session-same"}) + return foundry.StreamSummary{ResponseID: "resp-same", AgentSessionID: "session-same", Status: "completed"}, nil }, }, }} @@ -1034,7 +1026,7 @@ func TestRecordFunctionCallRejectsSubmittedCallIDReuse(t *testing.T) { pendingTools: map[string]string{}, providerCallIDs: map[string]string{}, } - if err := adapter.recordFunctionCall(turn, foundryOutputItem{Type: "function_call", CallID: "call-reused", Name: "lookup", Arguments: json.RawMessage(`{}`)}); err == nil { + if err := adapter.recordFunctionCall(turn, foundry.OutputItem{Type: "function_call", CallID: "call-reused", Name: "lookup", Arguments: json.RawMessage(`{}`)}); err == nil { t.Fatal("submitted call id reuse was accepted") } } @@ -1081,7 +1073,7 @@ func TestAdapterPreservesOpaqueProviderCallIDs(t *testing.T) { emittedResults: map[string]struct{}{}, } providerCallID := " call:1 " - if err := adapter.recordFunctionCall(turn, foundryOutputItem{ + if err := adapter.recordFunctionCall(turn, foundry.OutputItem{ Type: "function_call", CallID: providerCallID, Name: "lookup", @@ -1101,9 +1093,9 @@ func TestAdapterPreservesOpaqueProviderCallIDs(t *testing.T) { func TestEmptyExecutionModeIsNormalizedToObserved(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { - _ = callbacks.OnCreated(foundryResponse{ID: "resp-empty-mode", Status: "in_progress"}) - return foundryStreamSummary{}, providerHTTPError{StatusCode: http.StatusServiceUnavailable, Operation: "POST responses"} + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { + _ = callbacks.OnCreated(foundry.Response{ID: "resp-empty-mode", Status: "in_progress"}) + return foundry.StreamSummary{}, providerHTTPError{StatusCode: http.StatusServiceUnavailable, Operation: "POST responses"} }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -1122,11 +1114,11 @@ func TestEmptyExecutionModeIsNormalizedToObserved(t *testing.T) { func TestTextDeltaSynthesizesTurnStartedFirst(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { if err := callbacks.OnTextDelta("hello"); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } - return foundryStreamSummary{ResponseID: "resp-no-created", AgentSessionID: "session-no-created", Status: "completed", Text: "hello"}, nil + return foundry.StreamSummary{ResponseID: "resp-no-created", AgentSessionID: "session-no-created", Status: "completed", Text: "hello"}, nil }, }}} adapter := newAdapter(testConfig("http://127.0.0.1"), backend) @@ -1142,13 +1134,13 @@ func TestTextDeltaSynthesizesTurnStartedFirst(t *testing.T) { func TestManySmallTextDeltasDoNotExhaustEventLimit(t *testing.T) { backend := &fakeResponsesBackend{responses: []scriptedResponse{{ - run: func(_ context.Context, callbacks responseCallbacks) (foundryStreamSummary, error) { + run: func(_ context.Context, callbacks foundry.ResponseCallbacks) (foundry.StreamSummary, error) { for range 5000 { if err := callbacks.OnTextDelta("x"); err != nil { - return foundryStreamSummary{}, err + return foundry.StreamSummary{}, err } } - return foundryStreamSummary{ + return foundry.StreamSummary{ ResponseID: "resp-small-deltas", AgentSessionID: "session-small-deltas", Status: "completed", diff --git a/config.go b/internal/adapter/config.go similarity index 62% rename from config.go rename to internal/adapter/config.go index f0f363f..f4caea2 100644 --- a/config.go +++ b/internal/adapter/config.go @@ -1,16 +1,15 @@ -package main +package adapter import ( "errors" "fmt" - "net" "net/url" "os" - "regexp" "strconv" "strings" "time" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" "github.com/orka-agents/agent-runtime-foundry/internal/harness" ) @@ -25,47 +24,25 @@ const ( maxRetainedTurns = 256 maxRetainedTurnStateBytes = 64 << 20 maxHarnessIdentityBytes = 512 - maxFoundryPromptBytes = 4 << 20 - maxFoundryToolSchemaBytes = 2 << 20 - maxProviderIdentifierBytes = 4 << 10 maxHarnessFrameBytes = (8 << 20) - (64 << 10) - defaultMaxOutputBytes = 1 << 20 - defaultMaxStreamBytes = 16 << 20 - defaultMaxEventBytes = 8 << 20 - defaultMaxBrokeredBytes = 4 << 20 - defaultMaxBrokeredTurnBytes = 16 << 20 - defaultMaxBrokeredCalls = 256 - defaultMaxEvents = 4096 defaultMaxTurns = 1 - - envAddr = "ORKA_FOUNDRY_ADAPTER_ADDR" - envRuntimeName = "ORKA_FOUNDRY_RUNTIME_NAME" - envAdapterBearer = "ORKA_FOUNDRY_ADAPTER_BEARER_" + "TOKEN" - envProjectEndpoint = "ORKA_FOUNDRY_PROJECT_ENDPOINT" - envResponsesURL = "ORKA_FOUNDRY_RESPONSES_ENDPOINT" - envAgentName = "ORKA_FOUNDRY_AGENT_NAME" - envAgentVersion = "ORKA_FOUNDRY_AGENT_VERSION" - envAPIVersion = "ORKA_FOUNDRY_API_VERSION" - envTurnTimeout = "ORKA_FOUNDRY_TURN_TIMEOUT" - envIsolationMode = "ORKA_FOUNDRY_ISOLATION_MODE" - envFoundryFeatures = "ORKA_FOUNDRY_FEATURES" - envBrokeredToolClasses = "ORKA_FOUNDRY_BROKERED_TOOL_CLASSES" - envToolSchemaMode = "ORKA_FOUNDRY_TOOL_SCHEMA_MODE" -) - -const ( - toolSchemaModeRequest = "request" - toolSchemaModeProviderStatic = "provider-static" + envAddr = "ORKA_FOUNDRY_ADAPTER_ADDR" + envRuntimeName = "ORKA_FOUNDRY_RUNTIME_NAME" + envAdapterBearer = "ORKA_FOUNDRY_ADAPTER_BEARER_" + "TOKEN" + envProjectEndpoint = "ORKA_FOUNDRY_PROJECT_ENDPOINT" + envResponsesURL = "ORKA_FOUNDRY_RESPONSES_ENDPOINT" + envAgentName = "ORKA_FOUNDRY_AGENT_NAME" + envAgentVersion = "ORKA_FOUNDRY_AGENT_VERSION" + envAPIVersion = "ORKA_FOUNDRY_API_VERSION" + envTurnTimeout = "ORKA_FOUNDRY_TURN_TIMEOUT" + envFoundryFeatures = "ORKA_FOUNDRY_FEATURES" + envBrokeredToolClasses = "ORKA_FOUNDRY_BROKERED_TOOL_CLASSES" + envToolSchemaMode = "ORKA_FOUNDRY_TOOL_SCHEMA_MODE" ) const foundryEndpointRequirement = "Foundry endpoint must use https " + "(http allowed only for loopback) and must not include credentials, query, or fragment" -var ( - agentNameRE = regexp.MustCompile(`^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$`) - agentVersionRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) -) - type config struct { addr string runtimeName string @@ -98,28 +75,28 @@ func loadConfig() config { brokeredToolClassSetting := os.Getenv(envBrokeredToolClasses) brokeredToolClasses := parseBrokeredToolClasses(brokeredToolClassSetting) return config{ - addr: firstNonBlank(os.Getenv(envAddr), defaultAddr), - runtimeName: firstNonBlank(os.Getenv(envRuntimeName), "foundry-hosted-runtime"), + addr: foundry.FirstNonBlank(os.Getenv(envAddr), defaultAddr), + runtimeName: foundry.FirstNonBlank(os.Getenv(envRuntimeName), "foundry-hosted-runtime"), adapterBearer: strings.TrimSpace(os.Getenv(envAdapterBearer)), projectEndpoint: projectEndpoint, responsesEndpoint: responsesEndpoint, agentName: strings.TrimSpace(os.Getenv(envAgentName)), agentVersion: strings.TrimSpace(os.Getenv(envAgentVersion)), - apiVersion: firstNonBlank(os.Getenv(envAPIVersion), defaultAPIVersion), + apiVersion: foundry.FirstNonBlank(os.Getenv(envAPIVersion), defaultAPIVersion), turnTimeout: parseDurationEnv(envTurnTimeout, defaultTurnTimeout), - isolationMode: firstNonBlank(os.Getenv(envIsolationMode), "entra"), + isolationMode: foundry.FirstNonBlank(os.Getenv(foundry.IsolationModeEnv), "entra"), foundryFeatures: foundryFeatures, - maxOutputBytes: defaultMaxOutputBytes, - maxStreamBytes: defaultMaxStreamBytes, - maxEventBytes: defaultMaxEventBytes, - maxBrokeredBytes: defaultMaxBrokeredBytes, - maxBrokeredTurnBytes: defaultMaxBrokeredTurnBytes, - maxBrokeredCalls: defaultMaxBrokeredCalls, - maxEvents: defaultMaxEvents, + maxOutputBytes: foundry.DefaultMaxOutputBytes, + maxStreamBytes: foundry.DefaultMaxStreamBytes, + maxEventBytes: foundry.DefaultMaxEventBytes, + maxBrokeredBytes: foundry.DefaultMaxBrokeredBytes, + maxBrokeredTurnBytes: foundry.DefaultMaxBrokeredTurnBytes, + maxBrokeredCalls: foundry.DefaultMaxBrokeredCalls, + maxEvents: foundry.DefaultMaxEvents, maxConcurrent: defaultMaxTurns, brokeredToolClasses: brokeredToolClasses, brokeredToolClassSetting: brokeredToolClassSetting, - toolSchemaMode: strings.ToLower(firstNonBlank(os.Getenv(envToolSchemaMode), toolSchemaModeRequest)), + toolSchemaMode: strings.ToLower(foundry.FirstNonBlank(os.Getenv(envToolSchemaMode), foundry.ToolSchemaModeRequest)), } } @@ -152,10 +129,10 @@ func (c config) validate() error { if strings.TrimSpace(c.adapterBearer) == "" { return errors.New("adapter bearer token is required") } - if err := validateAgentName(c.agentName); err != nil { + if err := foundry.ValidateAgentName(c.agentName); err != nil { return err } - if err := validateAgentVersion(c.agentVersion); err != nil { + if err := foundry.ValidateAgentVersion(c.agentVersion); err != nil { return err } if c.turnTimeout <= 0 { @@ -167,11 +144,11 @@ func (c config) validate() error { if c.projectEndpoint == "" { return errors.New("foundry project endpoint is required") } - if !foundryEndpointIsSafe(c.projectEndpoint) { + if !foundry.EndpointIsSafe(c.projectEndpoint) { return errors.New(strings.ToLower(foundryEndpointRequirement[:1]) + foundryEndpointRequirement[1:]) } if c.responsesEndpoint != "" { - if !foundryEndpointIsSafe(c.responsesEndpoint) { + if !foundry.EndpointIsSafe(c.responsesEndpoint) { return errors.New(strings.ToLower(foundryEndpointRequirement[:1]) + foundryEndpointRequirement[1:]) } projectURL, projectErr := url.Parse(c.projectEndpoint) @@ -200,9 +177,9 @@ func (c config) validate() error { } } switch strings.ToLower(strings.TrimSpace(c.toolSchemaMode)) { - case "", toolSchemaModeRequest, toolSchemaModeProviderStatic: + case "", foundry.ToolSchemaModeRequest, foundry.ToolSchemaModeProviderStatic: default: - return fmt.Errorf("foundry tool schema mode must be %s or %s", toolSchemaModeRequest, toolSchemaModeProviderStatic) + return fmt.Errorf("foundry tool schema mode must be %s or %s", foundry.ToolSchemaModeRequest, foundry.ToolSchemaModeProviderStatic) } switch strings.ToLower(strings.TrimSpace(c.isolationMode)) { case "entra", "header": @@ -237,55 +214,6 @@ func effectivePort(endpoint *url.URL) string { } } -func validateAgentName(name string) error { - if name == "" { - return errors.New("foundry agent name is required") - } - if len(name) > 63 || !agentNameRE.MatchString(name) { - return errors.New("foundry agent name must be 1-63 alphanumeric or hyphen characters without leading, trailing, or repeated hyphens") - } - return nil -} - -func validateAgentVersion(version string) error { - if version == "" { - return nil - } - if strings.HasPrefix(version, "@") || !agentVersionRE.MatchString(version) { - return errors.New("foundry agent version must be a concrete version identifier") - } - return nil -} - -func foundryEndpointIsSafe(raw string) bool { - trimmed := strings.TrimSpace(raw) - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return false - } - if parsed.User != nil || parsed.ForceQuery || parsed.RawQuery != "" || strings.Contains(trimmed, "#") { - return false - } - if strings.EqualFold(parsed.Scheme, "https") { - return true - } - if !strings.EqualFold(parsed.Scheme, "http") { - return false - } - host := strings.Trim(strings.ToLower(parsed.Hostname()), "[]") - ip := net.ParseIP(host) - return host == "localhost" || (ip != nil && ip.IsLoopback()) -} - -func firstNonBlank(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - func parseDurationEnv(name string, fallback time.Duration) time.Duration { value, exists := os.LookupEnv(name) if !exists || strings.TrimSpace(value) == "" { diff --git a/config_test.go b/internal/adapter/config_test.go similarity index 61% rename from config_test.go rename to internal/adapter/config_test.go index c8afd27..269eb8d 100644 --- a/config_test.go +++ b/internal/adapter/config_test.go @@ -1,55 +1,12 @@ -package main +package adapter import ( "testing" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" "github.com/orka-agents/agent-runtime-foundry/internal/harness" ) -func TestValidateAgentNameAndVersion(t *testing.T) { - for _, name := range []string{"agent", "agent-1", "Agent-1"} { - if err := validateAgentName(name); err != nil { - t.Fatalf("validateAgentName(%q): %v", name, err) - } - } - for _, name := range []string{"", "-agent", "agent-", "agent--one", "agent_one", string(make([]byte, 64))} { - if err := validateAgentName(name); err == nil { - t.Fatalf("validateAgentName(%q) succeeded", name) - } - } - for _, version := range []string{"", "1", "2026.07.15", "v2-build_1"} { - if err := validateAgentVersion(version); err != nil { - t.Fatalf("validateAgentVersion(%q): %v", version, err) - } - } - for _, version := range []string{"@latest", " bad", "bad/version"} { - if err := validateAgentVersion(version); err == nil { - t.Fatalf("validateAgentVersion(%q) succeeded", version) - } - } -} - -func TestFoundryEndpointIsSafe(t *testing.T) { - tests := []struct { - endpoint string - want bool - }{ - {endpoint: "https://example.services.ai.azure.com/api/projects/demo", want: true}, - {endpoint: "http://localhost:8080", want: true}, - {endpoint: "http://127.0.0.1:8080", want: true}, - {endpoint: "http://[::1]:8080", want: true}, - {endpoint: "http://example.services.ai.azure.com", want: false}, - {endpoint: "https://user@example.services.ai.azure.com", want: false}, - {endpoint: "https://example.services.ai.azure.com?api-version=v1", want: false}, - {endpoint: "https://example.services.ai.azure.com#fragment", want: false}, - } - for _, test := range tests { - if got := foundryEndpointIsSafe(test.endpoint); got != test.want { - t.Fatalf("foundryEndpointIsSafe(%q) = %v, want %v", test.endpoint, got, test.want) - } - } -} - func TestDefaultFoundryFeaturesHonorsExplicitEmptyValue(t *testing.T) { t.Setenv(envFoundryFeatures, "") if got := defaultFoundryFeatures(); got != "" { @@ -58,9 +15,9 @@ func TestDefaultFoundryFeaturesHonorsExplicitEmptyValue(t *testing.T) { } func TestToolSchemaModeConfiguration(t *testing.T) { - t.Setenv(envToolSchemaMode, toolSchemaModeProviderStatic) - if got := loadConfig().toolSchemaMode; got != toolSchemaModeProviderStatic { - t.Fatalf("tool schema mode = %q, want %q", got, toolSchemaModeProviderStatic) + t.Setenv(envToolSchemaMode, foundry.ToolSchemaModeProviderStatic) + if got := loadConfig().toolSchemaMode; got != foundry.ToolSchemaModeProviderStatic { + t.Fatalf("tool schema mode = %q, want %q", got, foundry.ToolSchemaModeProviderStatic) } cfg := testConfig("https://account.services.ai.azure.com") diff --git a/context.go b/internal/adapter/context.go similarity index 97% rename from context.go rename to internal/adapter/context.go index e545465..f70ed5c 100644 --- a/context.go +++ b/internal/adapter/context.go @@ -1,4 +1,4 @@ -package main +package adapter import ( "context" diff --git a/internal/adapter/responses.go b/internal/adapter/responses.go new file mode 100644 index 0000000..317043c --- /dev/null +++ b/internal/adapter/responses.go @@ -0,0 +1,250 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +const backendMetadata = "foundry-hosted-responses" + +type foundryResponsesClient struct { + cfg config + httpClient *http.Client + tokenProvider foundry.TokenProvider +} + +func newResponsesClient(cfg config, httpClient *http.Client, provider foundry.TokenProvider) *foundryResponsesClient { + return &foundryResponsesClient{cfg, httpClient, provider} +} + +type providerHTTPError struct { + StatusCode int + Operation string +} + +func (e providerHTTPError) Error() string { + return fmt.Sprintf("Foundry %s failed with HTTP %d", e.Operation, e.StatusCode) +} + +func newFoundryHTTPClient(endpoint string) *http.Client { + base, _ := url.Parse(strings.TrimSpace(endpoint)) + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 Foundry redirects") + } + if base == nil || !strings.EqualFold(req.URL.Scheme, base.Scheme) || + !strings.EqualFold(req.URL.Host, base.Host) { + return errors.New("refusing Foundry redirect outside the configured origin") + } + return nil + }, + } +} + +func (c *foundryResponsesClient) createResponse( + ctx context.Context, + request foundry.ResponseRequest, + callbacks foundry.ResponseCallbacks, +) (foundry.StreamSummary, error) { + request.Stream = true + request.Store = true + body, err := json.Marshal(request) + if err != nil { + return foundry.StreamSummary{}, err + } + response, err := c.do(ctx, http.MethodPost, c.responsesURL(), bytes.NewReader(body)) + if err != nil { + return foundry.StreamSummary{}, err + } + defer response.Body.Close() //nolint:errcheck + mediaType := strings.ToLower(response.Header.Get("Content-Type")) + if strings.Contains(mediaType, "text/event-stream") { + return foundry.ParseSSE(response.Body, c.cfg.maxStreamBytes, c.cfg.maxEventBytes, c.cfg.maxEvents, callbacks) + } + return foundry.ParseJSON(response.Body, c.cfg.maxStreamBytes, callbacks) +} + +func (c *foundryResponsesClient) createSession(ctx context.Context) (string, error) { + body := []byte(`{}`) + if c.cfg.agentVersion != "" { + encoded, err := json.Marshal(map[string]any{ + "version_indicator": map[string]string{ + "type": "version_ref", + "agent_version": c.cfg.agentVersion, + }, + }) + if err != nil { + return "", err + } + body = encoded + } + response, err := c.do(ctx, http.MethodPost, c.sessionsURL(), bytes.NewReader(body)) + if err != nil { + return "", err + } + defer response.Body.Close() //nolint:errcheck + data, err := io.ReadAll(io.LimitReader(response.Body, c.cfg.maxEventBytes+1)) + if err != nil { + return "", err + } + if int64(len(data)) > c.cfg.maxEventBytes { + return "", errors.New("foundry session response exceeded adapter limit") + } + var payload struct { + AgentSessionID string `json:"agent_session_id"` + SessionID string `json:"session_id"` + ID string `json:"id"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", errors.New("foundry session response was invalid JSON") + } + sessionID := foundry.FirstNonBlank(payload.AgentSessionID, payload.SessionID, payload.ID) + if sessionID == "" { + return "", errors.New("foundry session response did not include a session id") + } + if err := foundry.ValidateIdentifier("agent session id", sessionID); err != nil { + return "", err + } + return sessionID, nil +} + +func (c *foundryResponsesClient) validateAgent(ctx context.Context) error { + agentURL, err := c.agentURL() + if err != nil { + return err + } + response, err := c.do(ctx, http.MethodGet, agentURL, nil) + if err != nil { + return err + } + defer response.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + if c.cfg.agentVersion == "" { + return nil + } + versionURL, err := c.agentVersionURL() + if err != nil { + return err + } + versionResponse, err := c.do(ctx, http.MethodGet, versionURL, nil) + if err != nil { + return err + } + defer versionResponse.Body.Close() //nolint:errcheck + data, err := io.ReadAll(io.LimitReader(versionResponse.Body, c.cfg.maxEventBytes+1)) + if err != nil { + return err + } + if int64(len(data)) > c.cfg.maxEventBytes { + return errors.New("foundry agent-version response exceeded adapter limit") + } + var version struct { + Status string `json:"status"` + } + if err := json.Unmarshal(data, &version); err != nil { + return errors.New("foundry agent-version response was invalid JSON") + } + if !strings.EqualFold(strings.TrimSpace(version.Status), "active") { + return errors.New("configured Foundry agent version is not active") + } + return nil +} + +func (c *foundryResponsesClient) do(ctx context.Context, method, rawURL string, body io.Reader) (*http.Response, error) { + if c == nil || c.httpClient == nil || c.tokenProvider == nil { + return nil, errors.New("foundry client is not configured") + } + request, err := http.NewRequestWithContext(ctx, method, rawURL, body) + if err != nil { + return nil, err + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + request.Header.Set("Accept", "text/event-stream, application/json") + if c.cfg.foundryFeatures != "" { + request.Header.Set("Foundry-Features", c.cfg.foundryFeatures) + } + if strings.EqualFold(c.cfg.isolationMode, "header") { + isolationKey, ok := foundryIsolationKeyFromContext(ctx) + if !ok || isolationKey == "" { + return nil, errors.New("foundry header isolation requires a scoped isolation key") + } + request.Header.Set("x-ms-user-isolation-key", isolationKey) + } + token, err := c.tokenProvider.AccessToken(ctx) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+token) + response, err := c.httpClient.Do(request) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + defer response.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return nil, providerHTTPError{StatusCode: response.StatusCode, Operation: method + " " + request.URL.Path} + } + return response, nil +} + +func (c *foundryResponsesClient) responsesURL() string { + if c.cfg.responsesEndpoint != "" { + return withAPIVersion(c.cfg.responsesEndpoint, c.cfg.apiVersion) + } + base := strings.TrimRight(c.cfg.projectEndpoint, "/") + "/agents/" + url.PathEscape(c.cfg.agentName) + + "/endpoint/protocols/openai/responses" + return withAPIVersion(base, c.cfg.apiVersion) +} + +func (c *foundryResponsesClient) sessionsURL() string { + base := c.cfg.projectEndpoint + if base == "" { + u, _ := url.Parse(c.cfg.responsesEndpoint) + suffix := "/agents/" + url.PathEscape(c.cfg.agentName) + "/endpoint/protocols/openai/responses" + base = strings.TrimSuffix(strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/"), suffix) + } + return withAPIVersion(strings.TrimRight(base, "/")+"/agents/"+url.PathEscape(c.cfg.agentName)+"/endpoint/sessions", c.cfg.apiVersion) +} + +func (c *foundryResponsesClient) agentURL() (string, error) { + if c.cfg.projectEndpoint == "" { + return "", errors.New("foundry project endpoint is required for readiness validation") + } + return withAPIVersion(strings.TrimRight(c.cfg.projectEndpoint, "/")+"/agents/"+url.PathEscape(c.cfg.agentName), c.cfg.apiVersion), nil +} + +func (c *foundryResponsesClient) agentVersionURL() (string, error) { + agentURL, err := c.agentURL() + if err != nil { + return "", err + } + u, err := url.Parse(agentURL) + if err != nil { + return "", err + } + u.Path = strings.TrimRight(u.Path, "/") + "/versions/" + url.PathEscape(c.cfg.agentVersion) + return u.String(), nil +} + +func withAPIVersion(rawURL, apiVersion string) string { + u, err := url.Parse(rawURL) + if err != nil || apiVersion == "" { + return rawURL + } + query := u.Query() + query.Set("api-version", apiVersion) + u.RawQuery = query.Encode() + return u.String() +} diff --git a/internal/adapter/responses_test.go b/internal/adapter/responses_test.go new file mode 100644 index 0000000..1e35c86 --- /dev/null +++ b/internal/adapter/responses_test.go @@ -0,0 +1,131 @@ +package adapter + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/harness" +) + +type staticFoundryTokenProvider string + +func (p staticFoundryTokenProvider) AccessToken(context.Context) (string, error) { + return string(p), nil +} + +func TestFoundryResponsesClientRequestShapeAndHeaders(t *testing.T) { + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/projects/demo/agents/hosted-agent/endpoint/protocols/openai/responses" { + t.Fatalf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("api-version") != "v1" { + t.Fatalf("api-version = %q", r.URL.Query().Get("api-version")) + } + if got := r.Header.Get("Authorization"); got != "Bearer mock-token" { + t.Fatalf("authorization = %q", got) + } + if got := r.Header.Get("Foundry-Features"); got != "HostedAgents=V1Preview" { + t.Fatalf("feature header = %q", got) + } + if got := r.Header.Get("x-ms-user-isolation-key"); got != "isolation-1" { + t.Fatalf("isolation header = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-2\",\"status\":\"in_progress\",\"agent_session_id\":\"session-2\"}}\n\n") + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"status\":\"completed\",\"agent_session_id\":\"session-2\"}}\n\n") + })) + defer server.Close() + + cfg := testConfig(server.URL) + cfg.isolationMode = "header" + client := testResponsesClient(cfg, server.URL) + ctx := withFoundryIsolationKey(context.Background(), "isolation-1") + _, err := client.createResponse(ctx, foundry.ResponseRequest{ + Input: []foundry.FunctionOutput{{Type: "function_call_output", CallID: "call-1", Output: `{"ok":true}`}}, + PreviousResponseID: "resp-1", + AgentSessionID: "session-1", + Tools: []foundry.ToolSchema{{Type: "function", Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}, + }, foundry.ResponseCallbacks{}) + if err != nil { + t.Fatalf("createResponse: %v", err) + } + if requestBody["stream"] != true || requestBody["store"] != true || requestBody["previous_response_id"] != "resp-1" || requestBody["agent_session_id"] != "session-1" { + t.Fatalf("request body = %#v", requestBody) + } + input, ok := requestBody["input"].([]any) + if !ok || len(input) != 1 || input[0].(map[string]any)["type"] != "function_call_output" { + t.Fatalf("input = %#v", requestBody["input"]) + } + tools, ok := requestBody["tools"].([]any) + if !ok || len(tools) != 1 || tools[0].(map[string]any)["name"] != "lookup" { + t.Fatalf("tools = %#v", requestBody["tools"]) + } +} + +func TestFoundryResponsesClientVersionValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/projects/demo/agents/hosted-agent": + _, _ = io.WriteString(w, `{"name":"hosted-agent"}`) + case "/api/projects/demo/agents/hosted-agent/versions/2": + _, _ = io.WriteString(w, `{"status":"active"}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + cfg := testConfig(server.URL) + cfg.agentVersion = "2" + client := testResponsesClient(cfg, server.URL) + if err := client.validateAgent(context.Background()); err != nil { + t.Fatalf("validateAgent: %v", err) + } +} + +func TestProviderSafeMessageRedactsOpaqueFailures(t *testing.T) { + if got := providerSafeMessage(errors.New("token is super-secret")); got != "Foundry request failed" { + t.Fatalf("message = %q", got) + } +} + +func testConfig(baseURL string) config { + return config{ + addr: ":0", + runtimeName: "foundry-test", + adapterBearer: "adapter-token", + projectEndpoint: baseURL + "/api/projects/demo", + agentName: "hosted-agent", + apiVersion: "v1", + turnTimeout: 5 * time.Second, + isolationMode: "entra", + foundryFeatures: "HostedAgents=V1Preview", + maxOutputBytes: 1 << 20, + maxStreamBytes: 1 << 20, + maxEventBytes: 1 << 16, + maxBrokeredBytes: 1 << 16, + maxBrokeredTurnBytes: 1 << 20, + maxBrokeredCalls: 128, + maxEvents: 128, + maxConcurrent: 1, + brokeredToolClasses: []harness.BrokeredToolClass{ + harness.BrokeredToolClassRead, + harness.BrokeredToolClassWrite, + }, + } +} + +func testResponsesClient(cfg config, serverURL string) *foundryResponsesClient { + return &foundryResponsesClient{cfg, newFoundryHTTPClient(serverURL), staticFoundryTokenProvider("mock-token")} +} diff --git a/main.go b/internal/adapter/run.go similarity index 55% rename from main.go rename to internal/adapter/run.go index fb2e376..c162f71 100644 --- a/main.go +++ b/internal/adapter/run.go @@ -1,38 +1,22 @@ -package main +package adapter import ( "log" "net/http" - "os" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) -func main() { - if handled, err := maybeServeHosted(os.Args[1:]); handled { - if err != nil { - log.Fatal("Foundry hosted lifetime unavailable; inspect the ownership ledger before replacement") - } - return - } - if handled, err := maybeServeBroker(os.Args[1:]); handled { - if err != nil { - log.Fatal("Foundry lifecycle broker failed") - } - return - } - if handled, err := maybeServeACP(os.Args[1:], os.Stdin, os.Stdout); handled { - if err != nil { - log.Fatal("Foundry ACP bridge failed") - } - return - } +// Serve runs the harness v1 HTTP adapter using its environment configuration. +func Serve() error { cfg := loadConfig() if err := cfg.validate(); err != nil { - log.Fatal(err) + return err } - credentialProvider, err := newAzureFoundryTokenProvider() + credentialProvider, err := foundry.NewTokenProvider() if err != nil { - log.Fatal(err) + return err } endpointForRedirects := cfg.responsesEndpoint if endpointForRedirects == "" { @@ -40,6 +24,7 @@ func main() { } foundryClient := newResponsesClient(cfg, newFoundryHTTPClient(endpointForRedirects), credentialProvider) adapter := newAdapter(cfg, foundryBackend{client: foundryClient}) + harnessServer := &server{cfg: cfg, adapter: adapter} log.Printf("Foundry Hosted Agents adapter listening on %s (runtime=%s agent=%s)", cfg.addr, cfg.runtimeName, cfg.agentName) httpServer := &http.Server{ @@ -49,5 +34,5 @@ func main() { ReadTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } - log.Fatal(httpServer.ListenAndServe()) + return httpServer.ListenAndServe() } diff --git a/server.go b/internal/adapter/server.go similarity index 99% rename from server.go rename to internal/adapter/server.go index e9afcaf..bbc85a1 100644 --- a/server.go +++ b/internal/adapter/server.go @@ -1,4 +1,4 @@ -package main +package adapter import ( "context" diff --git a/broker.go b/internal/broker/broker.go similarity index 87% rename from broker.go rename to internal/broker/broker.go index ce63798..8200d22 100644 --- a/broker.go +++ b/internal/broker/broker.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -10,6 +10,9 @@ import ( "net/http" "sync" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) // Preserve cleanup receipts even for owners already at the ordinary operation @@ -27,7 +30,7 @@ type brokerActive struct { type lifecycleBroker struct { cfg brokerConfiguration - tokenProvider foundryTokenProvider + tokenProvider foundry.TokenProvider httpClient *http.Client store *brokerStore mu sync.Mutex @@ -41,8 +44,8 @@ type lifecycleBroker struct { wg sync.WaitGroup } -func newLifecycleBroker(ctx context.Context, cfg brokerConfiguration, provider foundryTokenProvider, client *http.Client) (*lifecycleBroker, error) { - if provider == nil || len(cfg.bearer) < 32 || !brokerDigestValid(cfg.configDigest) { +func newLifecycleBroker(ctx context.Context, cfg brokerConfiguration, provider foundry.TokenProvider, client *http.Client) (*lifecycleBroker, error) { + if provider == nil || len(cfg.bearer) < 32 || !foundry.DigestValid(cfg.configDigest) { return nil, errBrokerInvalid } store, ledger, err := openBrokerStore(cfg.stateDir, cfg.configDigest) @@ -152,7 +155,7 @@ func (b *lifecycleBroker) failStorageLocked() error { } func brokerEnsureSession(next *brokerLedger, c brokerContext) (*brokerSession, error) { - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) if session := next.Sessions[key]; session != nil { return session, nil } @@ -205,9 +208,9 @@ func brokerRecordOperation(session *brokerSession, path string, c brokerContext) } limit := brokerOperationLimit switch path { - case brokerSettlePath: + case brokerapi.SettlePath: limit = brokerSettlementOperationLimit - case brokerRetirePath: + case brokerapi.RetirePath: limit = brokerRetirementOperationLimit } if len(session.Operations) >= limit { @@ -235,20 +238,20 @@ func (b *lifecycleBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) return } - validPath := r.URL.Path == brokerResponsesPath || r.URL.Path == brokerRenewPath || r.URL.Path == brokerSettlePath || - r.URL.Path == brokerRetirePath || r.URL.Path == brokerStatusPath + validPath := r.URL.Path == brokerapi.ResponsesPath || r.URL.Path == brokerapi.RenewPath || r.URL.Path == brokerapi.SettlePath || + r.URL.Path == brokerapi.RetirePath || r.URL.Path == brokerapi.StatusPath if !validPath || r.URL.RawQuery != "" || r.URL.RawPath != "" || r.Header.Get("Content-Encoding") != "" || - (r.URL.Path == brokerStatusPath && r.Method != http.MethodGet) || (r.URL.Path != brokerStatusPath && r.Method != http.MethodPost) { + (r.URL.Path == brokerapi.StatusPath && r.Method != http.MethodGet) || (r.URL.Path != brokerapi.StatusPath && r.Method != http.MethodPost) { w.WriteHeader(http.StatusBadRequest) return } - body, err := io.ReadAll(io.LimitReader(r.Body, maxFoundryPromptBytes+1)) - if err != nil || len(body) > maxFoundryPromptBytes { + body, err := io.ReadAll(io.LimitReader(r.Body, foundry.MaxPromptBytes+1)) + if err != nil || len(body) > foundry.MaxPromptBytes { w.WriteHeader(http.StatusBadRequest) return } - if (r.URL.Path == brokerStatusPath && len(body) != 0) || - (r.URL.Path != brokerResponsesPath && r.URL.Path != brokerStatusPath && string(body) != "{}") { + if (r.URL.Path == brokerapi.StatusPath && len(body) != 0) || + (r.URL.Path != brokerapi.ResponsesPath && r.URL.Path != brokerapi.StatusPath && string(body) != "{}") { w.WriteHeader(http.StatusBadRequest) return } @@ -257,22 +260,22 @@ func (b *lifecycleBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { brokerWriteError(w, err) return } - if r.URL.Path == brokerResponsesPath { + if r.URL.Path == brokerapi.ResponsesPath { b.serveResponses(w, r, c, body) return } - if r.URL.Path != brokerStatusPath { + if r.URL.Path != brokerapi.StatusPath { if err := b.control(r.URL.Path, c); err != nil { brokerWriteError(w, err) return } } - if r.URL.Path == brokerSettlePath || r.URL.Path == brokerRetirePath { - b.startReconcile(brokerJSONDigest(c.Owner), true) + if r.URL.Path == brokerapi.SettlePath || r.URL.Path == brokerapi.RetirePath { + b.startReconcile(foundry.JSONDigest(c.Owner), true) } b.mu.Lock() response := b.controlResponseLocked(c, contextDigest) - if r.URL.Path == brokerRenewPath && b.canAcknowledgeCreateRenewalLocked(c, response) { + if r.URL.Path == brokerapi.RenewPath && b.canAcknowledgeCreateRenewalLocked(c, response) { response.State = "open" } storageErr := b.storageError @@ -282,7 +285,7 @@ func (b *lifecycleBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } status := http.StatusOK - if (r.URL.Path == brokerSettlePath && !response.SettlementProven) || (r.URL.Path == brokerRetirePath && !response.RetirementProven) { + if (r.URL.Path == brokerapi.SettlePath && !response.SettlementProven) || (r.URL.Path == brokerapi.RetirePath && !response.RetirementProven) { status = http.StatusConflict } w.Header().Set("Content-Type", "application/json") @@ -308,9 +311,9 @@ func brokerWriteError(w http.ResponseWriter, err error) { func (b *lifecycleBroker) control(path string, c brokerContext) error { b.mu.Lock() defer b.mu.Unlock() - session := b.ledger.Sessions[brokerJSONDigest(c.Owner)] - ordinary := path == brokerRenewPath || session == nil || - (path == brokerSettlePath && session.Prompts[c.promptKey()] == nil) + session := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] + ordinary := path == brokerapi.RenewPath || session == nil || + (path == brokerapi.SettlePath && session.Prompts[c.promptKey()] == nil) err := b.commitCapacityLocked(ordinary, func(next *brokerLedger) error { session, err := brokerEnsureSession(next, c) if err != nil { @@ -320,7 +323,7 @@ func (b *lifecycleBroker) control(path string, c brokerContext) error { if err != nil { return err } - if path == brokerRetirePath { + if path == brokerapi.RetirePath { session.Retiring = true for _, prompt := range session.Prompts { prompt.Closing = true @@ -328,7 +331,7 @@ func (b *lifecycleBroker) control(path string, c brokerContext) error { return nil } if session.Retiring || session.Retired { - if path == brokerRenewPath { + if path == brokerapi.RenewPath { return errBrokerClosed } } @@ -336,7 +339,7 @@ func (b *lifecycleBroker) control(path string, c brokerContext) error { if err != nil { return err } - if path == brokerSettlePath { + if path == brokerapi.SettlePath { prompt.Closing = true return nil } @@ -358,8 +361,8 @@ func (b *lifecycleBroker) control(path string, c brokerContext) error { prompt.LeaseGeneration, prompt.LeaseExpiresAt = c.LeaseGeneration, expires return nil }) - if err == nil && (path == brokerSettlePath || path == brokerRetirePath) { - if active, ok := b.active[brokerJSONDigest(c.Owner)]; ok && (path == brokerRetirePath || active.prompt == c.promptKey()) { + if err == nil && (path == brokerapi.SettlePath || path == brokerapi.RetirePath) { + if active, ok := b.active[foundry.JSONDigest(c.Owner)]; ok && (path == brokerapi.RetirePath || active.prompt == c.promptKey()) { active.cancel() } } @@ -367,7 +370,7 @@ func (b *lifecycleBroker) control(path string, c brokerContext) error { } func (b *lifecycleBroker) controlResponseLocked(c brokerContext, contextDigest string) brokerControlResponse { - response := brokerControlResponse{Protocol: brokerProtocol, OwnerDigest: brokerJSONDigest(c.Owner), OperationID: c.OperationID, + response := brokerControlResponse{Protocol: brokerProtocol, OwnerDigest: foundry.JSONDigest(c.Owner), OperationID: c.OperationID, ContextSHA256: contextDigest, State: "open"} session := b.ledger.Sessions[response.OwnerDigest] if session == nil { @@ -435,7 +438,7 @@ func (b *lifecycleBroker) canAcknowledgeCreateRenewalLocked(c brokerContext, res response.AmbiguousInvocations != 0 || response.ActiveInvocations != 1 || response.LeaseGeneration != c.LeaseGeneration { return false } - key, promptKey := brokerJSONDigest(c.Owner), c.promptKey() + key, promptKey := foundry.JSONDigest(c.Owner), c.promptKey() session := b.ledger.Sessions[key] if session == nil || session.CreateState != "intent" || session.Retiring || session.Retired || session.CurrentPrompt != promptKey { return false @@ -606,9 +609,9 @@ func (b *lifecycleBroker) reconcile(ctx context.Context, key string) { for _, promptKey := range closing { prompt := session.Prompts[promptKey] prompt.Settled = true - prompt.ProofDigest = brokerJSONDigest(struct { + prompt.ProofDigest = foundry.JSONDigest(struct { Owner, Prompt, Remote, Kind, At string - }{key, promptKey, brokerSHA([]byte(remoteID)), kind, time.Now().UTC().Format(time.RFC3339Nano)}) + }{key, promptKey, foundry.Digest([]byte(remoteID)), kind, time.Now().UTC().Format(time.RFC3339Nano)}) for _, invocation := range prompt.Invocations { if invocation.State != "completed" { invocation.State = "settled" @@ -627,8 +630,8 @@ func (b *lifecycleBroker) reconcile(ctx context.Context, key string) { session.CreateState = "deleted" } session.Retired = true - session.ProofDigest = brokerJSONDigest(struct{ Owner, Remote, Kind, At string }{ - key, brokerSHA([]byte(remoteID)), kind, time.Now().UTC().Format(time.RFC3339Nano)}) + session.ProofDigest = foundry.JSONDigest(struct{ Owner, Remote, Kind, At string }{ + key, foundry.Digest([]byte(remoteID)), kind, time.Now().UTC().Format(time.RFC3339Nano)}) return nil }) } diff --git a/broker_test.go b/internal/broker/broker_test.go similarity index 87% rename from broker_test.go rename to internal/broker/broker_test.go index 7db004c..9996a15 100644 --- a/broker_test.go +++ b/internal/broker/broker_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -15,6 +15,9 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) const brokerFixtureBearer = "test-broker-bearer-with-more-than-thirty-two-bytes" @@ -45,7 +48,7 @@ type brokerFixture struct { release chan struct{} startOnce sync.Once releaseOnce sync.Once - requests []foundryResponseRequest + requests []foundry.ResponseRequest createCheck func(string) } @@ -88,7 +91,7 @@ func (f *brokerFixture) serve(w http.ResponseWriter, r *http.Request) { return } if suffix == "/endpoint/sessions" && r.Method == http.MethodPost { - var request brokerRemoteSession + var request foundry.RemoteSession if json.NewDecoder(r.Body).Decode(&request) != nil || request.ID == "" || request.Version.Type != "version_ref" || request.Version.Version != "3" { f.t.Error("session creation did not contain exact chosen identity and version") w.WriteHeader(400) @@ -113,7 +116,7 @@ func (f *brokerFixture) serve(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(status) if f.mode == "create-rejected-oversized" { - _, _ = io.WriteString(w, strings.Repeat("x", acpMaxConfigBytes+1)) + _, _ = io.WriteString(w, strings.Repeat("x", foundry.MaxAgentConfigBytes+1)) return } _, _ = io.WriteString(w, `{"error":"fixture creation rejection"}`) @@ -151,7 +154,7 @@ func (f *brokerFixture) serve(w http.ResponseWriter, r *http.Request) { return } if suffix == "/endpoint/protocols/openai/responses" && r.Method == http.MethodPost { - var request foundryResponseRequest + var request foundry.ResponseRequest if json.NewDecoder(r.Body).Decode(&request) != nil { w.WriteHeader(400) return @@ -202,7 +205,7 @@ func (f *brokerFixture) serve(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(status) if f.mode == "rejected-oversized" { - _, _ = io.WriteString(w, strings.Repeat("x", acpMaxConfigBytes+1)) + _, _ = io.WriteString(w, strings.Repeat("x", foundry.MaxAgentConfigBytes+1)) return } _, _ = io.WriteString(w, `{"error":"fixture rejection"}`) @@ -265,10 +268,10 @@ func (f *brokerFixture) sessionJSON(w http.ResponseWriter, id, state string) { func brokerTestConfig(t *testing.T, fixture *brokerFixture) brokerConfiguration { t.Helper() - agent := acpAgentConfiguration{Model: "fixture-model", ToolSchemaMode: toolSchemaModeProviderStatic, - HostedTarget: acpHostedTarget{ProjectEndpoint: fixture.server.URL + "/api/projects/fixture", AgentName: "fixture", AgentVersion: "3"}} + agent := foundry.AgentConfig{Model: "fixture-model", ToolSchemaMode: foundry.ToolSchemaModeProviderStatic, + HostedTarget: foundry.HostedTarget{ProjectEndpoint: fixture.server.URL + "/api/projects/fixture", AgentName: "fixture", AgentVersion: "3"}} raw, _ := json.Marshal(agent) - return brokerConfiguration{agent: agent, configDigest: brokerSHA(raw), stateDir: filepath.Join(t.TempDir(), "broker"), bearer: brokerFixtureBearer, operationTimeout: 2 * time.Second} + return brokerConfiguration{agent: agent, configDigest: foundry.Digest(raw), stateDir: filepath.Join(t.TempDir(), "broker"), bearer: brokerFixtureBearer, operationTimeout: 2 * time.Second} } func startBrokerTest(t *testing.T, cfg brokerConfiguration) (*lifecycleBroker, *httptest.Server) { @@ -297,17 +300,17 @@ func brokerTestContext(cfg brokerConfiguration) brokerContext { } func brokerTestBody(previous string) []byte { - request := acpResponseRequest{Model: "fixture-model", foundryResponseRequest: foundryResponseRequest{ + request := foundry.ModelResponseRequest{Model: "fixture-model", ResponseRequest: foundry.ResponseRequest{ Input: "fixture-input-do-not-persist", Stream: true, Store: true, PreviousResponseID: previous}} data, _ := json.Marshal(request) return data } func brokerTestHTTP(ctx context.Context, base, path string, c brokerContext, body []byte) (int, []byte, error) { - c.BodySHA256 = brokerSHA(body) + c.BodySHA256 = foundry.Digest(body) raw, _ := json.Marshal(c) method := http.MethodPost - if path == brokerStatusPath { + if path == brokerapi.StatusPath { method = http.MethodGet } request, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body)) @@ -328,7 +331,7 @@ func brokerTestHTTP(ctx context.Context, base, path string, c brokerContext, bod func brokerTestControlContext(path string, c brokerContext) (brokerContext, []byte) { c.InvocationSequence = 0 c.OperationID = "control-" + strings.TrimPrefix(path, "/internal/v1/") + "-" + c.PromptID - if path == brokerRetirePath || path == brokerStatusPath { + if path == brokerapi.RetirePath || path == brokerapi.StatusPath { c.TaskUID = "" c.TaskAttempt = 0 c.PromptID = "" @@ -337,7 +340,7 @@ func brokerTestControlContext(path string, c brokerContext) (brokerContext, []by c.LeaseExpiresAt = "" } body := []byte("{}") - if path == brokerStatusPath { + if path == brokerapi.StatusPath { body = nil } return c, body @@ -385,31 +388,31 @@ func TestBrokerLifecycleOwnershipContinuationAndRetirement(t *testing.T) { } } c := brokerTestContext(cfg) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) - var first foundryResponse + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) + var first foundry.Response if err != nil || status != 200 || json.Unmarshal(data, &first) != nil { t.Fatalf("first response failed: %d", status) } if !strings.HasPrefix(first.ID, "fr_") || first.AgentSessionID != "" || bytes.Contains(data, []byte("provider-")) { t.Fatal("provider identity escaped into ACP response") } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) - if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 || !brokerDigestValid(proof.ProofDigest) { + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 || !foundry.DigestValid(proof.ProofDigest) { t.Fatal("first settlement lacked exact proof") } c.TaskUID = "fixture-task-two" c.PromptID = "fixture-prompt-two" c.OperationID = "inference-two" - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody(first.ID)) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody(first.ID)) if err != nil || status != 200 { t.Fatalf("continued response failed: %d", status) } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven || !proof.SettlementProven || proof.CreatePending || proof.State != "retired" { t.Fatal("retirement lacked proof") } - duplicate := brokerTestControl(t, server.URL, brokerRetirePath, c) + duplicate := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if duplicate.ProofDigest != proof.ProofDigest { t.Fatal("retirement proof changed on duplicate") } @@ -427,7 +430,7 @@ func TestBrokerLifecycleOwnershipContinuationAndRetirement(t *testing.T) { t.Fatal("ledger retained content or credentials") } b.mu.Lock() - retired := b.ledger.Sessions[brokerJSONDigest(c.Owner)].Retired + retired := b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Retired b.mu.Unlock() if !retired { t.Fatal("retirement was not durable") @@ -440,17 +443,17 @@ func TestBrokerNoInferenceCleanupRejectsDelayedPOST(t *testing.T) { _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.LeaseExpiresAt = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.RemoteSessionCreated { t.Fatal("empty prompt did not obtain never-created proof") } c.LeaseExpiresAt = time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano) c.OperationID = "delayed-inference" - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != 410 { t.Fatalf("delayed inference was not fenced: %d", status) } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven || proof.RemoteSessionCreated { t.Fatal("never-created retirement missing") } @@ -466,11 +469,11 @@ func TestBrokerRenewBeforeInference(t *testing.T) { _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.LeaseGeneration = 4 - proof := brokerTestControl(t, server.URL, brokerRenewPath, c) + proof := brokerTestControl(t, server.URL, brokerapi.RenewPath, c) if proof.LeaseGeneration != 4 || proof.State != "open" { t.Fatal("early renewal was not established") } - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != 200 { t.Fatalf("first inference after renewal failed: %d", status) } @@ -479,18 +482,18 @@ func TestBrokerRenewBeforeInference(t *testing.T) { c.OperationID = "renew-five" cc := c cc.InvocationSequence = 0 - cc.BodySHA256 = brokerSHA([]byte("{}")) - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, cc, []byte("{}")) + cc.BodySHA256 = foundry.Digest([]byte("{}")) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, cc, []byte("{}")) if err != nil || status != 200 { t.Fatalf("later renewal failed: %d", status) } c.LeaseGeneration = 4 c.LeaseExpiresAt = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) - proof = brokerTestControl(t, server.URL, brokerSettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.LeaseGeneration != 5 { t.Fatal("cleanup with old lease failed or reopened authority") } - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } func TestBrokerUnknownInferenceNeverClaimsCleanup(t *testing.T) { @@ -502,7 +505,7 @@ func TestBrokerUnknownInferenceNeverClaimsCleanup(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _, _ = brokerTestHTTP(ctx, server.URL, brokerResponsesPath, c, brokerTestBody("")) + _, _, _ = brokerTestHTTP(ctx, server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) }() select { case <-f.started: @@ -514,14 +517,14 @@ func TestBrokerUnknownInferenceNeverClaimsCleanup(t *testing.T) { brokerAwait(t, func() bool { b.mu.Lock() defer b.mu.Unlock() - p := b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()] + p := b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()] return p.Invocations[1].State == "uncertain" }) cc := c cc.InvocationSequence = 0 cc.OperationID = "unknown-settle" for range 3 { - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, cc, []byte("{}")) + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, cc, []byte("{}")) var proof brokerControlResponse if err != nil || status != 409 || json.Unmarshal(data, &proof) != nil || proof.AmbiguousInvocations != 1 || proof.SettlementProven || proof.ProofDigest != "" { t.Fatal("ambiguous inference produced a cleanup proof") @@ -535,7 +538,7 @@ func TestBrokerUnknownInferenceNeverClaimsCleanup(t *testing.T) { cc.LeaseGeneration = 0 cc.LeaseExpiresAt = "" cc.OperationID = "unknown-retire" - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, cc, []byte("{}")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, cc, []byte("{}")) if err != nil || status != 409 { t.Fatal("ambiguous inference was retired") } diff --git a/broker_byte_capacity_test.go b/internal/broker/byte_capacity_test.go similarity index 81% rename from broker_byte_capacity_test.go rename to internal/broker/byte_capacity_test.go index d8c707e..e789973 100644 --- a/broker_byte_capacity_test.go +++ b/internal/broker/byte_capacity_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -13,6 +13,9 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) // Retained, valid synthetic history places the small public requests below at @@ -26,12 +29,12 @@ func brokerCapacityFillHistory(t *testing.T, b *lifecycleBroker, c brokerContext err := b.commitCapacityLocked(ordinary, func(next *brokerLedger) error { owner := c.Owner owner.RuntimeSessionUID = "capacity-retained-history" - key := brokerJSONDigest(owner) + key := foundry.JSONDigest(owner) if next.Sessions[key] != nil { return errBrokerConflict } history := &brokerSession{Owner: owner, CreateState: "none", Retiring: true, Retired: true, - ProofDigest: brokerSHA([]byte("synthetic-retained-proof")), Prompts: map[string]*brokerPrompt{}, + ProofDigest: foundry.Digest([]byte("synthetic-retained-proof")), Prompts: map[string]*brokerPrompt{}, Responses: map[string]brokerResponseID{}, Operations: map[string]string{}} next.Sessions[key] = history data, err := json.Marshal(next) @@ -39,7 +42,7 @@ func brokerCapacityFillHistory(t *testing.T, b *lifecycleBroker, c brokerContext return err } size := len(data) - digest := brokerSHA([]byte("synthetic-retained-operation")) + digest := foundry.Digest([]byte("synthetic-retained-operation")) for i := 0; i < brokerOperationLimit; i++ { prefix := fmt.Sprintf("history-%05d-", i) id := prefix + strings.Repeat("<", 512-len(prefix)) @@ -82,16 +85,16 @@ func brokerCapacityFillHistory(t *testing.T, b *lifecycleBroker, c brokerContext } func TestBrokerByteReserveCoversEscapedAcceptanceAndCleanup(t *testing.T) { - cfg := brokerConfiguration{configDigest: brokerSHA([]byte("capacity-bounds"))} + cfg := brokerConfiguration{configDigest: foundry.Digest([]byte("capacity-bounds"))} c := brokerTestContext(cfg) - c.BodySHA256 = brokerSHA([]byte("synthetic-body")) + c.BodySHA256 = foundry.Digest([]byte("synthetic-body")) c.OperationID = strings.Repeat("&", 512) ledger := &brokerLedger{Version: 1, ConfigDigest: cfg.configDigest, Sessions: map[string]*brokerSession{}} session, err := brokerEnsureSession(ledger, c) if err != nil { t.Fatal("could not establish bound owner") } - if _, err := brokerRecordOperation(session, brokerResponsesPath, c); err != nil { + if _, err := brokerRecordOperation(session, brokerapi.ResponsesPath, c); err != nil { t.Fatal("could not establish bound operation") } prompt, err := brokerEnsurePrompt(session, c) @@ -109,26 +112,26 @@ func TestBrokerByteReserveCoversEscapedAcceptanceAndCleanup(t *testing.T) { if got := brokerLedgerReserveBytes(ledger); got != brokerOwnerReserveBytes+brokerPrincipalReserveBytes { t.Fatal("owner or first principal reserve is missing") } - ledger.PrincipalDigest = brokerSHA([]byte("bound-principal")) + ledger.PrincipalDigest = foundry.Digest([]byte("bound-principal")) principal, _ := json.Marshal(ledger) if len(principal)-len(before) > brokerPrincipalReserveBytes { t.Fatal("first principal exceeds its reserve") } session.RemoteID, session.CreateState = brokerIdentityRemoteSession, "deleted" session.Retiring, session.Retired = true, true - session.ProofDigest = brokerSHA([]byte("retirement-proof")) + session.ProofDigest = foundry.Digest([]byte("retirement-proof")) prompt.Closing, prompt.Settled = true, true - prompt.ProofDigest = brokerSHA([]byte("settlement-proof")) - invocation.ResponseID = strings.Repeat("<", maxProviderIdentifierBytes) + prompt.ProofDigest = foundry.Digest([]byte("settlement-proof")) + invocation.ResponseID = strings.Repeat("<", foundry.MaxIdentifierBytes) invocation.ResponseAlias, invocation.State = "fr_11111111-1111-4111-8111-111111111111", "settled" session.Responses[invocation.ResponseAlias] = brokerResponseID{RemoteID: invocation.ResponseID, PromptKey: c.promptKey()} - for _, path := range []string{brokerSettlePath, brokerRetirePath} { + for _, path := range []string{brokerapi.SettlePath, brokerapi.RetirePath} { control, body := brokerTestControlContext(path, c) control.OperationID = strings.Repeat("<", 512) - if path == brokerRetirePath { + if path == brokerapi.RetirePath { control.OperationID = strings.Repeat(">", 512) } - control.BodySHA256 = brokerSHA(body) + control.BodySHA256 = foundry.Digest(body) if _, err := brokerRecordOperation(session, path, control); err != nil { t.Fatal("maximum escaped cleanup operation was rejected") } @@ -163,30 +166,30 @@ func TestBrokerByteCapacityRejectsBeforeIOAndKeepsFailureClosed(t *testing.T) { oldInfo, _ := record.Stat() active, cancel := context.WithCancel(context.Background()) defer cancel() - b.active[brokerJSONDigest(c.Owner)] = brokerActive{prompt: c.promptKey(), cancel: cancel} + b.active[foundry.JSONDigest(c.Owner)] = brokerActive{prompt: c.promptKey(), cancel: cancel} // A write would fail here. Capacity rejection must happen first and must // leave this original active request and the durable writer healthy. if os.Rename(b.store.dir, b.store.dir+"-hidden") != nil { t.Fatal("could not hide the fixture directory") } defer func() { _ = os.Rename(b.store.dir+"-hidden", b.store.dir) }() - renewal, body := brokerTestControlContext(brokerRenewPath, c) + renewal, body := brokerTestControlContext(brokerapi.RenewPath, c) renewal.OperationID = strings.Repeat("<", 512) - renewal.BodySHA256 = brokerSHA(body) + renewal.BodySHA256 = foundry.Digest(body) renewal.LeaseGeneration++ - if err := b.control(brokerRenewPath, renewal); !errors.Is(err, errBrokerCapacity) { + if err := b.control(brokerapi.RenewPath, renewal); !errors.Is(err, errBrokerCapacity) { t.Fatalf("ordinary growth did not stop before I/O: %v", err) } err = b.commitLocked(func(next *brokerLedger) error { - session := next.Sessions[brokerJSONDigest(c.Owner)] + session := next.Sessions[foundry.JSONDigest(c.Owner)] for i := range 32 { id := fmt.Sprintf("extra-%02d-", i) + strings.Repeat("<", 500) - session.Operations[id] = brokerSHA([]byte("extra-operation")) + session.Operations[id] = foundry.Digest([]byte("extra-operation")) } return nil }) if !errors.Is(err, errBrokerCapacity) || b.storageError != nil || active.Err() != nil || - brokerSHA(before) != brokerJSONDigest(b.ledger) { + foundry.Digest(before) != foundry.JSONDigest(b.ledger) { t.Fatal("hard-cap refusal poisoned the writer, cancelled work, or mutated ownership") } if os.Rename(b.store.dir+"-hidden", b.store.dir) != nil { @@ -200,13 +203,13 @@ func TestBrokerByteCapacityRejectsBeforeIOAndKeepsFailureClosed(t *testing.T) { t.Fatal("could not inject a real storage failure") } err = b.commitLocked(func(next *brokerLedger) error { - next.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()].Closing = true + next.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()].Closing = true return nil }) if !errors.Is(err, errBrokerStorage) || !errors.Is(b.storageError, errBrokerStorage) || active.Err() != context.Canceled { t.Fatal("actual I/O failure did not remain fail-closed") } - if brokerSHA(before) != brokerJSONDigest(b.ledger) { + if foundry.Digest(before) != foundry.JSONDigest(b.ledger) { t.Fatal("failed I/O replaced original durable ownership") } } @@ -233,13 +236,13 @@ func brokerCapacityControlProof(t *testing.T, base, path string, c brokerContext } func TestBrokerByteCapacityConcurrentOwnersPreserveAcceptanceAndCleanup(t *testing.T) { - responseID := strings.Repeat("<", maxProviderIdentifierBytes) - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { - response := foundryResponse{ID: responseID, AgentSessionID: request.AgentSessionID, Status: "completed"} - for i := range defaultMaxBrokeredCalls { + responseID := strings.Repeat("<", foundry.MaxIdentifierBytes) + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { + response := foundry.Response{ID: responseID, AgentSessionID: request.AgentSessionID, Status: "completed"} + for i := range foundry.DefaultMaxBrokeredCalls { prefix := fmt.Sprintf("call-%03d-", i) - response.Output = append(response.Output, foundryOutputItem{ID: fmt.Sprintf("item-%03d", i), Type: "function_call", - Name: "hosted-probe-read", CallID: prefix + strings.Repeat("x", maxProviderIdentifierBytes-len(prefix)), Arguments: json.RawMessage(`"{}"`)}) + response.Output = append(response.Output, foundry.OutputItem{ID: fmt.Sprintf("item-%03d", i), Type: "function_call", + Name: "hosted-probe-read", CallID: prefix + strings.Repeat("x", foundry.MaxIdentifierBytes-len(prefix)), Arguments: json.RawMessage(`"{}"`)}) } data, err := json.Marshal(response) if err != nil { @@ -275,7 +278,7 @@ func TestBrokerByteCapacityConcurrentOwnersPreserveAcceptanceAndCleanup(t *testi b.mu.Lock() reserve := brokerLedgerReserveBytes(b.ledger) for _, c := range owners { - if b.ledger.Sessions[brokerJSONDigest(c.Owner)].CreateState != "intent" { + if b.ledger.Sessions[foundry.JSONDigest(c.Owner)].CreateState != "intent" { b.mu.Unlock() t.Fatal("original creation intent was not durable before submission") } @@ -299,7 +302,7 @@ func TestBrokerByteCapacityConcurrentOwnersPreserveAcceptanceAndCleanup(t *testi b.mu.Lock() valid, healthy := brokerLedgerValid(b.ledger, cfg.configDigest), b.storageError == nil for _, c := range owners { - session := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + session := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] invocation := session.Prompts[c.promptKey()].Invocations[c.InvocationSequence] link := session.Responses[invocation.ResponseAlias] valid = valid && session.CreateState == "known" && invocation.ResponseID == responseID && @@ -311,17 +314,17 @@ func TestBrokerByteCapacityConcurrentOwnersPreserveAcceptanceAndCleanup(t *testi } proofs := make([]string, len(owners)) for i, c := range owners { - settlement, _ := brokerTestControlContext(brokerSettlePath, c) + settlement, _ := brokerTestControlContext(brokerapi.SettlePath, c) settlement.OperationID = strings.Repeat("<", 512) for range 3 { - proof := brokerCapacityControlProof(t, server.URL, brokerSettlePath, settlement) + proof := brokerCapacityControlProof(t, server.URL, brokerapi.SettlePath, settlement) if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 { t.Fatal("exact settlement retries consumed another owner's cleanup space") } } - retirement, _ := brokerTestControlContext(brokerRetirePath, c) + retirement, _ := brokerTestControlContext(brokerapi.RetirePath, c) retirement.OperationID = strings.Repeat(">", 512) - proof := brokerCapacityControlProof(t, server.URL, brokerRetirePath, retirement) + proof := brokerCapacityControlProof(t, server.URL, brokerapi.RetirePath, retirement) if !proof.RetirementProven { t.Fatal("owner could not persist exact retirement proof") } @@ -332,13 +335,13 @@ func TestBrokerByteCapacityConcurrentOwnersPreserveAcceptanceAndCleanup(t *testi server.Close() reopened, restarted := startBrokerTest(t, cfg) for i, c := range owners { - retirement, _ := brokerTestControlContext(brokerRetirePath, c) + retirement, _ := brokerTestControlContext(brokerapi.RetirePath, c) retirement.OperationID = strings.Repeat(">", 512) - proof := brokerCapacityControlProof(t, restarted.URL, brokerRetirePath, retirement) + proof := brokerCapacityControlProof(t, restarted.URL, brokerapi.RetirePath, retirement) if !proof.RetirementProven || proof.ProofDigest != proofs[i] { t.Fatal("restart lost the original retirement receipt") } - status, _, err := brokerTestHTTP(context.Background(), restarted.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), restarted.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusGone { t.Fatal("restart admitted inference on retired ownership") } @@ -359,8 +362,8 @@ func TestBrokerByteCapacityLegacyLedgerStillRecovers(t *testing.T) { b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.LeaseExpiresAt = time.Now().Add(4 * time.Minute).UTC().Format(time.RFC3339Nano) - _ = brokerTestControl(t, server.URL, brokerRenewPath, c) - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RenewPath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) // An old valid ledger need not contain the newly required admission reserve. // Preserve it without erasure or newly fabricated ownership on reopen. brokerCapacityFillHistory(t, b, c, brokerMaxLedgerBytes-8192, false) @@ -371,24 +374,24 @@ func TestBrokerByteCapacityLegacyLedgerStillRecovers(t *testing.T) { if !bytes.Equal(before, brokerIdentityLedgerBytes(t, reopened)) { t.Fatal("legacy recovery rewrote existing ownership") } - status, _, err := brokerTestHTTP(context.Background(), restarted.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), restarted.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusGone { t.Fatal("legacy recovery replayed a closed prompt") } fresh := c fresh.Owner.RuntimeSessionUID = "new-owner-after-legacy-capacity" fresh.OperationID = "new-ordinary-operation" - status, _, err = brokerTestHTTP(context.Background(), restarted.URL, brokerResponsesPath, fresh, brokerTestBody("")) + status, _, err = brokerTestHTTP(context.Background(), restarted.URL, brokerapi.ResponsesPath, fresh, brokerTestBody("")) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("legacy byte-capacity state admitted unreserved ownership") } - retirement, _ := brokerTestControlContext(brokerRetirePath, c) - proof := brokerCapacityControlProof(t, restarted.URL, brokerRetirePath, retirement) + retirement, _ := brokerTestControlContext(brokerapi.RetirePath, c) + proof := brokerCapacityControlProof(t, restarted.URL, brokerapi.RetirePath, retirement) if !proof.RetirementProven { t.Fatal("existing legacy cleanup was rejected by the new reserve rule") } reopened.mu.Lock() - healthy := reopened.storageError == nil && reopened.ledger.Sessions[brokerJSONDigest(fresh.Owner)] == nil + healthy := reopened.storageError == nil && reopened.ledger.Sessions[foundry.JSONDigest(fresh.Owner)] == nil reopened.mu.Unlock() creates, inferences, stops, deletes := f.counts() if !healthy || creates+inferences+stops+deletes != 0 { diff --git a/broker_create_ack_test.go b/internal/broker/create_ack_test.go similarity index 86% rename from broker_create_ack_test.go rename to internal/broker/create_ack_test.go index 798227e..44200c7 100644 --- a/broker_create_ack_test.go +++ b/internal/broker/create_ack_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -6,6 +6,8 @@ import ( "net/http" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" ) func TestBrokerLostCreateAckCannotRetireFromLaterGET(t *testing.T) { @@ -29,8 +31,8 @@ func TestBrokerLostCreateAckCannotRetireFromLaterGET(t *testing.T) { defer b.mu.Unlock() return len(b.workers) == 0 }) - retire, body := brokerTestControlContext(brokerRetirePath, c) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, retire, body) + retire, body := brokerTestControlContext(brokerapi.RetirePath, c) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, retire, body) if err != nil || status != http.StatusOK && status != http.StatusConflict { t.Fatal("original retirement request failed unexpectedly") } @@ -39,8 +41,8 @@ func TestBrokerLostCreateAckCannotRetireFromLaterGET(t *testing.T) { defer b.mu.Unlock() return len(b.workers) == 0 }) - statusContext, body := brokerTestControlContext(brokerStatusPath, c) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerStatusPath, statusContext, body) + statusContext, body := brokerTestControlContext(brokerapi.StatusPath, c) + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.StatusPath, statusContext, body) var proof brokerControlResponse if err != nil || status != http.StatusOK || json.Unmarshal(data, &proof) != nil { t.Fatal("original owner status is unreadable") diff --git a/broker_create_lease_test.go b/internal/broker/create_lease_test.go similarity index 81% rename from broker_create_lease_test.go rename to internal/broker/create_lease_test.go index c129cdd..0433f8b 100644 --- a/broker_create_lease_test.go +++ b/internal/broker/create_lease_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -6,6 +6,10 @@ import ( "sync/atomic" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) func TestBrokerCreationRequiresCurrentLeaseBeforeIntent(t *testing.T) { @@ -20,9 +24,9 @@ func TestBrokerCreationRequiresCurrentLeaseBeforeIntent(t *testing.T) { c := brokerTestContext(cfg) c.LeaseExpiresAt = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) body := brokerTestBody("") - c.BodySHA256 = brokerSHA(body) - var request acpResponseRequest - if acpDecode(body, &request, true) != nil { + c.BodySHA256 = foundry.Digest(body) + var request foundry.ModelResponseRequest + if strictjson.Decode(body, &request, true) != nil { t.Fatal("invalid inference fixture") } store, ledger, err := openBrokerStore(cfg.stateDir, cfg.configDigest) @@ -42,7 +46,7 @@ func TestBrokerCreationRequiresCurrentLeaseBeforeIntent(t *testing.T) { if ensureErr != nil { return ensureErr } - if _, ensureErr = brokerRecordOperation(session, brokerResponsesPath, c); ensureErr != nil { + if _, ensureErr = brokerRecordOperation(session, brokerapi.ResponsesPath, c); ensureErr != nil { return ensureErr } prompt, ensureErr := brokerEnsurePrompt(session, c) @@ -62,7 +66,7 @@ func TestBrokerCreationRequiresCurrentLeaseBeforeIntent(t *testing.T) { if tokens.Add(1) == 3 && renewed { b.mu.Lock() err := b.commitLocked(func(next *brokerLedger) error { - prompt := next.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()] + prompt := next.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()] prompt.LeaseGeneration++ prompt.LeaseExpiresAt = time.Now().Add(10 * time.Second) return nil @@ -74,9 +78,9 @@ func TestBrokerCreationRequiresCurrentLeaseBeforeIntent(t *testing.T) { } return brokerTestToken(), nil }) - _, err = b.invoke(ctx, c, request.foundryResponseRequest) + _, err = b.invoke(ctx, c, request.ResponseRequest) creates, inferences, _, _ := f.counts() - owner := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] if renewed { if err != nil || creates != 1 || inferences != 1 || owner.Prompts[c.promptKey()].LeaseGeneration != 2 { t.Fatal("current renewed lease did not authorize the original creation") diff --git a/broker_evidence_test.go b/internal/broker/evidence_test.go similarity index 86% rename from broker_evidence_test.go rename to internal/broker/evidence_test.go index a317811..54df432 100644 --- a/broker_evidence_test.go +++ b/internal/broker/evidence_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -13,9 +13,12 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) -func newBrokerEvidenceFixture(t *testing.T, reply func(foundryResponseRequest) (string, []byte)) *brokerFixture { +func newBrokerEvidenceFixture(t *testing.T, reply func(foundry.ResponseRequest) (string, []byte)) *brokerFixture { t.Helper() f := newBrokerFixture(t, "success") f.server.Close() @@ -29,7 +32,7 @@ func newBrokerEvidenceFixture(t *testing.T, reply func(foundryResponseRequest) ( w.WriteHeader(http.StatusUnauthorized) return } - var request foundryResponseRequest + var request foundry.ResponseRequest if json.NewDecoder(r.Body).Decode(&request) != nil { t.Error("inference request was not decodable") w.WriteHeader(http.StatusBadRequest) @@ -61,7 +64,7 @@ func TestBrokerMalformedSSECannotAcknowledgeInference(t *testing.T) { "created-completed", "queued-in-progress", "completed-in-progress", "completed-error", "wrong-session", } { t.Run(name, func(t *testing.T) { - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { response := map[string]any{"id": "provider-evidence", "status": "in_progress", "agent_session_id": request.AgentSessionID, "output": []any{}} event := map[string]any{"type": "response.created", "response": response} switch name { @@ -94,15 +97,15 @@ func TestBrokerMalformedSSECannotAcknowledgeInference(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("malformed inference exposed a successful response") } if state := brokerInvocationState(b, c); state != "uncertain" { t.Fatalf("malformed SSE fabricated acknowledgement: state=%s", state) } - brokerPendingControl(t, server.URL, brokerSettlePath, c, false, 1) - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.SettlePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 1) creates, inferences, _, deletes := f.counts() if creates != 1 || inferences != 1 || deletes != 0 { t.Fatal("malformed SSE replayed work or authorized deletion") @@ -115,7 +118,7 @@ func TestBrokerFailureResponseRetainsAcknowledgement(t *testing.T) { for _, media := range []string{"application/json", "text/event-stream"} { for _, state := range []string{"failed", "incomplete"} { t.Run(media+"/"+state, func(t *testing.T) { - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { response := map[string]any{"id": "provider-failure", "status": state, "agent_session_id": request.AgentSessionID, "output": []any{}} if state == "failed" { response["error"] = map[string]string{"code": "server_error", "message": "fixture-error-do-not-persist"} @@ -132,18 +135,18 @@ func TestBrokerFailureResponseRetainsAcknowledgement(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("failed provider response was returned as success") } if actual := brokerInvocationState(b, c); actual != "accepted" && actual != "settled" { t.Fatalf("coherent failure lost its acknowledgement: state=%s", actual) } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 { t.Fatal("acknowledged failure could not prove stop settlement") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven { t.Fatal("acknowledged failure could not retire its owner") } @@ -161,7 +164,7 @@ func TestBrokerFailureResponseRetainsAcknowledgement(t *testing.T) { } func TestBrokerValidAcknowledgementSurvivesMalformedTail(t *testing.T) { - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { response := map[string]any{"id": "provider-accepted", "status": "in_progress", "agent_session_id": request.AgentSessionID, "output": []any{}} data, _ := json.Marshal(map[string]any{"type": "response.created", "response": response}) return "text/event-stream", []byte("data: " + string(data) + "\n\ndata: {\"type\":\"invalid-after-ack\"}\n\n") @@ -169,15 +172,15 @@ func TestBrokerValidAcknowledgementSurvivesMalformedTail(t *testing.T) { cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("malformed tail exposed a successful response") } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.AmbiguousInvocations != 0 { t.Fatal("later malformed data erased valid acknowledgement") } - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if creates != 1 || inferences != 1 || stops == 0 || deletes != 1 { t.Fatal("acknowledged malformed response lacked exact containment") @@ -220,22 +223,22 @@ func TestBrokerUnsentRequestsRetainNoAmbiguousIntent(t *testing.T) { server := httptest.NewServer(b) t.Cleanup(func() { b.close(); server.Close() }) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK || tokenCalls.Load() < failAt { t.Fatal("pre-send identity failure was not exercised") } b.mu.Lock() - owner := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] createState, remoteID := owner.CreateState, owner.RemoteID b.mu.Unlock() if phase == "create" && (createState != "none" || remoteID != "") { t.Fatalf("unsent creation retained ambiguous ownership: state=%s", createState) } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.CreatePending || proof.AmbiguousInvocations != 0 { t.Fatal("definitively unsent request did not settle") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven { t.Fatal("unsent request left an unretirable owner") } diff --git a/broker_folded_authority_test.go b/internal/broker/folded_authority_test.go similarity index 93% rename from broker_folded_authority_test.go rename to internal/broker/folded_authority_test.go index 668e17d..45f6725 100644 --- a/broker_folded_authority_test.go +++ b/internal/broker/folded_authority_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -12,6 +12,9 @@ import ( "sync/atomic" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerFoldedSessionEvidenceCannotSettleActiveOwner(t *testing.T) { @@ -37,7 +40,7 @@ func TestBrokerFoldedSessionEvidenceCannotSettleActiveOwner(t *testing.T) { if err != nil { return nil, err } - var session brokerRemoteSession + var session foundry.RemoteSession if json.Unmarshal(data, &session) != nil { t.Error("fixture session evidence unreadable") } @@ -49,10 +52,10 @@ func TestBrokerFoldedSessionEvidenceCannotSettleActiveOwner(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTestWithClient(t, cfg, client) c := brokerTestContext(cfg) - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) finished := make(chan struct{}) go func() { - _, _, _ = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + _, _, _ = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) close(finished) }() brokerAwait(t, func() bool { @@ -62,8 +65,8 @@ func TestBrokerFoldedSessionEvidenceCannotSettleActiveOwner(t *testing.T) { return session != nil && session.Prompts[c.promptKey()].Invocations[1].ResponseID != "" }) cleanupEvidence.Store(true) - control, body := brokerTestControlContext(brokerSettlePath, c) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, control, body) + control, body := brokerTestControlContext(brokerapi.SettlePath, c) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, control, body) if err != nil || (status != http.StatusOK && status != http.StatusConflict) { t.Fatalf("original settlement request failed: status=%d", status) } diff --git a/broker_guards_test.go b/internal/broker/guards_test.go similarity index 85% rename from broker_guards_test.go rename to internal/broker/guards_test.go index 95a40c6..a416c15 100644 --- a/broker_guards_test.go +++ b/internal/broker/guards_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -10,12 +10,15 @@ import ( "strings" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func brokerFunctionBody(previous, call string) []byte { - body, _ := json.Marshal(acpResponseRequest{Model: "fixture-model", foundryResponseRequest: foundryResponseRequest{ + body, _ := json.Marshal(foundry.ModelResponseRequest{Model: "fixture-model", ResponseRequest: foundry.ResponseRequest{ Stream: true, Store: true, PreviousResponseID: previous, - Input: []foundryFunctionOutput{{Type: "function_call_output", CallID: call, Output: "fixture-tool-result"}}, + Input: []foundry.FunctionOutput{{Type: "function_call_output", CallID: call, Output: "fixture-tool-result"}}, }}) return body } @@ -26,8 +29,8 @@ func TestBrokerOpaqueAliasesAndNoFunctionReplay(t *testing.T) { _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.InvocationSequence = 3 // Supervisor allocation order can contain gaps. - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) - var first foundryResponse + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) + var first foundry.Response if err != nil || status != 200 || json.Unmarshal(data, &first) != nil || len(first.Output) != 1 { t.Fatalf("function proposal failed: %d", status) } @@ -55,7 +58,7 @@ func TestBrokerOpaqueAliasesAndNoFunctionReplay(t *testing.T) { case "missing_output": body = brokerTestBody(first.ID) } - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, bad, body) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, bad, body) if err != nil || status != http.StatusConflict { t.Fatalf("invalid function ownership %s was accepted: %d", which, status) } @@ -63,8 +66,8 @@ func TestBrokerOpaqueAliasesAndNoFunctionReplay(t *testing.T) { next := c next.OperationID = "function-output" next.InvocationSequence = 8 - status, data, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, next, brokerFunctionBody(first.ID, call)) - var second foundryResponse + status, data, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, next, brokerFunctionBody(first.ID, call)) + var second foundry.Response if err != nil || status != 200 || json.Unmarshal(data, &second) != nil { t.Fatal("valid owned function output failed") } @@ -72,7 +75,7 @@ func TestBrokerOpaqueAliasesAndNoFunctionReplay(t *testing.T) { replay := next replay.InvocationSequence = 9 replay.OperationID = "replay-" + previous - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, replay, brokerFunctionBody(previous, call)) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, replay, brokerFunctionBody(previous, call)) if err != nil || status != http.StatusConflict { t.Fatal("consumed function output was replayed") } @@ -87,8 +90,8 @@ func TestBrokerOpaqueAliasesAndNoFunctionReplay(t *testing.T) { } } f.mu.Unlock() - _ = brokerTestControl(t, server.URL, brokerSettlePath, next) - _ = brokerTestControl(t, server.URL, brokerRetirePath, next) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, next) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, next) } func TestBrokerConcurrentDuplicateAdmitsOnce(t *testing.T) { @@ -117,8 +120,8 @@ func TestBrokerConcurrentDuplicateAdmitsOnce(t *testing.T) { if creates != 1 || inferences != 1 { t.Fatal("duplicate caused a second provider operation") } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } func TestBrokerRejectsUntrustedRoutesContextsAndInput(t *testing.T) { @@ -131,7 +134,7 @@ func TestBrokerRejectsUntrustedRoutesContextsAndInput(t *testing.T) { t.Run(name, func(t *testing.T) { c := brokerTestContext(cfg) body := brokerTestBody("") - path := brokerResponsesPath + path := brokerapi.ResponsesPath switch name { case "query": path += "?unexpected=1" @@ -162,7 +165,7 @@ func TestBrokerRejectsUntrustedRoutesContextsAndInput(t *testing.T) { case "nonstore": body = bytes.Replace(body, []byte(`"store":true`), []byte(`"store":false`), 1) } - c.BodySHA256 = brokerSHA(body) + c.BodySHA256 = foundry.Digest(body) if name == "wrong_digest" { c.BodySHA256 = "sha256:" + strings.Repeat("3", 64) } @@ -199,20 +202,20 @@ func TestBrokerControlProofBindsExactHeaderBytes(t *testing.T) { f := newBrokerFixture(t, "success") cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) - c, body := brokerTestControlContext(brokerSettlePath, brokerTestContext(cfg)) - c.BodySHA256 = brokerSHA(body) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, c, body) + c, body := brokerTestControlContext(brokerapi.SettlePath, brokerTestContext(cfg)) + c.BodySHA256 = foundry.Digest(body) + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, c, body) var proof brokerControlResponse if err != nil || (status != 200 && status != 409) || json.Unmarshal(data, &proof) != nil { t.Fatal("control reply unavailable") } - if proof.Protocol != brokerProtocol || proof.OwnerDigest != brokerJSONDigest(c.Owner) || - proof.OperationID != c.OperationID || proof.ContextSHA256 != brokerJSONDigest(c) { + if proof.Protocol != brokerProtocol || proof.OwnerDigest != foundry.JSONDigest(c.Owner) || + proof.OperationID != c.OperationID || proof.ContextSHA256 != foundry.JSONDigest(c) { t.Fatal("proof did not bind the exact trusted owner and context") } // An operation ID cannot be reassigned to a different owner-context body. c.LeaseGeneration++ - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, c, body) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, c, body) if err != nil || status != http.StatusConflict { t.Fatal("control idempotency key accepted another context") } diff --git a/internal/broker/json_fields_test.go b/internal/broker/json_fields_test.go new file mode 100644 index 0000000..e5d405b --- /dev/null +++ b/internal/broker/json_fields_test.go @@ -0,0 +1,28 @@ +package broker + +import ( + "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +func TestBrokerResponseEvidenceKeepsUnusableOutputOwnership(t *testing.T) { + const data = `{"id":"response-1","status":"completed","agent_session_id":"owned","output":[{"type":"web_search_call","Type":"function_call"}]}` + response, err := brokerDecodeResponseEvidence([]byte(data)) + if err != nil || response.ID != "response-1" || response.AgentSessionID != "owned" { + t.Fatal("coherent response ownership was discarded because output is unusable") + } + if _, err := foundry.DecodeResponse([]byte(data)); err == nil { + t.Fatal("ownership evidence admitted unusable output") + } + for _, data := range []string{ + `{"id":"wrong","ID":"response-1","status":"completed"}`, + `{"id":"response-1","status":"active","Status":"completed"}`, + `{"id":"response-1","status":"completed","agent_session_id":"wrong","Agent_Session_ID":"owned"}`, + `{"id":"response-1","status":"completed","error":{"code":"failure"},"Error":null}`, + } { + if _, err := brokerDecodeResponseEvidence([]byte(data)); err == nil { + t.Error("contradictory fields fabricated response ownership") + } + } +} diff --git a/broker_lease_admission_test.go b/internal/broker/lease_admission_test.go similarity index 87% rename from broker_lease_admission_test.go rename to internal/broker/lease_admission_test.go index 559e7d7..1480e4a 100644 --- a/broker_lease_admission_test.go +++ b/internal/broker/lease_admission_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -8,6 +8,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" ) func TestBrokerNewInferenceRequiresCurrentLease(t *testing.T) { @@ -17,9 +19,9 @@ func TestBrokerNewInferenceRequiresCurrentLease(t *testing.T) { cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - first, body := brokerTestControlContext(brokerRenewPath, c) + first, body := brokerTestControlContext(brokerapi.RenewPath, c) first.OperationID = "original-lease" - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, first, body) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, first, body) if err != nil || status != http.StatusOK { t.Fatal("could not establish original lease") } @@ -27,7 +29,7 @@ func TestBrokerNewInferenceRequiresCurrentLease(t *testing.T) { renewed.OperationID = "renewed-lease" renewed.LeaseGeneration++ renewed.LeaseExpiresAt = time.Now().Add(20 * time.Second).UTC().Format(time.RFC3339Nano) - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, renewed, body) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, renewed, body) if err != nil || status != http.StatusOK { t.Fatal("exact renewal did not acknowledge") } @@ -42,7 +44,7 @@ func TestBrokerNewInferenceRequiresCurrentLease(t *testing.T) { if err != nil { t.Fatal("could not read original ownership") } - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) creates, inferences, _, _ := f.counts() if err != nil { t.Fatal("broker response unreadable") diff --git a/broker_legacy_recovery_test.go b/internal/broker/legacy_recovery_test.go similarity index 89% rename from broker_legacy_recovery_test.go rename to internal/broker/legacy_recovery_test.go index e224be8..bf591b1 100644 --- a/broker_legacy_recovery_test.go +++ b/internal/broker/legacy_recovery_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -9,6 +9,9 @@ import ( "path/filepath" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerLegacyIntentAtByteCapRemainsContainable(t *testing.T) { @@ -43,14 +46,14 @@ func TestBrokerLegacyIntentAtByteCapRemainsContainable(t *testing.T) { t.Fatal("unpersistable uncertainty overwrote the legacy owner") } recovered, restarted := startBrokerTest(t, cfg) - proof := brokerTestControl(t, restarted.URL, brokerStatusPath, c) + proof := brokerTestControl(t, restarted.URL, brokerapi.StatusPath, c) if proof.State != "blocked" || proof.AmbiguousInvocations != 1 || proof.ActiveInvocations != 0 || proof.CreatePending || !proof.RemoteSessionCreated || proof.SettlementProven || proof.RetirementProven || proof.ProofDigest != "" { t.Fatal("abandoned intent was reported as active or proven cleanup") } brokerAwait(t, func() bool { _, _, stops, _ := f.counts(); return stops > 0 }) recovered.mu.Lock() - session := recovered.ledger.Sessions[brokerJSONDigest(c.Owner)] + session := recovered.ledger.Sessions[foundry.JSONDigest(c.Owner)] prompt := session.Prompts[c.promptKey()] valid := brokerLedgerValid(recovered.ledger, cfg.configDigest) && prompt.Closing && !prompt.Settled && !session.Retired && prompt.Invocations[c.InvocationSequence].State == "intent" diff --git a/broker_preflight_recovery_test.go b/internal/broker/preflight_recovery_test.go similarity index 88% rename from broker_preflight_recovery_test.go rename to internal/broker/preflight_recovery_test.go index bf0ddf1..67c542c 100644 --- a/broker_preflight_recovery_test.go +++ b/internal/broker/preflight_recovery_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -12,6 +12,9 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerPreparedRequestsKeepDurableIntentBeforeTransport(t *testing.T) { @@ -28,7 +31,7 @@ func TestBrokerPreparedRequestsKeepDurableIntentBeforeTransport(t *testing.T) { t.Error("outbound submission has no readable durable ownership") return nil, errBrokerStorage } - owner := ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := ledger.Sessions[foundry.JSONDigest(c.Owner)] if owner == nil || owner.RemoteID == "" { t.Error("outbound submission lost its exact owner") return nil, errBrokerStorage @@ -48,12 +51,12 @@ func TestBrokerPreparedRequestsKeepDurableIntentBeforeTransport(t *testing.T) { return http.DefaultTransport.RoundTrip(request) })} _, server := startBrokerTestWithClient(t, cfg, client) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusOK || posts.Load() != 2 { t.Fatal("prepared request did not complete exactly one creation and inference") } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } func TestBrokerPreflightCrashDoesNotStrandUnsentOwnership(t *testing.T) { @@ -92,7 +95,7 @@ func TestBrokerPreflightCrashDoesNotStrandUnsentOwnership(t *testing.T) { return "fixture." + base64.RawURLEncoding.EncodeToString(claims) + ".fixture", nil default: b.mu.Lock() - cancel := b.active[brokerJSONDigest(c.Owner)].cancel + cancel := b.active[foundry.JSONDigest(c.Owner)].cancel b.mu.Unlock() cancel() if failure == "cancelled-token" { @@ -108,7 +111,7 @@ func TestBrokerPreflightCrashDoesNotStrandUnsentOwnership(t *testing.T) { } server := httptest.NewServer(b) t.Cleanup(func() { b.close(); server.Close() }) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("definitely-unsent preflight unexpectedly succeeded") } @@ -124,7 +127,7 @@ func TestBrokerPreflightCrashDoesNotStrandUnsentOwnership(t *testing.T) { if json.Unmarshal(raw, &ledger) != nil || !brokerLedgerValid(&ledger, cfg.configDigest) { t.Fatal("preflight snapshot is not valid durable ownership") } - owner := ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := ledger.Sessions[foundry.JSONDigest(c.Owner)] if owner == nil || owner.Prompts[c.promptKey()] == nil { t.Fatal("preflight snapshot lost reserved ownership") } @@ -139,11 +142,11 @@ func TestBrokerPreflightCrashDoesNotStrandUnsentOwnership(t *testing.T) { t.Fatal("could not restore crash-boundary fixture") } _, restarted := startBrokerTest(t, cfg) - proof := brokerTestControl(t, restarted.URL, brokerSettlePath, c) + proof := brokerTestControl(t, restarted.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.CreatePending || proof.AmbiguousInvocations != 0 { t.Fatal("restart stranded definitely-unsent ownership") } - proof = brokerTestControl(t, restarted.URL, brokerRetirePath, c) + proof = brokerTestControl(t, restarted.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || inferences != 0 || stops != 0 || (phase == "create" && (creates != 0 || deletes != 0)) || diff --git a/broker_protocol.go b/internal/broker/protocol.go similarity index 70% rename from broker_protocol.go rename to internal/broker/protocol.go index abe045a..692fcab 100644 --- a/broker_protocol.go +++ b/internal/broker/protocol.go @@ -1,25 +1,20 @@ -package main +package broker import ( - "crypto/sha256" "encoding/base64" - "encoding/hex" - "encoding/json" "errors" "net/http" - "strings" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) const ( brokerProtocol = "orka.foundry.broker.v1" brokerContextHeader = "X-Orka-Foundry-Context" brokerMaxContext = 16 << 10 - brokerResponsesPath = "/v1/responses" - brokerRenewPath = "/internal/v1/renew" - brokerSettlePath = "/internal/v1/settle" - brokerRetirePath = "/internal/v1/retire" - brokerStatusPath = "/internal/v1/status" ) var ( @@ -78,37 +73,15 @@ type brokerControlResponse struct { ProofDigest string `json:"proofDigest"` } -func brokerSHA(data []byte) string { - digest := sha256.Sum256(data) - return "sha256:" + hex.EncodeToString(digest[:]) -} - -func brokerJSONDigest(value any) string { - data, _ := json.Marshal(value) // Only concrete, JSON-safe broker structs are used. - return brokerSHA(data) -} - -func brokerDigestValid(value string) bool { - if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { - return false - } - for _, char := range value[len("sha256:"):] { - if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') { - return false - } - } - return true -} - func (o brokerOwner) valid() bool { - return acpSafeString(o.RuntimeInstanceID, 512) && acpSafeString(o.SupervisorBootID, 512) && - o.ControllerEpoch > 0 && acpSafeString(o.RuntimePoolUID, 512) && o.RuntimePoolGeneration > 0 && - acpSafeString(o.RuntimeSessionUID, 512) && o.RuntimeSessionGeneration > 0 && - brokerDigestValid(o.RuntimeProfileDigest) && o.ProfileDigestSchemaVersion == 1 + return foundry.SafeString(o.RuntimeInstanceID, 512) && foundry.SafeString(o.SupervisorBootID, 512) && + o.ControllerEpoch > 0 && foundry.SafeString(o.RuntimePoolUID, 512) && o.RuntimePoolGeneration > 0 && + foundry.SafeString(o.RuntimeSessionUID, 512) && o.RuntimeSessionGeneration > 0 && + foundry.DigestValid(o.RuntimeProfileDigest) && o.ProfileDigestSchemaVersion == 1 } func (c brokerContext) promptKey() string { - return brokerJSONDigest(struct { + return foundry.JSONDigest(struct { TaskUID string `json:"taskUID"` TaskAttempt uint32 `json:"taskAttempt"` PromptID string `json:"promptID"` @@ -126,19 +99,19 @@ func brokerParseContext(r *http.Request, body []byte, configDigest string, now t return brokerContext{}, "", errBrokerInvalid } var c brokerContext - if acpDecode(raw, &c, true) != nil || c.Protocol != brokerProtocol || !c.Owner.valid() || - c.AgentConfigurationDigest != configDigest || !brokerDigestValid(configDigest) || - !acpSafeString(c.OperationID, 512) || c.BodySHA256 != brokerSHA(body) { + if strictjson.Decode(raw, &c, true) != nil || c.Protocol != brokerProtocol || !c.Owner.valid() || + c.AgentConfigurationDigest != configDigest || !foundry.DigestValid(configDigest) || + !foundry.SafeString(c.OperationID, 512) || c.BodySHA256 != foundry.Digest(body) { return brokerContext{}, "", errBrokerInvalid } - needsPrompt := r.URL.Path != brokerRetirePath && r.URL.Path != brokerStatusPath + needsPrompt := r.URL.Path != brokerapi.RetirePath && r.URL.Path != brokerapi.StatusPath if needsPrompt { expiry, err := time.Parse(time.RFC3339Nano, c.LeaseExpiresAt) - if !acpSafeString(c.TaskUID, 512) || c.TaskAttempt == 0 || !acpSafeString(c.PromptID, 512) || - !brokerDigestValid(c.PromptRequestDigest) || c.LeaseGeneration == 0 || err != nil { + if !foundry.SafeString(c.TaskUID, 512) || c.TaskAttempt == 0 || !foundry.SafeString(c.PromptID, 512) || + !foundry.DigestValid(c.PromptRequestDigest) || c.LeaseGeneration == 0 || err != nil { return brokerContext{}, "", errBrokerInvalid } - if (r.URL.Path == brokerResponsesPath || r.URL.Path == brokerRenewPath) && + if (r.URL.Path == brokerapi.ResponsesPath || r.URL.Path == brokerapi.RenewPath) && (!expiry.After(now) || expiry.After(now.Add(5*time.Minute))) { return brokerContext{}, "", errBrokerClosed } @@ -146,14 +119,14 @@ func brokerParseContext(r *http.Request, body []byte, configDigest string, now t c.LeaseGeneration != 0 || c.LeaseExpiresAt != "" { return brokerContext{}, "", errBrokerInvalid } - if (r.URL.Path == brokerResponsesPath) != (c.InvocationSequence > 0) { + if (r.URL.Path == brokerapi.ResponsesPath) != (c.InvocationSequence > 0) { return brokerContext{}, "", errBrokerInvalid } - return c, brokerSHA(raw), nil + return c, foundry.Digest(raw), nil } func brokerOperationDigest(path string, c brokerContext) string { - return brokerJSONDigest(struct { + return foundry.JSONDigest(struct { Path string `json:"path"` Context brokerContext `json:"context"` }{path, c}) diff --git a/broker_recovery_test.go b/internal/broker/recovery_test.go similarity index 85% rename from broker_recovery_test.go rename to internal/broker/recovery_test.go index bfe4341..f329e0b 100644 --- a/broker_recovery_test.go +++ b/internal/broker/recovery_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -8,6 +8,9 @@ import ( "path/filepath" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) type brokerHTTPResult struct { @@ -19,7 +22,7 @@ type brokerHTTPResult struct { func brokerAsyncInference(ctx context.Context, base string, c brokerContext, body []byte) <-chan brokerHTTPResult { done := make(chan brokerHTTPResult, 1) go func() { - status, data, err := brokerTestHTTP(ctx, base, brokerResponsesPath, c, body) + status, data, err := brokerTestHTTP(ctx, base, brokerapi.ResponsesPath, c, body) done <- brokerHTTPResult{status, data, err} }() return done @@ -39,7 +42,7 @@ func brokerWaitInference(t *testing.T, done <-chan brokerHTTPResult) brokerHTTPR func brokerInvocationState(b *lifecycleBroker, c brokerContext) string { b.mu.Lock() defer b.mu.Unlock() - if session := b.ledger.Sessions[brokerJSONDigest(c.Owner)]; session != nil { + if session := b.ledger.Sessions[foundry.JSONDigest(c.Owner)]; session != nil { if prompt := session.Prompts[c.promptKey()]; prompt != nil { if invocation := prompt.Invocations[c.InvocationSequence]; invocation != nil { return invocation.State @@ -89,7 +92,7 @@ func TestBrokerAcknowledgedDisconnectExpiryAndTruncation(t *testing.T) { if result.err == nil && result.status == http.StatusOK { t.Fatal("interrupted inference exposed a terminal result") } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 { t.Fatal("acknowledged interrupted response did not settle") } @@ -97,7 +100,7 @@ func TestBrokerAcknowledgedDisconnectExpiryAndTruncation(t *testing.T) { if creates != 1 || inferences != 1 || stops < 1 || deletes != 0 { t.Fatal("disconnect/expiry cleanup skipped stop or replayed inference") } - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) }) } } @@ -119,7 +122,7 @@ func TestBrokerDelayedCreateRetainsOwnershipThrough404(t *testing.T) { _ = brokerWaitInference(t, done) brokerAwait(t, func() bool { return brokerInvocationState(b, c) == "rejected" }) for range 3 { - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) } creates, inferences, _, deletes := f.counts() if creates != 1 || inferences != 0 || deletes != 0 { @@ -129,7 +132,7 @@ func TestBrokerDelayedCreateRetainsOwnershipThrough404(t *testing.T) { b.close() server.Close() _, server = startBrokerTest(t, cfg) - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) f.unblock() brokerAwait(t, func() bool { f.mu.Lock() @@ -138,7 +141,7 @@ func TestBrokerDelayedCreateRetainsOwnershipThrough404(t *testing.T) { }) // The object appearing after a lost acknowledgement does not establish // completion of the original CREATE, including after a broker restart. - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) creates, inferences, _, deletes = f.counts() if creates != 1 || inferences != 0 || deletes != 0 { t.Fatal("same-intent recovery replayed work or deleted an unacknowledged owner") @@ -178,7 +181,7 @@ func TestBrokerCancellationWaitsForCreateAcknowledgement(t *testing.T) { } // This public close request synchronously cancels the prompt context while // the fixture still holds the original creation acknowledgement. - brokerPendingControl(t, server.URL, brokerSettlePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.SettlePath, c, true, 0) if createCtx.Err() != nil { t.Fatal("prompt cancellation aborted durable session creation") } @@ -186,7 +189,7 @@ func TestBrokerCancellationWaitsForCreateAcknowledgement(t *testing.T) { t.Fatal("session creation lacks the broker's operation bound") } f.unblock() - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || !proof.RemoteSessionCreated || proof.CreatePending || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 { t.Fatal("acknowledged creation did not settle the cancelled prompt") @@ -196,12 +199,12 @@ func TestBrokerCancellationWaitsForCreateAcknowledgement(t *testing.T) { if err != nil || json.Unmarshal(data, &ledger) != nil || !brokerLedgerValid(&ledger, cfg.configDigest) { t.Fatal("settlement did not preserve a valid durable ledger") } - owned := ledger.Sessions[brokerJSONDigest(c.Owner)] + owned := ledger.Sessions[foundry.JSONDigest(c.Owner)] if owned == nil || owned.CreateState != "known" || owned.RemoteID == "" || !owned.Prompts[c.promptKey()].Settled { t.Fatal("positive creation acknowledgement was not durably retained") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) - if !proof.RetirementProven || !brokerDigestValid(proof.ProofDigest) { + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) + if !proof.RetirementProven || !foundry.DigestValid(proof.ProofDigest) { t.Fatal("cancelled prompt's acknowledged session did not retire") } creates, inferences, stops, deletes := f.counts() @@ -217,21 +220,21 @@ func TestBrokerCreationAdmissionRejectionVersusUnknownFailure(t *testing.T) { cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("failed creation exposed an inference result") } if mode == "create-rejected" { - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.RemoteSessionCreated || proof.CreatePending { t.Fatal("complete creation rejection did not prove no remote session") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven || proof.RemoteSessionCreated { t.Fatal("rejected creation could not retire without a remote session") } } else { - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) } creates, inferences, stops, deletes := f.counts() if creates != 1 || inferences != 0 || stops != 0 || deletes != 0 { @@ -267,15 +270,15 @@ func TestBrokerRestartSettlesOnlyAcknowledgedInference(t *testing.T) { server.Close() _, server = startBrokerTest(t, cfg) if acknowledged { - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven { t.Fatal("original acknowledged owner did not settle after restart") } - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } else { for range 3 { - brokerPendingControl(t, server.URL, brokerSettlePath, c, false, 1) - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.SettlePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 1) } } creates, inferences, _, deletes := f.counts() @@ -298,7 +301,7 @@ func TestBrokerRenewalExtendsActiveRequestAndOldLeaseCanClose(t *testing.T) { renewal := c renewal.LeaseGeneration = 2 renewal.LeaseExpiresAt = time.Now().Add(3 * time.Second).UTC().Format(time.RFC3339Nano) - proof := brokerTestControl(t, server.URL, brokerRenewPath, renewal) + proof := brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal) if proof.LeaseGeneration != 2 || proof.State != "open" { t.Fatal("renewal did not bind the active request") } @@ -307,7 +310,7 @@ func TestBrokerRenewalExtendsActiveRequestAndOldLeaseCanClose(t *testing.T) { if inferences != 1 || stops != 0 || brokerInvocationState(b, c) != "accepted" { t.Fatal("old expiry canceled a renewed request") } - proof = brokerTestControl(t, server.URL, brokerSettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.LeaseGeneration != 2 { t.Fatal("old lease could not close exact prompt authority") } @@ -315,7 +318,7 @@ func TestBrokerRenewalExtendsActiveRequestAndOldLeaseCanClose(t *testing.T) { if result.status == http.StatusOK { t.Fatal("settled cancellation exposed output") } - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } func TestBrokerKnownAbsentSessionStillRequiresDeleteAcknowledgement(t *testing.T) { @@ -323,7 +326,7 @@ func TestBrokerKnownAbsentSessionStillRequiresDeleteAcknowledgement(t *testing.T cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusOK { t.Fatal("initial request failed") } @@ -333,7 +336,7 @@ func TestBrokerKnownAbsentSessionStillRequiresDeleteAcknowledgement(t *testing.T delete(f.sessions, id) } f.mu.Unlock() - proof := brokerTestControl(t, server.URL, brokerRetirePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) _, _, _, deletes := f.counts() if !proof.RetirementProven || deletes != 1 { t.Fatal("known missing target did not obtain DELETE204 + GET404 proof") @@ -347,15 +350,15 @@ func TestBrokerCompleteAdmissionRejectionVersusUnknownFailure(t *testing.T) { cfg := brokerTestConfig(t, f) _, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("provider rejection exposed a result") } if mode == "rejected" { - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } else { - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 1) } creates, inferences, _, deletes := f.counts() if creates != 1 || inferences != 1 || (mode != "rejected" && deletes != 0) { diff --git a/broker_remote.go b/internal/broker/remote.go similarity index 85% rename from broker_remote.go rename to internal/broker/remote.go index 7ccd19b..bcc1b3c 100644 --- a/broker_remote.go +++ b/internal/broker/remote.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -13,20 +13,13 @@ import ( "strings" "time" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" "golang.org/x/net/http/httpguts" ) var errBrokerRequestUnsent = errors.New("Foundry broker request was not sent") -type brokerRemoteSession struct { - ID string `json:"agent_session_id"` - Version struct { - Type string `json:"type"` - Version string `json:"agent_version"` - } `json:"version_indicator"` - Status string `json:"status"` -} - // Complete fallible authentication and request preparation before a caller // records possible submission. Only sendRemoteRequest crosses that boundary. func (b *lifecycleBroker) prepareRemoteRequest(ctx context.Context, method, suffix string, body []byte) (*http.Request, error) { @@ -98,11 +91,11 @@ func (b *lifecycleBroker) pinPrincipal(token string) error { App string `json:"appid"` AZP string `json:"azp"` } - if err != nil || acpDecodeStruct(raw, &claims, false) != nil || strings.TrimRight(claims.Audience, "/") != "https://ai.azure.com" || - !acpSafeString(claims.Tenant, 512) || !acpSafeString(claims.Object, 512) { + if err != nil || strictjson.DecodeStruct(raw, &claims, false) != nil || strings.TrimRight(claims.Audience, "/") != "https://ai.azure.com" || + !foundry.SafeString(claims.Tenant, 512) || !foundry.SafeString(claims.Object, 512) { return errBrokerRemote } - digest := brokerJSONDigest(claims) + digest := foundry.JSONDigest(claims) b.mu.Lock() defer b.mu.Unlock() if b.ledger.PrincipalDigest == digest { @@ -128,12 +121,12 @@ func (b *lifecycleBroker) remoteJSONRequest(request *http.Request, target any) ( return 0, err } defer response.Body.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(response.Body, acpMaxConfigBytes+1)) - if err != nil || len(data) > acpMaxConfigBytes { + data, err := io.ReadAll(io.LimitReader(response.Body, foundry.MaxAgentConfigBytes+1)) + if err != nil || len(data) > foundry.MaxAgentConfigBytes { return response.StatusCode, errBrokerRemote } if target != nil && response.StatusCode >= 200 && response.StatusCode < 300 { - if acpDecodeStruct(data, target, false) != nil { + if strictjson.DecodeStruct(data, target, false) != nil { return response.StatusCode, errBrokerRemote } } @@ -157,7 +150,7 @@ func (b *lifecycleBroker) validateRemoteTarget(ctx context.Context) error { var value struct { Type string `json:"type"` } - if acpDecodeStruct(scheme, &value, true) != nil || !strings.EqualFold(value.Type, "entra") { + if strictjson.DecodeStruct(scheme, &value, true) != nil || !strings.EqualFold(value.Type, "entra") { return errBrokerRemote } } @@ -177,15 +170,13 @@ func (b *lifecycleBroker) validateRemoteTarget(ctx context.Context) error { return nil } -func (b *lifecycleBroker) remoteSessionMatches(value brokerRemoteSession, id string) bool { +func (b *lifecycleBroker) remoteSessionMatches(value foundry.RemoteSession, id string) bool { return value.ID == id && value.Version.Type == "version_ref" && value.Version.Version == b.cfg.agent.HostedTarget.AgentVersion } -func brokerSessionSuffix(id string) string { return "/endpoint/sessions/" + url.PathEscape(id) } - -func (b *lifecycleBroker) remoteSessionGet(ctx context.Context, id string) (brokerRemoteSession, int, error) { - var session brokerRemoteSession - status, err := b.remoteJSON(ctx, http.MethodGet, brokerSessionSuffix(id), nil, &session) +func (b *lifecycleBroker) remoteSessionGet(ctx context.Context, id string) (foundry.RemoteSession, int, error) { + var session foundry.RemoteSession + status, err := b.remoteJSON(ctx, http.MethodGet, foundry.SessionSuffix(id), nil, &session) if err == nil && status == http.StatusOK && !b.remoteSessionMatches(session, id) { err = errBrokerConflict } @@ -202,12 +193,12 @@ func (b *lifecycleBroker) prepareRemoteSessionCreate(ctx context.Context, id str // unacknowledged outcome retains the durable creation intent; an absent-session // observation cannot clear it. Authentication was completed before the intent. func (b *lifecycleBroker) remoteSessionCreate(request *http.Request, id string) (bool, error) { - var session brokerRemoteSession + var session foundry.RemoteSession status, err := b.remoteJSONRequest(request, &session) if errors.Is(err, errBrokerRequestUnsent) { return false, err } - if err == nil && brokerDefiniteRejection(status) { + if err == nil && foundry.DefiniteRejection(status) { return false, nil } if err != nil || status != http.StatusCreated || !b.remoteSessionMatches(session, id) { @@ -221,7 +212,7 @@ func (b *lifecycleBroker) remoteSessionStop(ctx context.Context, id string) erro if err != nil || status != http.StatusOK { return errBrokerPending } - status, err = b.remoteJSON(ctx, http.MethodPost, brokerSessionSuffix(id)+":stop", nil, nil) + status, err = b.remoteJSON(ctx, http.MethodPost, foundry.SessionSuffix(id)+":stop", nil, nil) if err != nil || (status != http.StatusNoContent && status != http.StatusConflict) { return errBrokerPending } @@ -251,7 +242,7 @@ func (b *lifecycleBroker) remoteSessionDelete(ctx context.Context, id string) er if err != nil || (status != http.StatusOK && status != http.StatusNotFound) { return errBrokerPending } - status, err = b.remoteJSON(ctx, http.MethodDelete, brokerSessionSuffix(id), nil, nil) + status, err = b.remoteJSON(ctx, http.MethodDelete, foundry.SessionSuffix(id), nil, nil) if err != nil || status != http.StatusNoContent { return errBrokerPending } diff --git a/remote_header_preflight_test.go b/internal/broker/remote_header_preflight_test.go similarity index 61% rename from remote_header_preflight_test.go rename to internal/broker/remote_header_preflight_test.go index 62f35e2..b9af640 100644 --- a/remote_header_preflight_test.go +++ b/internal/broker/remote_header_preflight_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -9,6 +9,9 @@ import ( "path/filepath" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func invalidRemoteHeaderTokens() map[string]func(string) string { @@ -53,7 +56,7 @@ func TestBrokerInvalidRemoteHeaderDoesNotReserveSubmission(t *testing.T) { } server := httptest.NewServer(b) t.Cleanup(func() { b.close(); server.Close() }) - status, _, err := brokerTestHTTP(t.Context(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(t.Context(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK || tokens.Load() < failAt { t.Fatal("fixture did not reach the invalid header boundary") } @@ -67,7 +70,7 @@ func TestBrokerInvalidRemoteHeaderDoesNotReserveSubmission(t *testing.T) { if err != nil || json.Unmarshal(raw, &ledger) != nil || !brokerLedgerValid(&ledger, cfg.configDigest) { t.Fatal("could not inspect durable header-failure ownership") } - owner := ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := ledger.Sessions[foundry.JSONDigest(c.Owner)] if owner == nil || owner.CreateState == "intent" || (phase == "create" && (owner.CreateState != "none" || owner.RemoteID != "")) { t.Fatal("invalid authorization stranded an unsent creation") @@ -77,11 +80,11 @@ func TestBrokerInvalidRemoteHeaderDoesNotReserveSubmission(t *testing.T) { t.Fatal("invalid authorization stranded an unsent inference") } _, restarted := startBrokerTest(t, cfg) - proof := brokerTestControl(t, restarted.URL, brokerSettlePath, c) + proof := brokerTestControl(t, restarted.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.CreatePending || proof.AmbiguousInvocations != 0 { t.Fatal("restart could not settle definitely-unsent authorization failure") } - proof = brokerTestControl(t, restarted.URL, brokerRetirePath, c) + proof = brokerTestControl(t, restarted.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || int64(creates) != expectedPosts || inferences != 0 || stops != 0 || int64(deletes) != expectedPosts { @@ -91,46 +94,3 @@ func TestBrokerInvalidRemoteHeaderDoesNotReserveSubmission(t *testing.T) { } } } - -func TestHostedInvalidRemoteHeaderDoesNotReserveCreation(t *testing.T) { - for name, corrupt := range invalidRemoteHeaderTokens() { - t.Run(name, func(t *testing.T) { - f := newHostedGatewayTestFixture(t, hostedGatewayTestOptions{}) - var posts atomic.Int64 - transport := f.gateway.httpClient.Transport - f.gateway.httpClient.Transport = hostedGatewayTestRoundTripper(func(request *http.Request) (*http.Response, error) { - if request.Method == http.MethodPost { - posts.Add(1) - } - return transport.RoundTrip(request) - }) - f.gateway.provider = hostedGatewayTestTokenProvider(func(context.Context) (string, error) { - token := f.token.Load().(string) - if f.tokenCalls.Add(1) == 4 { - return corrupt(token), nil - } - return token, nil - }) - if f.initialize() == nil || f.tokenCalls.Load() != 4 { - t.Fatal("fixture did not reach the invalid creation header") - } - if posts.Load() != 0 || f.creates.Load() != 0 || f.dials.Load() != 0 || f.httpCalls.Load() != 3 { - t.Error("invalid creation header reached transport or channel setup") - } - f.assertNoBootstrap(t) - store, ledger, err := openHostedGatewayStore(f.settings.stateDir, f.settings.config) - if err != nil { - t.Fatal("could not reopen gateway after local header rejection") - } - defer store.close() - if ledger.CreateAttempted || ledger.SessionCreated || ledger.ExposurePossible { - t.Fatal("local header rejection stranded the hosted creation") - } - restarted := &hostedGateway{cfg: f.settings.config, provider: f.gateway.provider, - httpClient: f.gateway.httpClient, store: store, ledger: ledger} - if restarted.ensureSession(t.Context()) != nil || posts.Load() != 1 || f.creates.Load() != 1 || !restarted.ledger.SessionCreated { - t.Fatal("definitely-unsent header rejection prevented a later owned creation") - } - }) - } -} diff --git a/broker_renewal_create_test.go b/internal/broker/renewal_create_test.go similarity index 85% rename from broker_renewal_create_test.go rename to internal/broker/renewal_create_test.go index 6ed5b3a..7b9b2a7 100644 --- a/broker_renewal_create_test.go +++ b/internal/broker/renewal_create_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -7,6 +7,9 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func requirePendingCreateRenewal(t *testing.T, proof brokerControlResponse, renewal brokerContext) { @@ -23,7 +26,7 @@ func requirePendingCreateRenewal(t *testing.T, proof brokerControlResponse, rene func requireBlockedRenewalReplay(t *testing.T, base string, renewal brokerContext, pending bool, ambiguous uint32) { t.Helper() - proof := brokerTestControl(t, base, brokerRenewPath, renewal) + proof := brokerTestControl(t, base, brokerapi.RenewPath, renewal) if proof.State != "blocked" || proof.CreatePending != pending || proof.AmbiguousInvocations != ambiguous || proof.SettlementProven || proof.RetirementProven || proof.ProofDigest != "" { t.Fatal("renewal replay reopened unresolved ownership") @@ -48,18 +51,18 @@ func TestBrokerRenewalDuringPendingCreateSurvivesOriginalExpiry(t *testing.T) { renewal := c renewal.LeaseGeneration = 2 renewal.LeaseExpiresAt = originalExpiry.Add(3 * time.Second).UTC().Format(time.RFC3339Nano) - proof := brokerTestControl(t, server.URL, brokerRenewPath, renewal) + proof := brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal) requirePendingCreateRenewal(t, proof, renewal) // Only this exact idempotent control may be repeated. The original create // and inference each retain their one attempt. - requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerRenewPath, renewal), renewal) - status := brokerTestControl(t, server.URL, brokerStatusPath, c) + requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal), renewal) + status := brokerTestControl(t, server.URL, brokerapi.StatusPath, c) if status.State != "blocked" || !status.CreatePending || status.SettlementProven || status.RetirementProven { t.Fatal("renewal changed ordinary pending-creation status") } time.Sleep(time.Until(originalExpiry.Add(150 * time.Millisecond))) b.mu.Lock() - prompt := b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()] + prompt := b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()] active := !prompt.Closing && !prompt.Settled && prompt.LeaseGeneration == 2 && prompt.LeaseExpiresAt.After(time.Now()) b.mu.Unlock() if !active { @@ -74,11 +77,11 @@ func TestBrokerRenewalDuringPendingCreateSurvivesOriginalExpiry(t *testing.T) { if result.err != nil || result.status != http.StatusOK { t.Fatal("the original invocation failed after its renewed creation completed") } - proof = brokerTestControl(t, server.URL, brokerSettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.CreatePending || proof.LeaseGeneration != 2 { t.Fatal("the original prompt did not settle under its renewed lease") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes = f.counts() if !proof.RetirementProven || creates != 1 || inferences != 1 || stops != 0 || deletes != 1 { t.Fatal("renewal changed exact-attempt execution or retirement") @@ -86,7 +89,7 @@ func TestBrokerRenewalDuringPendingCreateSurvivesOriginalExpiry(t *testing.T) { } func TestBrokerRenewalDuringPendingCreateCannotReopenClosing(t *testing.T) { - for _, path := range []string{brokerSettlePath, brokerRetirePath} { + for _, path := range []string{brokerapi.SettlePath, brokerapi.RetirePath} { t.Run(path, func(t *testing.T) { f := newBrokerFixture(t, "hold-create") defer f.unblock() @@ -102,13 +105,13 @@ func TestBrokerRenewalDuringPendingCreateCannotReopenClosing(t *testing.T) { renewal := c renewal.LeaseGeneration = 2 renewal.LeaseExpiresAt = time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano) - requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerRenewPath, renewal), renewal) + requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal), renewal) brokerPendingControl(t, server.URL, path, c, true, 0) - if path == brokerSettlePath { + if path == brokerapi.SettlePath { requireBlockedRenewalReplay(t, server.URL, renewal, true, 0) } else { - cc, body := brokerTestControlContext(brokerRenewPath, renewal) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, cc, body) + cc, body := brokerTestControlContext(brokerapi.RenewPath, renewal) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, cc, body) if err != nil || status != http.StatusGone { t.Fatal("retiring owner accepted a renewal replay") } @@ -118,7 +121,7 @@ func TestBrokerRenewalDuringPendingCreateCannotReopenClosing(t *testing.T) { if result.err == nil && result.status == http.StatusOK { t.Fatal("closed creation proceeded to inference") } - proof := brokerTestControl(t, server.URL, brokerRetirePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || creates != 1 || inferences != 0 || stops != 0 || deletes != 1 { t.Fatal("pending-creation cleanup lost ownership or replayed work") @@ -149,7 +152,7 @@ func TestBrokerRenewalDuringPendingCreateCannotReopenLostAcknowledgement(t *test renewal := c renewal.LeaseGeneration = 2 renewal.LeaseExpiresAt = time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano) - requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerRenewPath, renewal), renewal) + requirePendingCreateRenewal(t, brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal), renewal) unblock() result := brokerWaitInference(t, done) if result.err == nil && result.status == http.StatusOK { @@ -161,7 +164,7 @@ func TestBrokerRenewalDuringPendingCreateCannotReopenLostAcknowledgement(t *test server.Close() _, server = startBrokerTest(t, cfg) requireBlockedRenewalReplay(t, server.URL, renewal, true, 0) - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) creates, inferences, stops, deletes := f.counts() if creates != 1 || inferences != 0 || stops != 0 || deletes != 0 { t.Fatal("abandoned creation was replayed or retired from missing-session evidence") @@ -173,7 +176,7 @@ func TestBrokerRenewalDuringPendingCreateCannotReopenLostAcknowledgement(t *test return len(f.sessions) == 1 }) // Renewal and a later object cannot replace the lost original CREATE ack. - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) creates, inferences, stops, deletes = f.counts() if creates != 1 || inferences != 0 || stops != 0 || deletes != 0 { t.Fatal("late unacknowledged creation was replayed or retired") @@ -197,7 +200,7 @@ func TestBrokerRenewalReplayCannotClearAmbiguousInference(t *testing.T) { renewal := c renewal.LeaseGeneration = 2 renewal.LeaseExpiresAt = time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano) - proof := brokerTestControl(t, server.URL, brokerRenewPath, renewal) + proof := brokerTestControl(t, server.URL, brokerapi.RenewPath, renewal) if proof.State != "open" || proof.LeaseGeneration != 2 { t.Fatal("live inference did not acknowledge its lease renewal") } @@ -205,13 +208,13 @@ func TestBrokerRenewalReplayCannotClearAmbiguousInference(t *testing.T) { _ = brokerWaitInference(t, done) brokerAwait(t, func() bool { return brokerInvocationState(b, c) == "uncertain" }) requireBlockedRenewalReplay(t, server.URL, renewal, false, 1) - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 1) creates, inferences, _, deletes := f.counts() if creates != 1 || inferences != 1 || deletes != 0 { t.Fatal("ambiguous inference was replayed or deleted") } - statusContext, body := brokerTestControlContext(brokerStatusPath, c) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerStatusPath, statusContext, body) + statusContext, body := brokerTestControlContext(brokerapi.StatusPath, c) + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.StatusPath, statusContext, body) if err != nil || status != http.StatusOK || json.Unmarshal(data, &proof) != nil || proof.State != "blocked" || proof.SettlementProven || proof.RetirementProven { t.Fatal("ambiguous ownership no longer blocks status and cleanup") diff --git a/broker_response_identity_write_test.go b/internal/broker/response_identity_write_test.go similarity index 81% rename from broker_response_identity_write_test.go rename to internal/broker/response_identity_write_test.go index 4fac3ce..865b46f 100644 --- a/broker_response_identity_write_test.go +++ b/internal/broker/response_identity_write_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -12,13 +12,16 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) const brokerIdentityRemoteSession = "11111111-1111-4111-8111-111111111111" func newBrokerResponseIdentityFixture(t *testing.T) (*lifecycleBroker, brokerContext) { t.Helper() - cfg := brokerConfiguration{configDigest: brokerSHA([]byte("identity-write-fixture")), stateDir: filepath.Join(t.TempDir(), "broker")} + cfg := brokerConfiguration{configDigest: foundry.Digest([]byte("identity-write-fixture")), stateDir: filepath.Join(t.TempDir(), "broker")} store, ledger, err := openBrokerStore(cfg.stateDir, cfg.configDigest) if err != nil { t.Fatal("could not initialize identity fixture store") @@ -28,16 +31,16 @@ func newBrokerResponseIdentityFixture(t *testing.T) (*lifecycleBroker, brokerCon t.Cleanup(cancel) b := &lifecycleBroker{store: store, ledger: ledger, ctx: ctx, cancel: cancel, active: map[string]brokerActive{}} c := brokerTestContext(cfg) - c.BodySHA256 = brokerSHA([]byte("identity-write-input")) + c.BodySHA256 = foundry.Digest([]byte("identity-write-input")) c.LeaseExpiresAt = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) err = b.commitLocked(func(next *brokerLedger) error { session, err := brokerEnsureSession(next, c) if err != nil { return err } - next.PrincipalDigest = brokerSHA([]byte("identity-write-principal")) + next.PrincipalDigest = foundry.Digest([]byte("identity-write-principal")) session.RemoteID, session.CreateState = brokerIdentityRemoteSession, "known" - if _, err := brokerRecordOperation(session, brokerResponsesPath, c); err != nil { + if _, err := brokerRecordOperation(session, brokerapi.ResponsesPath, c); err != nil { return err } prompt, err := brokerEnsurePrompt(session, c) @@ -103,7 +106,7 @@ func brokerIdentityLedgerBytes(t *testing.T, b *lifecycleBroker) []byte { } var ledger brokerLedger if json.Unmarshal(data, &ledger) != nil || !brokerLedgerValid(&ledger, b.ledger.ConfigDigest) || - brokerJSONDigest(ledger) != brokerJSONDigest(b.ledger) { + foundry.JSONDigest(ledger) != foundry.JSONDigest(b.ledger) { t.Fatal("in-memory identity does not match valid durable ownership") } return data @@ -116,17 +119,17 @@ func TestBrokerResponseIdentityWritesOncePerInvocation(t *testing.T) { valid bool }{ {"repeated", 64, true}, - {"event-limit", defaultMaxEvents, true}, - {"over-event-limit", defaultMaxEvents + 1, false}, + {"event-limit", foundry.DefaultMaxEvents, true}, + {"over-event-limit", foundry.DefaultMaxEvents + 1, false}, } { t.Run(test.name, func(t *testing.T) { b, c := newBrokerResponseIdentityFixture(t) events := make([]string, test.events) for i := range events { - events[i] = acpTestSSE(`{"type":"response.in_progress","response":{"id":"response-1","status":"in_progress"}}`) + events[i] = testSSE(`{"type":"response.in_progress","response":{"id":"response-1","status":"in_progress"}}`) } - events[0] = acpTestSSE(`{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}`) - events[len(events)-1] = acpTestSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed"}}`) + events[0] = testSSE(`{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}`) + events[len(events)-1] = testSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed"}}`) reader := &brokerIdentityEventReader{t: t, path: filepath.Join(b.store.dir, "state.json"), events: events} data, err := b.readTrackedStream(reader, c, brokerIdentityRemoteSession) if err != nil { @@ -135,12 +138,12 @@ func TestBrokerResponseIdentityWritesOncePerInvocation(t *testing.T) { if reader.writes != 1 { t.Fatalf("identity persistence count = %d for %d lifecycle events; want 1", reader.writes, test.events) } - invocation := b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[c.InvocationSequence] + invocation := b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[c.InvocationSequence] if invocation.State != "accepted" || invocation.ResponseID != "response-1" || invocation.ResponseAlias == "" { t.Fatal("stream tracking did not preserve acceptance separately from completion") } _ = brokerIdentityLedgerBytes(t, b) - summary, err := acpParseFoundrySSE(bytes.NewReader(data)) + summary, err := foundry.ParseStrictSSE(bytes.NewReader(data)) if (err == nil) != test.valid { t.Fatal("identity deduplication changed stream event validation") } @@ -160,12 +163,12 @@ func TestBrokerResponseIdentityWritesOncePerInvocation(t *testing.T) { func TestBrokerResponseIdentityDuplicateStillRejectsConflicts(t *testing.T) { b, c := newBrokerResponseIdentityFixture(t) - accepted := foundryResponse{ID: "response-1", AgentSessionID: brokerIdentityRemoteSession} + accepted := foundry.Response{ID: "response-1", AgentSessionID: brokerIdentityRemoteSession} if err := b.recordResponseIdentity(c, accepted, brokerIdentityRemoteSession); err != nil { t.Fatal("initial response identity was rejected") } before := brokerIdentityLedgerBytes(t, b) - for name, response := range map[string]foundryResponse{ + for name, response := range map[string]foundry.Response{ "changed-response": {ID: "response-2"}, "missing-response": {}, "invalid-response": {ID: "response/invalid"}, @@ -180,15 +183,15 @@ func TestBrokerResponseIdentityDuplicateStillRejectsConflicts(t *testing.T) { } }) } - if _, err := b.commitCompletedResponse(c, foundryStreamSummary{ResponseID: accepted.ID}); err != nil { + if _, err := b.commitCompletedResponse(c, foundry.StreamSummary{ResponseID: accepted.ID}); err != nil { t.Fatal("could not prepare previous response identity") } nextContext := c nextContext.InvocationSequence++ nextContext.OperationID = "identity-next-invocation" if err := b.commitLocked(func(next *brokerLedger) error { - session := next.Sessions[brokerJSONDigest(c.Owner)] - if _, err := brokerRecordOperation(session, brokerResponsesPath, nextContext); err != nil { + session := next.Sessions[foundry.JSONDigest(c.Owner)] + if _, err := brokerRecordOperation(session, brokerapi.ResponsesPath, nextContext); err != nil { return err } prompt := session.Prompts[c.promptKey()] @@ -210,7 +213,7 @@ func TestBrokerResponseIdentityDuplicateStillRejectsConflicts(t *testing.T) { func TestBrokerResponseIdentityConcurrentDuplicatesDoNotWrite(t *testing.T) { b, c := newBrokerResponseIdentityFixture(t) - response := foundryResponse{ID: "response-1"} + response := foundry.Response{ID: "response-1"} if err := b.recordResponseIdentity(c, response, brokerIdentityRemoteSession); err != nil { t.Fatal("initial response identity was rejected") } @@ -247,14 +250,14 @@ func TestBrokerResponseIdentityPersistenceFailureRemainsClosed(t *testing.T) { } t.Run(name, func(t *testing.T) { b, c := newBrokerResponseIdentityFixture(t) - response := foundryResponse{ID: "response-1"} + response := foundry.Response{ID: "response-1"} if existing && b.recordResponseIdentity(c, response, brokerIdentityRemoteSession) != nil { t.Fatal("initial response identity was rejected") } before := brokerIdentityLedgerBytes(t, b) active, cancel := context.WithCancel(context.Background()) defer cancel() - b.active[brokerJSONDigest(c.Owner)] = brokerActive{prompt: c.promptKey(), cancel: cancel} + b.active[foundry.JSONDigest(c.Owner)] = brokerActive{prompt: c.promptKey(), cancel: cancel} if err := os.Rename(b.store.dir, b.store.dir+"-unavailable"); err != nil { t.Fatal("could not inject identity persistence failure") } @@ -267,7 +270,7 @@ func TestBrokerResponseIdentityPersistenceFailureRemainsClosed(t *testing.T) { if active.Err() != context.Canceled || !errors.Is(b.storageError, errBrokerStorage) { t.Fatal("persistence failure did not contain active work") } - if brokerSHA(before) != brokerJSONDigest(b.ledger) { + if foundry.Digest(before) != foundry.JSONDigest(b.ledger) { t.Fatal("failed persistence changed the last durable identity") } if err := os.Rename(b.store.dir+"-unavailable", b.store.dir); err != nil { @@ -282,12 +285,12 @@ func TestBrokerResponseIdentityPersistenceFailureRemainsClosed(t *testing.T) { func TestBrokerResponseIdentityTrackingStillRejectsMalformedTail(t *testing.T) { b, c := newBrokerResponseIdentityFixture(t) - created := acpTestSSE(`{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}`) - stream := created + acpTestSSE(`{"type":"response.in_progress","response":{"id":"response-1","status":"completed"}}`) + created := testSSE(`{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}`) + stream := created + testSSE(`{"type":"response.in_progress","response":{"id":"response-1","status":"completed"}}`) if _, err := b.readTrackedStream(strings.NewReader(stream), c, brokerIdentityRemoteSession); err == nil { t.Fatal("duplicate response identity bypassed lifecycle validation") } - invocation := b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[c.InvocationSequence] + invocation := b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[c.InvocationSequence] if invocation.ResponseID != "response-1" || invocation.State != "accepted" { t.Fatal("malformed tail erased earlier durable acknowledgement") } diff --git a/broker_response_storage_precedence_test.go b/internal/broker/response_storage_precedence_test.go similarity index 99% rename from broker_response_storage_precedence_test.go rename to internal/broker/response_storage_precedence_test.go index 0d3fcf9..68c02e7 100644 --- a/broker_response_storage_precedence_test.go +++ b/internal/broker/response_storage_precedence_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" diff --git a/broker_responses.go b/internal/broker/responses.go similarity index 75% rename from broker_responses.go rename to internal/broker/responses.go index 75ae403..406ff07 100644 --- a/broker_responses.go +++ b/internal/broker/responses.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bufio" @@ -13,11 +13,14 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) func (b *lifecycleBroker) serveResponses(w http.ResponseWriter, r *http.Request, c brokerContext, raw []byte) { - var request acpResponseRequest - if acpDecode(raw, &request, true) != nil || request.Model != b.cfg.agent.Model || !request.Stream || !request.Store || request.Input == nil { + var request foundry.ModelResponseRequest + if strictjson.Decode(raw, &request, true) != nil || request.Model != b.cfg.agent.Model || !request.Stream || !request.Store || request.Input == nil { brokerWriteError(w, errBrokerInvalid) return } @@ -30,19 +33,19 @@ func (b *lifecycleBroker) serveResponses(w http.ResponseWriter, r *http.Request, // or empty values that would disappear when the decoded request is encoded. for name := range fields { if strings.EqualFold(name, "agent_session_id") || - (b.cfg.agent.ToolSchemaMode == toolSchemaModeProviderStatic && strings.EqualFold(name, "tools")) { + (b.cfg.agent.ToolSchemaMode == foundry.ToolSchemaModeProviderStatic && strings.EqualFold(name, "tools")) { brokerWriteError(w, errBrokerInvalid) return } } for _, tool := range request.Tools { var schema map[string]any - if tool.Type != "function" || !acpSafeString(tool.Name, 512) || acpDecode(tool.Parameters, &schema, false) != nil { + if tool.Type != "function" || !foundry.SafeString(tool.Name, 512) || strictjson.Decode(tool.Parameters, &schema, false) != nil { brokerWriteError(w, errBrokerInvalid) return } } - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) runCtx, cancel := context.WithCancel(b.ctx) err := b.reserveInvocation(c, &request, cancel) if err != nil { @@ -54,8 +57,8 @@ func (b *lifecycleBroker) serveResponses(w http.ResponseWriter, r *http.Request, defer cancel() stopDisconnect := context.AfterFunc(r.Context(), func() { b.closePrompt(key, c.promptKey()); cancel() }) defer stopDisconnect() - var result foundryResponse - result, err = b.invoke(runCtx, c, request.foundryResponseRequest) + var result foundry.Response + result, err = b.invoke(runCtx, c, request.ResponseRequest) stopDisconnect() b.finishInvocation(c, err) // Finalization can poison storage after a valid remote completion. The @@ -80,7 +83,7 @@ func (b *lifecycleBroker) serveResponses(w http.ResponseWriter, r *http.Request, } } -func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *acpResponseRequest, cancel context.CancelFunc) error { +func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *foundry.ModelResponseRequest, cancel context.CancelFunc) error { b.mu.Lock() defer b.mu.Unlock() if b.storageError != nil { @@ -89,7 +92,7 @@ func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *acpRespons if b.ctx.Err() != nil { return errBrokerClosed } - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) if _, active := b.active[key]; active { return errBrokerConflict } @@ -101,7 +104,7 @@ func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *acpRespons if session.Retiring || session.Retired { return errBrokerClosed } - duplicate, err := brokerRecordOperation(session, brokerResponsesPath, c) + duplicate, err := brokerRecordOperation(session, brokerapi.ResponsesPath, c) if err != nil { return err } @@ -122,7 +125,7 @@ func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *acpRespons if prompt.LastSequence != 0 && request.PreviousResponseID != prompt.LastAlias { return errBrokerConflict } - if err := brokerTranslatePrevious(session, c, prompt.LastSequence == 0, &request.foundryResponseRequest); err != nil { + if err := brokerTranslatePrevious(session, c, prompt.LastSequence == 0, &request.ResponseRequest); err != nil { return err } prompt.LastSequence = c.InvocationSequence @@ -138,7 +141,7 @@ func (b *lifecycleBroker) reserveInvocation(c brokerContext, request *acpRespons return nil } -func brokerTranslatePrevious(session *brokerSession, c brokerContext, first bool, request *foundryResponseRequest) error { +func brokerTranslatePrevious(session *brokerSession, c brokerContext, first bool, request *foundry.ResponseRequest) error { previous, hasPrevious := session.Responses[request.PreviousResponseID] if request.PreviousResponseID != "" && (!hasPrevious || !previous.Completed || (first && previous.HasFunctions)) { @@ -171,7 +174,7 @@ func brokerTranslatePrevious(session *brokerSession, c brokerContext, first bool call, ok := item["call_id"].(string) output, outputOK := item["output"].(string) remote, owned := previous.CallIDs[call] - if !ok || !outputOK || !owned || seen[call] || len(item) != 3 || len(output) > defaultMaxBrokeredBytes { + if !ok || !outputOK || !owned || seen[call] || len(item) != 3 || len(output) > foundry.DefaultMaxBrokeredBytes { return errBrokerConflict } seen[call] = true @@ -181,15 +184,15 @@ func brokerTranslatePrevious(session *brokerSession, c brokerContext, first bool return nil } -func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request foundryResponseRequest) (foundryResponse, error) { - key, promptKey := brokerJSONDigest(c.Owner), c.promptKey() +func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request foundry.ResponseRequest) (foundry.Response, error) { + key, promptKey := foundry.JSONDigest(c.Owner), c.promptKey() b.mu.Lock() session := b.ledger.Sessions[key] createState, remoteID := session.CreateState, session.RemoteID b.mu.Unlock() if createState == "none" { if err := b.validateRemoteTarget(ctx); err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } remoteID = uuid.NewString() prepareCtx, cancelPrepare := context.WithTimeout(ctx, b.cfg.operationTimeout) @@ -197,7 +200,7 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f deadline, _ := prepareCtx.Deadline() cancelPrepare() if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } // Before durable intent, caller cancellation must leave creation unsent. // After intent, keep this one bounded attempt alive to retain its ack. @@ -216,7 +219,7 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f b.mu.Unlock() if err != nil { cancelCreate() - return foundryResponse{}, err + return foundry.Response{}, err } // Once the intent is durable, finish this one creation attempt even if // the prompt closes. Losing its acknowledgement would leave an owner @@ -225,7 +228,7 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f created, createErr := b.remoteSessionCreate(prepared, remoteID) cancelCreate() if createErr != nil && !errors.Is(createErr, errBrokerRequestUnsent) { - return foundryResponse{}, createErr + return foundry.Response{}, createErr } b.mu.Lock() err = b.commitLocked(func(next *brokerLedger) error { @@ -241,32 +244,32 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f }) b.mu.Unlock() if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } if createErr != nil { - return foundryResponse{}, createErr + return foundry.Response{}, createErr } if !created { - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } } else if createState != "known" { - return foundryResponse{}, errBrokerPending + return foundry.Response{}, errBrokerPending } if ctx.Err() != nil { - return foundryResponse{}, errBrokerClosed + return foundry.Response{}, errBrokerClosed } current, status, err := b.remoteSessionGet(ctx, remoteID) if err != nil || status != http.StatusOK || (current.Status != "active" && current.Status != "idle") { - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } request.AgentSessionID = remoteID body, err := json.Marshal(request) if err != nil { - return foundryResponse{}, errBrokerInvalid + return foundry.Response{}, errBrokerInvalid } prepared, err := b.prepareRemoteRequest(ctx, http.MethodPost, "/endpoint/protocols/openai/responses", body) if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } b.mu.Lock() err = b.commitLocked(func(next *brokerLedger) error { @@ -280,17 +283,17 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f }) b.mu.Unlock() if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } response, err := b.sendRemoteRequest(prepared) if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } defer response.Body.Close() //nolint:errcheck if response.StatusCode != http.StatusOK { - count, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, acpMaxConfigBytes+1)) - if readErr != nil || count > acpMaxConfigBytes || !brokerDefiniteRejection(response.StatusCode) { - return foundryResponse{}, errBrokerAmbiguous + count, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, foundry.MaxAgentConfigBytes+1)) + if readErr != nil || count > foundry.MaxAgentConfigBytes || !foundry.DefiniteRejection(response.StatusCode) { + return foundry.Response{}, errBrokerAmbiguous } // An explicit complete HTTP rejection is different from a lost response. // It never authorizes a retry, but has no delayed unacknowledged request. @@ -301,35 +304,35 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f }) b.mu.Unlock() if err != nil { - return foundryResponse{}, err + return foundry.Response{}, err } - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil { - return foundryResponse{}, errBrokerAmbiguous + return foundry.Response{}, errBrokerAmbiguous } - var summary foundryStreamSummary + var summary foundry.StreamSummary switch mediaType { case "text/event-stream": var data []byte data, err = b.readTrackedStream(response.Body, c, remoteID) if err == nil { - summary, err = acpParseFoundrySSE(bytes.NewReader(data)) + summary, err = foundry.ParseStrictSSE(bytes.NewReader(data)) } case "application/json": var data []byte - data, err = io.ReadAll(io.LimitReader(response.Body, defaultMaxStreamBytes+1)) - if err == nil && len(data) <= defaultMaxStreamBytes { - var document foundryResponse + data, err = io.ReadAll(io.LimitReader(response.Body, foundry.DefaultMaxStreamBytes+1)) + if err == nil && len(data) <= foundry.DefaultMaxStreamBytes { + var document foundry.Response document, err = brokerDecodeResponseEvidence(data) if err == nil { err = b.recordResponseIdentity(c, document, remoteID) } if err == nil && document.Status == "completed" { - document, err = acpDecodeFoundryResponse(data) + document, err = foundry.DecodeResponse(data) if err == nil { - summary, err = processCompletedResponse(document, responseCallbacks{}) + summary, err = foundry.CompleteResponse(document, foundry.ResponseCallbacks{}) } } else if err == nil { err = errBrokerRemote @@ -340,76 +343,63 @@ func (b *lifecycleBroker) invoke(ctx context.Context, c brokerContext, request f default: err = errBrokerAmbiguous } - if err != nil || acpValidateSummary(summary) != nil { - return foundryResponse{}, errBrokerAmbiguous + if err != nil || foundry.ValidateSummary(summary) != nil { + return foundry.Response{}, errBrokerAmbiguous } return b.commitCompletedResponse(c, summary) } -func brokerDefiniteRejection(status int) bool { - // A gateway timeout or server error may follow a forwarded request whose - // response was lost. Only explicit admission rejections close this ambiguity. - switch status { - case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, - http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge, - http.StatusUnsupportedMediaType, http.StatusUnprocessableEntity, http.StatusTooManyRequests: - return true - default: - return false - } -} - // Acceptance evidence is distinct from usable output. A coherent failed or // incomplete response still acknowledges this invocation and permits stop // containment, while only the stricter ACP decoder may admit successful output. -func brokerDecodeResponseEvidence(data []byte) (foundryResponse, error) { +func brokerDecodeResponseEvidence(data []byte) (foundry.Response, error) { var evidence struct { - ID string `json:"id"` - Status string `json:"status"` - AgentSessionID string `json:"agent_session_id"` - Output json.RawMessage `json:"output"` - Error *foundryError `json:"error"` - Incomplete *foundryIncomplete `json:"incomplete_details"` + ID string `json:"id"` + Status string `json:"status"` + AgentSessionID string `json:"agent_session_id"` + Output json.RawMessage `json:"output"` + Error *foundry.ResponseError `json:"error"` + Incomplete *foundry.Incomplete `json:"incomplete_details"` } - if acpDecodeStruct(data, &evidence, false) != nil || evidence.ID == "" || validateProviderIdentifier("response", evidence.ID) != nil { - return foundryResponse{}, errBrokerRemote + if strictjson.DecodeStruct(data, &evidence, false) != nil || evidence.ID == "" || foundry.ValidateIdentifier("response", evidence.ID) != nil { + return foundry.Response{}, errBrokerRemote } - response := foundryResponse{ID: evidence.ID, Status: evidence.Status, AgentSessionID: evidence.AgentSessionID, + response := foundry.Response{ID: evidence.ID, Status: evidence.Status, AgentSessionID: evidence.AgentSessionID, Error: evidence.Error, Incomplete: evidence.Incomplete} switch response.Status { case "queued", "in_progress", "completed", "cancelled": if response.Error != nil || response.Incomplete != nil { - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } case "failed": if response.Incomplete != nil { - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } case "incomplete": if response.Error != nil { - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } default: - return foundryResponse{}, errBrokerRemote + return foundry.Response{}, errBrokerRemote } return response, nil } func (b *lifecycleBroker) readTrackedStream(reader io.Reader, c brokerContext, remoteID string) ([]byte, error) { - limited := &io.LimitedReader{R: reader, N: defaultMaxStreamBytes + 1} + limited := &io.LimitedReader{R: reader, N: foundry.DefaultMaxStreamBytes + 1} scanner := bufio.NewScanner(limited) - scanner.Buffer(make([]byte, 32<<10), defaultMaxEventBytes) + scanner.Buffer(make([]byte, 32<<10), foundry.DefaultMaxEventBytes) var raw, event []byte flush := func() error { if len(event) == 0 || bytes.Equal(bytes.TrimSpace(event), []byte("[DONE]")) { return nil } var frame struct { - Type string `json:"type"` - Response json.RawMessage `json:"response"` - Error *foundryError `json:"error"` + Type string `json:"type"` + Response json.RawMessage `json:"response"` + Error *foundry.ResponseError `json:"error"` } - if acpDecodeStruct(event, &frame, false) != nil { + if strictjson.DecodeStruct(event, &frame, false) != nil { return errBrokerRemote } if frame.Response == nil { @@ -435,7 +425,7 @@ func (b *lifecycleBroker) readTrackedStream(reader io.Reader, c brokerContext, r } for scanner.Scan() { line := scanner.Bytes() - if len(raw)+len(line)+1 > defaultMaxStreamBytes { + if len(raw)+len(line)+1 > foundry.DefaultMaxStreamBytes { return nil, errBrokerRemote } raw = append(raw, line...) @@ -447,7 +437,7 @@ func (b *lifecycleBroker) readTrackedStream(reader io.Reader, c brokerContext, r event = nil } else if part, ok := bytes.CutPrefix(line, []byte("data:")); ok { part = bytes.TrimPrefix(part, []byte(" ")) - if len(event)+len(part)+1 > defaultMaxEventBytes { + if len(event)+len(part)+1 > foundry.DefaultMaxEventBytes { return nil, errBrokerRemote } if len(event) > 0 { @@ -462,8 +452,8 @@ func (b *lifecycleBroker) readTrackedStream(reader io.Reader, c brokerContext, r return raw, nil } -func (b *lifecycleBroker) recordResponseIdentity(c brokerContext, response foundryResponse, remoteID string) error { - if response.ID == "" || validateProviderIdentifier("response", response.ID) != nil || +func (b *lifecycleBroker) recordResponseIdentity(c brokerContext, response foundry.Response, remoteID string) error { + if response.ID == "" || foundry.ValidateIdentifier("response", response.ID) != nil || (response.AgentSessionID != "" && response.AgentSessionID != remoteID) { return errBrokerConflict } @@ -472,7 +462,7 @@ func (b *lifecycleBroker) recordResponseIdentity(c brokerContext, response found if b.storageError != nil { return b.storageError } - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) // Lifecycle events repeat the same identity. Only its first acceptance // changes durable ownership, but duplicates must still fail on storage loss. if previous := b.ledger.Sessions[key].Prompts[c.promptKey()].Invocations[c.InvocationSequence].ResponseID; previous != "" { @@ -496,12 +486,12 @@ func (b *lifecycleBroker) recordResponseIdentity(c brokerContext, response found }) } -func (b *lifecycleBroker) commitCompletedResponse(c brokerContext, summary foundryStreamSummary) (foundryResponse, error) { +func (b *lifecycleBroker) commitCompletedResponse(c brokerContext, summary foundry.StreamSummary) (foundry.Response, error) { b.mu.Lock() defer b.mu.Unlock() - var output foundryResponse + var output foundry.Response err := b.commitCapacityLocked(true, func(next *brokerLedger) error { - session := next.Sessions[brokerJSONDigest(c.Owner)] + session := next.Sessions[foundry.JSONDigest(c.Owner)] prompt := session.Prompts[c.promptKey()] invocation := prompt.Invocations[c.InvocationSequence] if invocation.ResponseID != summary.ResponseID || invocation.ResponseAlias == "" { @@ -510,16 +500,16 @@ func (b *lifecycleBroker) commitCompletedResponse(c brokerContext, summary found if prompt.Closing || !prompt.LeaseExpiresAt.After(time.Now()) { return errBrokerClosed } - output = foundryResponse{ID: invocation.ResponseAlias, Status: "completed", Output: []foundryOutputItem{}} + output = foundry.Response{ID: invocation.ResponseAlias, Status: "completed", Output: []foundry.OutputItem{}} if summary.Text != "" { - output.Output = append(output.Output, foundryOutputItem{ID: "fi_" + uuid.NewString(), Type: "message", - Content: []foundryOutputContent{{Type: "output_text", Text: summary.Text}}}) + output.Output = append(output.Output, foundry.OutputItem{ID: "fi_" + uuid.NewString(), Type: "message", + Content: []foundry.OutputContent{{Type: "output_text", Text: summary.Text}}}) } link := session.Responses[invocation.ResponseAlias] link.CallIDs = map[string]string{} seen := map[string]bool{} for _, item := range summary.FunctionCalls { - if !acpSafeString(item.CallID, maxProviderIdentifierBytes) || seen[item.CallID] { + if !foundry.SafeString(item.CallID, foundry.MaxIdentifierBytes) || seen[item.CallID] { return errBrokerConflict } seen[item.CallID] = true @@ -537,7 +527,7 @@ func (b *lifecycleBroker) commitCompletedResponse(c brokerContext, summary found } func (b *lifecycleBroker) finishInvocation(c brokerContext, invocationError error) { - key := brokerJSONDigest(c.Owner) + key := foundry.JSONDigest(c.Owner) b.mu.Lock() _ = b.commitLocked(func(next *brokerLedger) error { prompt := next.Sessions[key].Prompts[c.promptKey()] diff --git a/broker_retirement_capacity_test.go b/internal/broker/retirement_capacity_test.go similarity index 79% rename from broker_retirement_capacity_test.go rename to internal/broker/retirement_capacity_test.go index 6d5da18..44a7397 100644 --- a/broker_retirement_capacity_test.go +++ b/internal/broker/retirement_capacity_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -8,29 +8,32 @@ import ( "path/filepath" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) const brokerHistoricalOperationCapacity = 16384 func brokerFillHistoricalOperationCapacity(t *testing.T, b *lifecycleBroker, c brokerContext, count int) (brokerContext, map[string]string) { t.Helper() - renewal, body := brokerTestControlContext(brokerRenewPath, c) - renewal.BodySHA256 = brokerSHA(body) + renewal, body := brokerTestControlContext(brokerapi.RenewPath, c) + renewal.BodySHA256 = foundry.Digest(body) b.mu.Lock() err := b.commitLocked(func(next *brokerLedger) error { - session := next.Sessions[brokerJSONDigest(c.Owner)] + session := next.Sessions[foundry.JSONDigest(c.Owner)] prompt := session.Prompts[c.promptKey()] for len(session.Operations) < count { renewal.OperationID = fmt.Sprintf("historical-renewal-%d", len(session.Operations)) renewal.LeaseGeneration = prompt.LeaseGeneration + 1 - session.Operations[renewal.OperationID] = brokerOperationDigest(brokerRenewPath, renewal) + session.Operations[renewal.OperationID] = brokerOperationDigest(brokerapi.RenewPath, renewal) prompt.LeaseGeneration = renewal.LeaseGeneration } return nil }) valid := brokerLedgerValid(b.ledger, b.cfg.configDigest) - operations := make(map[string]string, len(b.ledger.Sessions[brokerJSONDigest(c.Owner)].Operations)) - for key, value := range b.ledger.Sessions[brokerJSONDigest(c.Owner)].Operations { + operations := make(map[string]string, len(b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Operations)) + for key, value := range b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Operations { operations[key] = value } b.mu.Unlock() @@ -52,7 +55,7 @@ func TestBrokerOperationCapacityPreservesSettlementAndRetirement(t *testing.T) { b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.LeaseExpiresAt = time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339Nano) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusOK { t.Fatal("initial fixture inference failed") } @@ -60,46 +63,46 @@ func TestBrokerOperationCapacityPreservesSettlementAndRetirement(t *testing.T) { fresh := renewal fresh.OperationID = "renewal-after-capacity" fresh.LeaseGeneration++ - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, fresh, []byte("{}")) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, fresh, []byte("{}")) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("saturated owner admitted more ordinary operation records") } - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, renewal, []byte("{}")) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, renewal, []byte("{}")) if err != nil || status != http.StatusOK { t.Fatal("saturation rejected an exact recorded renewal duplicate") } - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusConflict { t.Fatal("saturation replayed a recorded inference") } - conflict, body := brokerTestControlContext(brokerRetirePath, c) + conflict, body := brokerTestControlContext(brokerapi.RetirePath, c) conflict.OperationID = renewal.OperationID - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, conflict, body) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, conflict, body) if err != nil || status != http.StatusConflict { t.Fatal("cleanup capacity bypassed a recorded operation conflict") } if settleFirst { - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven { t.Fatal("saturated owner could not settle its current prompt") } - extra, body := brokerTestControlContext(brokerSettlePath, c) + extra, body := brokerTestControlContext(brokerapi.SettlePath, c) extra.OperationID = "extra-settlement-after-capacity" - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, extra, body) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, extra, body) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("extra settlement consumed retirement capacity") } } - proof := brokerTestControl(t, server.URL, brokerRetirePath, c) - if !proof.RetirementProven || proof.OwnerDigest != brokerJSONDigest(c.Owner) || !brokerDigestValid(proof.ProofDigest) { + proof := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) + if !proof.RetirementProven || proof.OwnerDigest != foundry.JSONDigest(c.Owner) || !foundry.DigestValid(proof.ProofDigest) { t.Fatal("saturated owner lacked exact durable retirement proof") } // Distinct cleanup IDs remain bounded; the original request stays // replayable even after the two reserved slots are occupied. for i := range 3 { - extra, body := brokerTestControlContext(brokerRetirePath, c) + extra, body := brokerTestControlContext(brokerapi.RetirePath, c) extra.OperationID = fmt.Sprintf("extra-retirement-%d", i) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, extra, body) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, extra, body) expected := http.StatusServiceUnavailable if !settleFirst && i == 0 { expected = http.StatusOK @@ -109,7 +112,7 @@ func TestBrokerOperationCapacityPreservesSettlementAndRetirement(t *testing.T) { } } b.mu.Lock() - session := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + session := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] bounded := len(session.Operations) == brokerHistoricalOperationCapacity+2 retained := true for key, value := range operations { @@ -123,7 +126,7 @@ func TestBrokerOperationCapacityPreservesSettlementAndRetirement(t *testing.T) { b.close() server.Close() _, server = startBrokerTest(t, cfg) - reopened := brokerTestControl(t, server.URL, brokerRetirePath, c) + reopened := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !reopened.RetirementProven || reopened.ProofDigest != proof.ProofDigest { t.Fatal("saturated retired owner did not reopen with the same proof") } @@ -141,7 +144,7 @@ func TestBrokerOperationCapacityRetainsUnknownCreation(t *testing.T) { b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) c.LeaseExpiresAt = time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339Nano) - _ = brokerTestControl(t, server.URL, brokerRenewPath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RenewPath, c) renewed, _ := brokerFillHistoricalOperationCapacity(t, b, c, brokerHistoricalOperationCapacity-1) c.LeaseGeneration = renewed.LeaseGeneration c.LeaseExpiresAt = renewed.LeaseExpiresAt @@ -152,8 +155,8 @@ func TestBrokerOperationCapacityRetainsUnknownCreation(t *testing.T) { t.Fatal("last ordinary operation did not attempt the original creation") } _ = brokerWaitInference(t, done) - retirement, body := brokerTestControlContext(brokerRetirePath, c) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, retirement, body) + retirement, body := brokerTestControlContext(brokerapi.RetirePath, c) + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, retirement, body) var proof brokerControlResponse if err != nil || status != http.StatusConflict || json.Unmarshal(data, &proof) != nil || !proof.CreatePending || proof.RetirementProven || proof.SettlementProven || proof.ProofDigest != "" { @@ -170,7 +173,7 @@ func TestBrokerOperationCapacityRetainsUnknownCreation(t *testing.T) { return len(f.sessions) == 1 }) // Reserved cleanup capacity cannot manufacture the original CREATE ack. - brokerPendingControl(t, server.URL, brokerRetirePath, c, true, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, true, 0) creates, inferences, _, deletes = f.counts() if creates != 1 || inferences != 0 || deletes != 0 { t.Fatal("saturated unacknowledged creation was replayed or retired") @@ -186,8 +189,8 @@ func TestBrokerOperationCapacityCancelsAcknowledgedInvocation(t *testing.T) { done := brokerAsyncInference(context.Background(), server.URL, c, brokerTestBody("")) brokerAwait(t, func() bool { return brokerInvocationState(b, c) == "accepted" }) _, _ = brokerFillHistoricalOperationCapacity(t, b, c, brokerHistoricalOperationCapacity) - settlement, body := brokerTestControlContext(brokerSettlePath, c) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, settlement, body) + settlement, body := brokerTestControlContext(brokerapi.SettlePath, c) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, settlement, body) if err != nil || (status != http.StatusOK && status != http.StatusConflict) { t.Fatal("saturated owner rejected cancellation of an active invocation") } @@ -197,7 +200,7 @@ func TestBrokerOperationCapacityCancelsAcknowledgedInvocation(t *testing.T) { } var firstProof string for i := range 3 { - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 { t.Fatal("repeated cancellation lost its settlement proof") } @@ -208,18 +211,18 @@ func TestBrokerOperationCapacityCancelsAcknowledgedInvocation(t *testing.T) { } extra := settlement extra.OperationID = fmt.Sprintf("extra-cancellation-%d", i) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, extra, body) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, extra, body) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("distinct repeated cancellation consumed retirement capacity") } } - proof := brokerTestControl(t, server.URL, brokerRetirePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || creates != 1 || inferences != 1 || stops != 1 || deletes != 1 { t.Fatal("saturated acknowledged invocation lost containment or retirement ownership") } b.mu.Lock() - session := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + session := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] valid := len(session.Operations) == brokerHistoricalOperationCapacity+2 && brokerLedgerValid(b.ledger, cfg.configDigest) b.mu.Unlock() if !valid { @@ -228,7 +231,7 @@ func TestBrokerOperationCapacityCancelsAcknowledgedInvocation(t *testing.T) { } func TestBrokerOperationCapacityRejectsOversizedLedger(t *testing.T) { - cfg := brokerConfiguration{configDigest: brokerSHA([]byte("bounded-capacity-fixture")), stateDir: filepath.Join(t.TempDir(), "broker")} + cfg := brokerConfiguration{configDigest: foundry.Digest([]byte("bounded-capacity-fixture")), stateDir: filepath.Join(t.TempDir(), "broker")} c := brokerTestContext(cfg) store, ledger, err := openBrokerStore(cfg.stateDir, cfg.configDigest) if err != nil { @@ -236,9 +239,9 @@ func TestBrokerOperationCapacityRejectsOversizedLedger(t *testing.T) { } session := &brokerSession{Owner: c.Owner, CreateState: "none", Prompts: map[string]*brokerPrompt{}, Responses: map[string]brokerResponseID{}, Operations: map[string]string{}} - ledger.Sessions[brokerJSONDigest(c.Owner)] = session + ledger.Sessions[foundry.JSONDigest(c.Owner)] = session for i := range brokerHistoricalOperationCapacity + 2 { - session.Operations[fmt.Sprintf("historical-operation-%d", i)] = brokerSHA([]byte(fmt.Sprintf("operation-%d", i))) + session.Operations[fmt.Sprintf("historical-operation-%d", i)] = foundry.Digest([]byte(fmt.Sprintf("operation-%d", i))) } err = store.save(ledger) store.close() @@ -249,7 +252,7 @@ func TestBrokerOperationCapacityRejectsOversizedLedger(t *testing.T) { if err != nil { t.Fatal("recovery rejected the maximum operation count") } - ledger.Sessions[brokerJSONDigest(c.Owner)].Operations["over-capacity"] = brokerSHA([]byte("extra-operation")) + ledger.Sessions[foundry.JSONDigest(c.Owner)].Operations["over-capacity"] = foundry.Digest([]byte("extra-operation")) err = store.save(ledger) store.close() if err != nil { diff --git a/broker_review_lifecycle_test.go b/internal/broker/review_lifecycle_test.go similarity index 84% rename from broker_review_lifecycle_test.go rename to internal/broker/review_lifecycle_test.go index b34259a..a4b1ba0 100644 --- a/broker_review_lifecycle_test.go +++ b/internal/broker/review_lifecycle_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -10,6 +10,9 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func brokerReviewFaultStore(t *testing.T, dir string) (string, func()) { @@ -60,9 +63,9 @@ func TestBrokerStorageFailureCancelsActiveRequests(t *testing.T) { } retained, restore := brokerReviewFaultStore(t, cfg.stateDir) if trigger != "expiry" { - path, closing := brokerSettlePath, c + path, closing := brokerapi.SettlePath, c if trigger == "other-owner-renewal" { - path = brokerRenewPath + path = brokerapi.RenewPath closing.Owner.RuntimeSessionUID = "fixture-other-session" } closing, body := brokerTestControlContext(path, closing) @@ -98,8 +101,8 @@ func TestBrokerStorageFailureCancelsActiveRequests(t *testing.T) { if active != 0 || !poisoned || creates != 1 || inferences != 1 || stops != 0 || deletes != 0 { t.Fatal("poisoned storage kept active authority or claimed remote cleanup") } - closing, body := brokerTestControlContext(brokerSettlePath, c) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, closing, body) + closing, body := brokerTestControlContext(brokerapi.SettlePath, c) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, closing, body) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("poisoned storage exposed settlement proof") } @@ -107,11 +110,11 @@ func TestBrokerStorageFailureCancelsActiveRequests(t *testing.T) { server.Close() restore() _, restarted := startBrokerTest(t, cfg) - proof := brokerTestControl(t, restarted.URL, brokerSettlePath, c) + proof := brokerTestControl(t, restarted.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.AmbiguousInvocations != 0 { t.Fatal("recovery lost the original acknowledged owner") } - proof = brokerTestControl(t, restarted.URL, brokerRetirePath, c) + proof = brokerTestControl(t, restarted.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes = f.counts() if !proof.RetirementProven || creates != 1 || inferences != 1 || stops == 0 || deletes != 1 { t.Fatal("storage recovery replayed work or skipped acknowledged cleanup") @@ -142,14 +145,14 @@ func TestBrokerRetirementRejectsNewSettlementPrompt(t *testing.T) { })} b, server := startBrokerTestWithClient(t, cfg, client) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusOK { t.Fatal("initial fixture inference failed") } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if state == "retiring" { - retire, body := brokerTestControlContext(brokerRetirePath, c) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRetirePath, retire, body) + retire, body := brokerTestControlContext(brokerapi.RetirePath, c) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RetirePath, retire, body) if err != nil || status != http.StatusConflict { t.Fatal("retirement did not wait for deletion acknowledgement") } @@ -159,7 +162,7 @@ func TestBrokerRetirementRejectsNewSettlementPrompt(t *testing.T) { t.Fatal("retirement did not reach the held deletion") } } else { - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) } before, err := os.ReadFile(filepath.Join(cfg.stateDir, "state.json")) if err != nil { @@ -167,8 +170,8 @@ func TestBrokerRetirementRejectsNewSettlementPrompt(t *testing.T) { } later := c later.TaskUID, later.PromptID = "fixture-task-after-retirement", "fixture-prompt-after-retirement" - later, body := brokerTestControlContext(brokerSettlePath, later) - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerSettlePath, later, body) + later, body := brokerTestControlContext(brokerapi.SettlePath, later) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.SettlePath, later, body) if err != nil || status != http.StatusGone { t.Errorf("new settlement identity was admitted after retirement began: status=%d", status) } @@ -178,21 +181,21 @@ func TestBrokerRetirementRejectsNewSettlementPrompt(t *testing.T) { } b.mu.Lock() valid := brokerLedgerValid(b.ledger, cfg.configDigest) - prompts := len(b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts) + prompts := len(b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts) b.mu.Unlock() if !valid || prompts != 1 { t.Errorf("retirement accepted new prompt or invalidated ledger: valid=%v prompts=%d", valid, prompts) } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven { t.Fatal("existing settlement lost idempotent proof during retirement") } release() - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) b.close() server.Close() _, restarted := startBrokerTest(t, cfg) - proof = brokerTestControl(t, restarted.URL, brokerRetirePath, c) + proof = brokerTestControl(t, restarted.URL, brokerapi.RetirePath, c) if !proof.RetirementProven { t.Fatal("retired ledger did not reopen with the original proof") } @@ -207,7 +210,7 @@ func TestBrokerCancelledResponseRetainsAcceptanceEvidence(t *testing.T) { if shape != "json" { media = "text/event-stream" } - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { response := map[string]any{"id": "provider-cancelled", "status": "cancelled", "agent_session_id": request.AgentSessionID, "output": []any{}} var value any = response if media == "text/event-stream" { @@ -227,18 +230,18 @@ func TestBrokerCancelledResponseRetainsAcceptanceEvidence(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK { t.Fatal("cancelled provider response was exposed as success") } if state := brokerInvocationState(b, c); state != "accepted" && state != "settled" { t.Fatalf("coherent cancellation lost acceptance evidence: state=%s", state) } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) if !proof.SettlementProven || proof.AmbiguousInvocations != 0 { t.Fatal("acknowledged cancellation could not prove stop containment") } - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || creates != 1 || inferences != 1 || stops == 0 || deletes != 1 { t.Fatal("acknowledged cancellation replayed work or skipped cleanup") @@ -250,7 +253,7 @@ func TestBrokerCancelledResponseRetainsAcceptanceEvidence(t *testing.T) { func TestBrokerCancelledSSERequiresCoherentEvidence(t *testing.T) { for _, failure := range []string{"status-mismatch", "response-error", "wrong-session"} { t.Run(failure, func(t *testing.T) { - f := newBrokerEvidenceFixture(t, func(request foundryResponseRequest) (string, []byte) { + f := newBrokerEvidenceFixture(t, func(request foundry.ResponseRequest) (string, []byte) { response := map[string]any{"id": "provider-cancelled", "status": "cancelled", "agent_session_id": request.AgentSessionID, "output": []any{}} switch failure { case "status-mismatch": @@ -266,12 +269,12 @@ func TestBrokerCancelledSSERequiresCoherentEvidence(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status == http.StatusOK || brokerInvocationState(b, c) != "uncertain" { t.Fatal("incoherent cancellation acknowledged an invocation") } - brokerPendingControl(t, server.URL, brokerSettlePath, c, false, 1) - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.SettlePath, c, false, 1) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 1) _, _, _, deletes := f.counts() if deletes != 0 { t.Fatal("incoherent cancellation authorized deletion") diff --git a/broker_main.go b/internal/broker/run.go similarity index 78% rename from broker_main.go rename to internal/broker/run.go index aa5697b..4e1c275 100644 --- a/broker_main.go +++ b/internal/broker/run.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" @@ -13,10 +13,12 @@ import ( "strings" "syscall" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) type brokerConfiguration struct { - agent acpAgentConfiguration + agent foundry.AgentConfig configDigest string addr string stateDir string @@ -24,7 +26,7 @@ type brokerConfiguration struct { operationTimeout time.Duration } -func maybeServeBroker(args []string) (bool, error) { +func MaybeServe(args []string) (bool, error) { selected := false for index, arg := range args { if arg == "--protocol=broker" || (arg == "--protocol" && index+1 < len(args) && args[index+1] == "broker") { @@ -37,19 +39,19 @@ func maybeServeBroker(args []string) (bool, error) { flags := flag.NewFlagSet("foundry-broker", flag.ContinueOnError) flags.SetOutput(io.Discard) protocol := flags.String("protocol", "", "") - path := flags.String("config", acpConfigPath, "") + path := flags.String("config", foundry.AgentConfigPath, "") healthCheck := flags.Bool("health-check", false, "") if flags.Parse(args) != nil || flags.NArg() != 0 || *protocol != "broker" { return true, errBrokerInvalid } if *healthCheck { - return true, checkBrokerHealth(firstNonBlank(os.Getenv("ORKA_FOUNDRY_BROKER_ADDR"), "127.0.0.1:8091")) + return true, checkBrokerHealth(foundry.FirstNonBlank(os.Getenv("ORKA_FOUNDRY_BROKER_ADDR"), "127.0.0.1:8091")) } cfg, err := loadBrokerConfiguration(*path, os.Getenv) if err != nil { return true, err } - provider, err := newAzureFoundryTokenProvider() + provider, err := foundry.NewTokenProvider() if err != nil { return true, errBrokerRemote } @@ -84,22 +86,22 @@ func loadBrokerConfiguration(path string, getenv func(string) string) (brokerCon return brokerConfiguration{}, errBrokerInvalid } defer file.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(file, acpMaxConfigBytes+1)) - if err != nil || len(data) > acpMaxConfigBytes { + data, err := io.ReadAll(io.LimitReader(file, foundry.MaxAgentConfigBytes+1)) + if err != nil || len(data) > foundry.MaxAgentConfigBytes { return brokerConfiguration{}, errBrokerInvalid } - digest := getenv(acpConfigDigestEnv) - agent, err := decodeACPAgentConfiguration(data, digest, getenv(acpModelEnv)) + digest := getenv(foundry.AgentConfigDigestEnv) + agent, err := foundry.DecodeAgentConfig(data, digest, getenv(foundry.ModelEnv)) if err != nil { return brokerConfiguration{}, errBrokerInvalid } cfg := brokerConfiguration{agent: agent, configDigest: digest, - addr: firstNonBlank(getenv("ORKA_FOUNDRY_BROKER_ADDR"), "127.0.0.1:8091"), + addr: foundry.FirstNonBlank(getenv("ORKA_FOUNDRY_BROKER_ADDR"), "127.0.0.1:8091"), stateDir: getenv("ORKA_FOUNDRY_BROKER_STATE_DIR"), bearer: getenv("ORKA_FOUNDRY_BROKER_BEARER_TOKEN"), operationTimeout: 45 * time.Second} if !brokerAddressValid(cfg.addr) || cfg.stateDir == "" || - !acpSafeString(cfg.bearer, 16<<10) || len(cfg.bearer) < 32 || strings.ContainsAny(cfg.bearer, " \t") || - (getenv(envIsolationMode) != "" && getenv(envIsolationMode) != "entra") { + !foundry.SafeString(cfg.bearer, 16<<10) || len(cfg.bearer) < 32 || strings.ContainsAny(cfg.bearer, " \t") || + (getenv(foundry.IsolationModeEnv) != "" && getenv(foundry.IsolationModeEnv) != "entra") { return brokerConfiguration{}, errBrokerInvalid } return cfg, nil diff --git a/broker_schema_case_test.go b/internal/broker/schema_case_test.go similarity index 69% rename from broker_schema_case_test.go rename to internal/broker/schema_case_test.go index 69ffa46..2075122 100644 --- a/broker_schema_case_test.go +++ b/internal/broker/schema_case_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -11,6 +11,9 @@ import ( "path/filepath" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) const brokerCaseToolSchema = `[{"type":"function","name":"hosted-probe-read","parameters":{"type":"object","properties":{},"additionalProperties":false}}]` @@ -21,12 +24,12 @@ func brokerCaseRequest(t *testing.T, b *lifecycleBroker, c brokerContext, member if members != "" { body = append([]byte("{"+members+","), body[1:]...) } - c.BodySHA256 = brokerSHA(body) + c.BodySHA256 = foundry.Digest(body) raw, err := json.Marshal(c) if err != nil { t.Fatal("could not encode fixture context") } - request := httptest.NewRequest(http.MethodPost, brokerResponsesPath, bytes.NewReader(body)) + request := httptest.NewRequest(http.MethodPost, brokerapi.ResponsesPath, bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+brokerFixtureBearer) request.Header.Set(brokerContextHeader, base64.RawURLEncoding.EncodeToString(raw)) response := httptest.NewRecorder() @@ -39,7 +42,7 @@ func brokerCaseRejectBeforeOwnership(t *testing.T, mode, members string) { f := newBrokerFixture(t, "success") cfg := brokerTestConfig(t, f) cfg.agent.ToolSchemaMode = mode - cfg.configDigest = brokerJSONDigest(cfg.agent) + cfg.configDigest = foundry.JSONDigest(cfg.agent) var tokenCalls, transportCalls atomic.Int64 provider := brokerEvidenceTokenProvider(func(context.Context) (string, error) { tokenCalls.Add(1) @@ -90,9 +93,9 @@ func TestBrokerProtectedFieldsRejectDecoderCaseVariantsBeforeOwnership(t *testin modes []string }{ {"tools", []string{`"tools"`, `"Tools"`, `"TOOLS"`, `"tOoLs"`, `"toolſ"`, `"TOOLſ"`, `"\u0054ools"`, `"tool\u017f"`}, - []string{brokerCaseToolSchema, `[]`, `null`}, []string{toolSchemaModeProviderStatic}}, + []string{brokerCaseToolSchema, `[]`, `null`}, []string{foundry.ToolSchemaModeProviderStatic}}, {"agent_session_id", []string{`"agent_session_id"`, `"Agent_Session_ID"`, `"AGENT_SESSION_ID"`, `"aGeNt_sEsSiOn_iD"`, `"agent_ſeſſion_id"`, `"\u0041gent_session_id"`, `"agent_\u017fe\u017f\u017fion_id"`}, - []string{`"fixture-injected-session"`, `""`, `null`}, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, + []string{`"fixture-injected-session"`, `""`, `null`}, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, } for _, field := range fields { for _, mode := range field.modes { @@ -113,18 +116,18 @@ func TestBrokerProtectedFieldsRejectDuplicatePresenceBeforeOwnership(t *testing. members string modes []string }{ - {"tools_folded_null_last", `"Tools":` + brokerCaseToolSchema + `,"TOOLS":null`, []string{toolSchemaModeProviderStatic}}, - {"tools_folded_value_last", `"Tools":null,"TOOLS":` + brokerCaseToolSchema, []string{toolSchemaModeProviderStatic}}, - {"tools_folded_empty_last", `"Tools":` + brokerCaseToolSchema + `,"TOOLſ":[]`, []string{toolSchemaModeProviderStatic}}, - {"tools_canonical_null", `"tools":null,"Tools":` + brokerCaseToolSchema, []string{toolSchemaModeProviderStatic}}, - {"tools_exact_duplicate", `"Tools":null,"Tools":[]`, []string{toolSchemaModeProviderStatic}}, - {"tools_escaped_duplicate", `"Tools":null,"\u0054ools":[]`, []string{toolSchemaModeProviderStatic}}, - {"session_folded_null_last", `"Agent_Session_ID":"fixture-injected-session","AGENT_SESSION_ID":null`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, - {"session_folded_value_last", `"Agent_Session_ID":null,"AGENT_SESSION_ID":"fixture-injected-session"`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, - {"session_folded_empty_last", `"Agent_Session_ID":"fixture-injected-session","agent_ſeſſion_id":""`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, - {"session_canonical_null", `"agent_session_id":null,"Agent_Session_ID":""`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, - {"session_exact_duplicate", `"Agent_Session_ID":null,"Agent_Session_ID":""`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, - {"session_escaped_duplicate", `"Agent_Session_ID":null,"\u0041gent_Session_ID":""`, []string{toolSchemaModeProviderStatic, toolSchemaModeRequest}}, + {"tools_folded_null_last", `"Tools":` + brokerCaseToolSchema + `,"TOOLS":null`, []string{foundry.ToolSchemaModeProviderStatic}}, + {"tools_folded_value_last", `"Tools":null,"TOOLS":` + brokerCaseToolSchema, []string{foundry.ToolSchemaModeProviderStatic}}, + {"tools_folded_empty_last", `"Tools":` + brokerCaseToolSchema + `,"TOOLſ":[]`, []string{foundry.ToolSchemaModeProviderStatic}}, + {"tools_canonical_null", `"tools":null,"Tools":` + brokerCaseToolSchema, []string{foundry.ToolSchemaModeProviderStatic}}, + {"tools_exact_duplicate", `"Tools":null,"Tools":[]`, []string{foundry.ToolSchemaModeProviderStatic}}, + {"tools_escaped_duplicate", `"Tools":null,"\u0054ools":[]`, []string{foundry.ToolSchemaModeProviderStatic}}, + {"session_folded_null_last", `"Agent_Session_ID":"fixture-injected-session","AGENT_SESSION_ID":null`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, + {"session_folded_value_last", `"Agent_Session_ID":null,"AGENT_SESSION_ID":"fixture-injected-session"`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, + {"session_folded_empty_last", `"Agent_Session_ID":"fixture-injected-session","agent_ſeſſion_id":""`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, + {"session_canonical_null", `"agent_session_id":null,"Agent_Session_ID":""`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, + {"session_exact_duplicate", `"Agent_Session_ID":null,"Agent_Session_ID":""`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, + {"session_escaped_duplicate", `"Agent_Session_ID":null,"\u0041gent_Session_ID":""`, []string{foundry.ToolSchemaModeProviderStatic, foundry.ToolSchemaModeRequest}}, } { for _, mode := range tc.modes { t.Run(tc.name+"/"+mode, func(t *testing.T) { @@ -141,23 +144,23 @@ func TestBrokerRequestToolCaseVariantsRemainValid(t *testing.T) { members string tools int }{ - {"static_omitted", toolSchemaModeProviderStatic, "", 0}, - {"request_omitted", toolSchemaModeRequest, "", 0}, - {"canonical", toolSchemaModeRequest, `"tools":` + brokerCaseToolSchema, 1}, - {"title", toolSchemaModeRequest, `"Tools":` + brokerCaseToolSchema, 1}, - {"upper", toolSchemaModeRequest, `"TOOLS":` + brokerCaseToolSchema, 1}, - {"mixed", toolSchemaModeRequest, `"tOoLs":` + brokerCaseToolSchema, 1}, - {"unicode_fold", toolSchemaModeRequest, `"toolſ":` + brokerCaseToolSchema, 1}, - {"escaped", toolSchemaModeRequest, `"\u0054ools":` + brokerCaseToolSchema, 1}, - {"escaped_unicode_fold", toolSchemaModeRequest, `"tool\u017f":` + brokerCaseToolSchema, 1}, - {"empty", toolSchemaModeRequest, `"Tools":[]`, 0}, - {"null", toolSchemaModeRequest, `"TOOLS":null`, 0}, + {"static_omitted", foundry.ToolSchemaModeProviderStatic, "", 0}, + {"request_omitted", foundry.ToolSchemaModeRequest, "", 0}, + {"canonical", foundry.ToolSchemaModeRequest, `"tools":` + brokerCaseToolSchema, 1}, + {"title", foundry.ToolSchemaModeRequest, `"Tools":` + brokerCaseToolSchema, 1}, + {"upper", foundry.ToolSchemaModeRequest, `"TOOLS":` + brokerCaseToolSchema, 1}, + {"mixed", foundry.ToolSchemaModeRequest, `"tOoLs":` + brokerCaseToolSchema, 1}, + {"unicode_fold", foundry.ToolSchemaModeRequest, `"toolſ":` + brokerCaseToolSchema, 1}, + {"escaped", foundry.ToolSchemaModeRequest, `"\u0054ools":` + brokerCaseToolSchema, 1}, + {"escaped_unicode_fold", foundry.ToolSchemaModeRequest, `"tool\u017f":` + brokerCaseToolSchema, 1}, + {"empty", foundry.ToolSchemaModeRequest, `"Tools":[]`, 0}, + {"null", foundry.ToolSchemaModeRequest, `"TOOLS":null`, 0}, } { t.Run(tc.name, func(t *testing.T) { f := newBrokerFixture(t, "success") cfg := brokerTestConfig(t, f) cfg.agent.ToolSchemaMode = tc.mode - cfg.configDigest = brokerJSONDigest(cfg.agent) + cfg.configDigest = foundry.JSONDigest(cfg.agent) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) if response := brokerCaseRequest(t, b, c, tc.members); response.Code != http.StatusOK { @@ -177,8 +180,8 @@ func TestBrokerRequestToolCaseVariantsRemainValid(t *testing.T) { if creates != 1 || inferences != 1 { t.Error("valid request did not submit exactly one owned inference") } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - _ = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) }) } } diff --git a/broker_settlement_test.go b/internal/broker/settlement_test.go similarity index 81% rename from broker_settlement_test.go rename to internal/broker/settlement_test.go index dfcdb7b..5f8018c 100644 --- a/broker_settlement_test.go +++ b/internal/broker/settlement_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -10,6 +10,9 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerCompletedSettlementPreservesRemoteConversation(t *testing.T) { @@ -30,7 +33,7 @@ func TestBrokerCompletedSettlementPreservesRemoteConversation(t *testing.T) { } if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/protocols/openai/responses") { raw, err := io.ReadAll(r.Body) - var request foundryResponseRequest + var request foundry.ResponseRequest if err != nil || json.Unmarshal(raw, &request) != nil { w.WriteHeader(http.StatusBadRequest) return @@ -46,8 +49,8 @@ func TestBrokerCompletedSettlementPreservesRemoteConversation(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) - var first foundryResponse + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) + var first foundry.Response if err != nil || status != http.StatusOK || json.Unmarshal(data, &first) != nil { t.Fatalf("seed response failed: status=%d", status) } @@ -56,17 +59,17 @@ func TestBrokerCompletedSettlementPreservesRemoteConversation(t *testing.T) { server.Close() b, server = startBrokerTest(t, cfg) } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) - if !proof.SettlementProven || proof.ActiveInvocations != 0 || !brokerDigestValid(proof.ProofDigest) { + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + if !proof.SettlementProven || proof.ActiveInvocations != 0 || !foundry.DigestValid(proof.ProofDigest) { t.Fatal("completed request lacked durable settlement") } c.TaskUID, c.PromptID, c.OperationID = "continuation-task", "continuation-prompt", "continuation-request" - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody(first.ID)) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody(first.ID)) if err != nil || status != http.StatusOK { t.Fatalf("completed settlement destroyed remote conversation: status=%d", status) } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven { t.Fatal("completed session retirement lacked deletion proof") } diff --git a/internal/broker/sse_test.go b/internal/broker/sse_test.go new file mode 100644 index 0000000..a3eabab --- /dev/null +++ b/internal/broker/sse_test.go @@ -0,0 +1,7 @@ +package broker + +import "strings" + +func testSSE(events ...string) string { + return "data: " + strings.Join(events, "\n\ndata: ") + "\n\n" +} diff --git a/broker_storage_poison_test.go b/internal/broker/storage_poison_test.go similarity index 89% rename from broker_storage_poison_test.go rename to internal/broker/storage_poison_test.go index 58c46ff..c82c2f2 100644 --- a/broker_storage_poison_test.go +++ b/internal/broker/storage_poison_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -11,6 +11,9 @@ import ( "sync" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerStoragePoisonCancelsDetachedMutations(t *testing.T) { @@ -46,14 +49,14 @@ func TestBrokerStoragePoisonCancelsDetachedMutations(t *testing.T) { done := brokerAsyncInference(context.Background(), server.URL, c, brokerTestBody("")) if phase == "stop" { brokerAwait(t, func() bool { return brokerInvocationState(b, c) == "accepted" }) - b.closePrompt(brokerJSONDigest(c.Owner), c.promptKey()) + b.closePrompt(foundry.JSONDigest(c.Owner), c.promptKey()) _ = brokerWaitInference(t, done) } else if phase == "delete" { result := brokerWaitInference(t, done) if result.err != nil || result.status != http.StatusOK { t.Fatal("original invocation did not complete") } - brokerPendingControl(t, server.URL, brokerRetirePath, c, false, 0) + brokerPendingControl(t, server.URL, brokerapi.RetirePath, c, false, 0) } var mutationCtx context.Context select { @@ -68,8 +71,8 @@ func TestBrokerStoragePoisonCancelsDetachedMutations(t *testing.T) { } other := c other.Owner.RuntimeSessionUID = "other-owner" - other, body := brokerTestControlContext(brokerRenewPath, other) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerRenewPath, other, body) + other, body := brokerTestControlContext(brokerapi.RenewPath, other) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.RenewPath, other, body) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("storage poison was not observed") } diff --git a/broker_store.go b/internal/broker/store.go similarity index 90% rename from broker_store.go rename to internal/broker/store.go index 2fb40b4..a6b6598 100644 --- a/broker_store.go +++ b/internal/broker/store.go @@ -1,4 +1,4 @@ -package main +package broker import ( "encoding/json" @@ -11,6 +11,9 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) const brokerMaxLedgerBytes = 32 << 20 @@ -25,6 +28,7 @@ const brokerMaxLedgerBytes = 32 << 20 // Unbounded distinct cleanup IDs from a lifecycle bearer holder are outside this // caller guarantee; their operation history is still retained and hard-capped. const brokerOwnerReserveBytes = 64 << 10 + const brokerPrincipalReserveBytes = 128 var errBrokerCapacity = errors.New("Foundry broker durable state is at capacity") @@ -97,10 +101,10 @@ type brokerStore struct { } func openBrokerStore(dir, digest string) (*brokerStore, *brokerLedger, error) { - if !filepath.IsAbs(dir) || filepath.Clean(dir) == string(filepath.Separator) || !brokerDigestValid(digest) { + if !filepath.IsAbs(dir) || filepath.Clean(dir) == string(filepath.Separator) || !foundry.DigestValid(digest) { return nil, nil, errBrokerStorage } - lock, created, err := openStoreLock(dir, "broker.lock", syncStoreDirectory) + lock, created, err := durablestore.OpenLock(dir, "broker.lock", durablestore.SyncDirectory) if err != nil { return nil, nil, errBrokerStorage } @@ -119,26 +123,18 @@ func openBrokerStore(dir, digest string) (*brokerStore, *brokerLedger, error) { } return store, ledger, nil } - if err != nil || !brokerPrivateFile(info) || info.Size() > brokerMaxLedgerBytes { + if err != nil || !durablestore.PrivateFile(info) || info.Size() > brokerMaxLedgerBytes { store.close() return nil, nil, errBrokerStorage } data, err := os.ReadFile(path) - if err != nil || acpDecode(data, ledger, true) != nil || !brokerLedgerValid(ledger, digest) { + if err != nil || strictjson.Decode(data, ledger, true) != nil || !brokerLedgerValid(ledger, digest) { store.close() return nil, nil, errBrokerStorage } return store, ledger, nil } -func brokerPrivateFile(info os.FileInfo) bool { - if info == nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { - return false - } - stat, ok := info.Sys().(*syscall.Stat_t) - return ok && int(stat.Uid) == os.Geteuid() && stat.Nlink == 1 -} - func (s *brokerStore) save(ledger *brokerLedger) error { data, err := json.Marshal(ledger) if err != nil { diff --git a/internal/broker/store_initialization_test.go b/internal/broker/store_initialization_test.go new file mode 100644 index 0000000..4b0ab77 --- /dev/null +++ b/internal/broker/store_initialization_test.go @@ -0,0 +1,57 @@ +package broker + +import ( + "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore/storetest" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +func brokerStoreFixture(t *testing.T) storetest.Fixture { + t.Helper() + digest := foundry.Digest([]byte("durable-broker-fixture")) + c := brokerTestContext(brokerConfiguration{configDigest: digest}) + ledger := &brokerLedger{Version: 1, ConfigDigest: digest, Sessions: map[string]*brokerSession{ + foundry.JSONDigest(c.Owner): {Owner: c.Owner, CreateState: "none", Retiring: true, Retired: true, + ProofDigest: foundry.Digest([]byte("retired-owner")), Prompts: map[string]*brokerPrompt{}, + Responses: map[string]brokerResponseID{}, Operations: map[string]string{}}, + }} + if !brokerLedgerValid(ledger, digest) { + t.Fatal("invalid retired broker fixture") + } + return storetest.Fixture{ + LockName: "broker.lock", Digest: foundry.JSONDigest(ledger), + Open: func(dir string) (func(), string, error) { + store, ledger, err := openBrokerStore(dir, digest) + if err != nil { + return nil, "", err + } + return store.close, foundry.JSONDigest(ledger), nil + }, + Save: func(dir string) error { return (&brokerStore{dir: dir}).save(ledger) }, + } +} + +func TestDurableStoreMissingLedgerFailsClosed(t *testing.T) { + storetest.MissingLedgerFailsClosed(t, brokerStoreFixture(t)) +} + +func TestDurableStoreEmptyDirectoryAndLegacyRecovery(t *testing.T) { + storetest.EmptyDirectoryAndLegacyRecovery(t, brokerStoreFixture(t)) +} + +func TestDurableStoreInitializerRace(t *testing.T) { + storetest.InitializerRace(t, brokerStoreFixture(t)) +} + +func TestStoreInitializerKeepsCreatorAcrossFlockContention(t *testing.T) { + storetest.CreatorKeepsLock(t, brokerStoreFixture(t)) +} + +func TestStoreInitializerExistingWriterRemainsNonblocking(t *testing.T) { + storetest.ExistingWriterNonblocking(t, brokerStoreFixture(t)) +} + +func TestStoreInitializerLegacyRecoveryRemainsNonblocking(t *testing.T) { + storetest.LegacyRecoveryNonblocking(t, brokerStoreFixture(t)) +} diff --git a/broker_store_test.go b/internal/broker/store_test.go similarity index 91% rename from broker_store_test.go rename to internal/broker/store_test.go index 442e50a..b0eb670 100644 --- a/broker_store_test.go +++ b/internal/broker/store_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -11,6 +11,9 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerStoreRejectsCorruptOwnership(t *testing.T) { @@ -18,7 +21,7 @@ func TestBrokerStoreRejectsCorruptOwnership(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusOK { t.Fatal("fixture ownership was not created") } @@ -36,7 +39,7 @@ func TestBrokerStoreRejectsCorruptOwnership(t *testing.T) { if json.Unmarshal(baseline, &ledger) != nil { t.Fatal("invalid baseline fixture") } - session := ledger.Sessions[brokerJSONDigest(c.Owner)] + session := ledger.Sessions[foundry.JSONDigest(c.Owner)] prompt := session.Prompts[c.promptKey()] invocation := prompt.Invocations[1] switch kind { @@ -61,7 +64,7 @@ func TestBrokerStoreRejectsCorruptOwnership(t *testing.T) { case "last_alias": prompt.LastAlias = "" case "premature_proof": - prompt.ProofDigest = brokerSHA([]byte("invented")) + prompt.ProofDigest = foundry.Digest([]byte("invented")) case "premature_retirement": session.Retired = true case "missing_principal": @@ -94,7 +97,7 @@ func TestBrokerStoreRejectsCorruptOwnership(t *testing.T) { } func TestBrokerStorePrivatePermissionsAndSingleWriter(t *testing.T) { - digest := brokerSHA([]byte("private-store-test")) + digest := foundry.Digest([]byte("private-store-test")) dir := filepath.Join(t.TempDir(), "broker") store, _, err := openBrokerStore(dir, digest) if err != nil { @@ -133,7 +136,7 @@ func TestBrokerPersistenceFailureClosesAdmissionAndHealth(t *testing.T) { } c := brokerTestContext(cfg) for range 2 { - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusServiceUnavailable { t.Fatal("persistence failure did not close inference admission") } @@ -164,7 +167,7 @@ func TestBrokerHealthCLIRequiresNoConfigurationCredentialOrLock(t *testing.T) { defer server.Close() t.Setenv("ORKA_FOUNDRY_BROKER_ADDR", strings.TrimPrefix(server.URL, "http://")) t.Setenv("ORKA_FOUNDRY_BROKER_STATE_DIR", "/not-used-for-health") - handled, err := maybeServeBroker([]string{"--protocol", "broker", "--health-check", "--config", "/missing.json"}) + handled, err := MaybeServe([]string{"--protocol", "broker", "--health-check", "--config", "/missing.json"}) if !handled || calls.Load() != 1 || (err == nil) != (status == http.StatusOK) { t.Fatal("health command initialized config/auth or misclassified readiness") } @@ -182,14 +185,14 @@ func TestBrokerEmptyRetirementIsDurableAdmissionTombstone(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - proof := brokerTestControl(t, server.URL, brokerRetirePath, c) + proof := brokerTestControl(t, server.URL, brokerapi.RetirePath, c) if !proof.RetirementProven || proof.RemoteSessionCreated { t.Fatal("no-inference retirement lacked proof") } b.close() server.Close() _, server = startBrokerTest(t, cfg) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) if err != nil || status != http.StatusGone { t.Fatal("restarted tombstone admitted delayed inference") } diff --git a/broker_store_validation.go b/internal/broker/store_validation.go similarity index 81% rename from broker_store_validation.go rename to internal/broker/store_validation.go index a3bc5d7..788d5f2 100644 --- a/broker_store_validation.go +++ b/internal/broker/store_validation.go @@ -1,17 +1,18 @@ -package main +package broker import ( "strings" "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) // Recovery never interprets an unknown state or a broken response reference as // quiescence. Content is deliberately absent; these are ownership records only. func brokerLedgerValid(ledger *brokerLedger, digest string) bool { if ledger.Version != 1 || ledger.ConfigDigest != digest || ledger.Sessions == nil || len(ledger.Sessions) > 4096 || - (ledger.PrincipalDigest != "" && !brokerDigestValid(ledger.PrincipalDigest)) { + (ledger.PrincipalDigest != "" && !foundry.DigestValid(ledger.PrincipalDigest)) { return false } for key, session := range ledger.Sessions { @@ -23,7 +24,7 @@ func brokerLedgerValid(ledger *brokerLedger, digest string) bool { } func brokerSessionValid(session *brokerSession, key, digest string) bool { - if session == nil || !session.Owner.valid() || key != brokerJSONDigest(session.Owner) || + if session == nil || !session.Owner.valid() || key != foundry.JSONDigest(session.Owner) || session.Prompts == nil || session.Responses == nil || session.Operations == nil || len(session.Prompts) > 4096 || len(session.Operations) > brokerRetirementOperationLimit { return false @@ -42,7 +43,7 @@ func brokerSessionValid(session *brokerSession, key, digest string) bool { return false } if session.Retired { - if !session.Retiring || (session.CreateState != "none" && session.CreateState != "deleted") || !brokerDigestValid(session.ProofDigest) { + if !session.Retiring || (session.CreateState != "none" && session.CreateState != "deleted") || !foundry.DigestValid(session.ProofDigest) { return false } } else if session.CreateState == "deleted" || session.ProofDigest != "" { @@ -53,7 +54,7 @@ func brokerSessionValid(session *brokerSession, key, digest string) bool { return false } for id, operation := range session.Operations { - if !acpSafeString(id, 512) || !brokerDigestValid(operation) { + if !foundry.SafeString(id, 512) || !foundry.DigestValid(operation) { return false } } @@ -69,9 +70,9 @@ func brokerSessionValid(session *brokerSession, key, digest string) bool { func brokerPromptValid(prompt *brokerPrompt, key, digest string, session *brokerSession, linked map[string]bool) bool { if prompt == nil || prompt.Identity.promptKey() != key || prompt.Identity.Owner != session.Owner || prompt.Identity.Protocol != brokerProtocol || prompt.Identity.AgentConfigurationDigest != digest || - !acpSafeString(prompt.Identity.TaskUID, 512) || prompt.Identity.TaskAttempt == 0 || - !acpSafeString(prompt.Identity.PromptID, 512) || !brokerDigestValid(prompt.Identity.PromptRequestDigest) || - !brokerDigestValid(prompt.Identity.BodySHA256) || session.Operations[prompt.Identity.OperationID] == "" || + !foundry.SafeString(prompt.Identity.TaskUID, 512) || prompt.Identity.TaskAttempt == 0 || + !foundry.SafeString(prompt.Identity.PromptID, 512) || !foundry.DigestValid(prompt.Identity.PromptRequestDigest) || + !foundry.DigestValid(prompt.Identity.BodySHA256) || session.Operations[prompt.Identity.OperationID] == "" || prompt.Invocations == nil || prompt.Identity.LeaseGeneration == 0 || prompt.LeaseGeneration < prompt.Identity.LeaseGeneration { return false } @@ -80,7 +81,7 @@ func brokerPromptValid(prompt *brokerPrompt, key, digest string, session *broker return false } if prompt.Settled { - if !prompt.Closing || !brokerDigestValid(prompt.ProofDigest) { + if !prompt.Closing || !foundry.DigestValid(prompt.ProofDigest) { return false } } else if prompt.ProofDigest != "" || session.Retired || key != session.CurrentPrompt { @@ -106,8 +107,8 @@ func brokerPromptValid(prompt *brokerPrompt, key, digest string, session *broker } func brokerInvocationValid(invocation *brokerInvocation, sequence uint64, promptKey string, prompt *brokerPrompt, session *brokerSession, linked map[string]bool) bool { - if invocation == nil || invocation.Sequence != sequence || sequence == 0 || !brokerDigestValid(invocation.BodyDigest) || - !acpSafeString(invocation.OperationID, 512) || session.Operations[invocation.OperationID] == "" { + if invocation == nil || invocation.Sequence != sequence || sequence == 0 || !foundry.DigestValid(invocation.BodyDigest) || + !foundry.SafeString(invocation.OperationID, 512) || session.Operations[invocation.OperationID] == "" { return false } switch invocation.State { @@ -131,13 +132,13 @@ func brokerInvocationValid(invocation *brokerInvocation, sequence uint64, prompt } link, ok := session.Responses[invocation.ResponseAlias] if !ok || linked[invocation.ResponseAlias] || !brokerAliasValid(invocation.ResponseAlias, "fr_") || - validateProviderIdentifier("response", link.RemoteID) != nil || link.RemoteID != invocation.ResponseID || + foundry.ValidateIdentifier("response", link.RemoteID) != nil || link.RemoteID != invocation.ResponseID || link.PromptKey != promptKey || link.Completed != (invocation.State == "completed") || link.HasFunctions != (len(link.CallIDs) > 0) || (!link.Completed && len(link.CallIDs) != 0) { return false } for alias, remote := range link.CallIDs { - if !brokerAliasValid(alias, "fc_") || validateProviderIdentifier("call", remote) != nil { + if !brokerAliasValid(alias, "fc_") || foundry.ValidateIdentifier("call", remote) != nil { return false } } diff --git a/broker_transport_deadline_test.go b/internal/broker/transport_deadline_test.go similarity index 90% rename from broker_transport_deadline_test.go rename to internal/broker/transport_deadline_test.go index 9d2ce64..1248f2a 100644 --- a/broker_transport_deadline_test.go +++ b/internal/broker/transport_deadline_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bufio" @@ -17,6 +17,9 @@ import ( "testing" "testing/synctest" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) // These tests use the production HTTP transport and real HTTP encoding over @@ -93,7 +96,7 @@ func (f *brokerDeadlineRemote) reply(r *http.Request, body []byte) (int, any, bo return 200, map[string]any{"name": "fixture", "version": "3", "status": "active", "definition": map[string]string{"kind": "hosted"}}, false } if p == "/endpoint/sessions" && r.Method == http.MethodPost { - var request brokerRemoteSession + var request foundry.RemoteSession _ = json.Unmarshal(body, &request) f.mu.Lock() f.creates++ @@ -103,7 +106,7 @@ func (f *brokerDeadlineRemote) reply(r *http.Request, body []byte) (int, any, bo return 201, brokerDeadlineSession(request.ID, "active"), true } if p == "/endpoint/protocols/openai/responses" && r.Method == http.MethodPost { - var request foundryResponseRequest + var request foundry.ResponseRequest _ = json.Unmarshal(body, &request) f.mu.Lock() f.inferences++ @@ -148,7 +151,7 @@ func brokerDeadlineSession(id, status string) map[string]any { } func brokerDeadlineRequest(b *lifecycleBroker, path string, c brokerContext, body []byte) *httptest.ResponseRecorder { - c.BodySHA256 = brokerSHA(body) + c.BodySHA256 = foundry.Digest(body) raw, _ := json.Marshal(c) r := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) r.Header.Set("Authorization", "Bearer "+brokerFixtureBearer) @@ -201,10 +204,10 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { transport := client.Transport.(*http.Transport) transport.DialContext = f.dial transport.DisableKeepAlives = true - agent := acpAgentConfiguration{Model: "fixture-model", ToolSchemaMode: toolSchemaModeProviderStatic, - HostedTarget: acpHostedTarget{ProjectEndpoint: "http://synthetic.invalid/api/projects/fixture", AgentName: "fixture", AgentVersion: "3"}} + agent := foundry.AgentConfig{Model: "fixture-model", ToolSchemaMode: foundry.ToolSchemaModeProviderStatic, + HostedTarget: foundry.HostedTarget{ProjectEndpoint: "http://synthetic.invalid/api/projects/fixture", AgentName: "fixture", AgentVersion: "3"}} raw, _ := json.Marshal(agent) - cfg := brokerConfiguration{agent: agent, configDigest: brokerSHA(raw), stateDir: filepath.Join(t.TempDir(), "broker"), bearer: brokerFixtureBearer, operationTimeout: 45 * time.Second} + cfg := brokerConfiguration{agent: agent, configDigest: foundry.Digest(raw), stateDir: filepath.Join(t.TempDir(), "broker"), bearer: brokerFixtureBearer, operationTimeout: 45 * time.Second} b, err := newLifecycleBroker(context.Background(), cfg, brokerFixtureToken{brokerTestToken()}, client) if err != nil { t.Fatal("synthetic broker did not initialize") @@ -218,7 +221,7 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { c := brokerTestContext(cfg) c.LeaseExpiresAt = started.Add(30 * time.Second).UTC().Format(time.RFC3339Nano) done := make(chan *httptest.ResponseRecorder, 1) - go func() { done <- brokerDeadlineRequest(b, brokerResponsesPath, c, brokerTestBody("")) }() + go func() { done <- brokerDeadlineRequest(b, brokerapi.ResponsesPath, c, brokerTestBody("")) }() for generation := uint64(2); generation <= 3; generation++ { time.Sleep(time.Until(started.Add(time.Duration(generation-1) * 15 * time.Second))) renew := c @@ -226,7 +229,7 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { renew.LeaseExpiresAt = started.Add(time.Duration(generation+1) * 15 * time.Second).UTC().Format(time.RFC3339Nano) renew.OperationID = fmt.Sprintf("synthetic-renew-%d", generation) renew.InvocationSequence = 0 - w := brokerDeadlineRequest(b, brokerRenewPath, renew, []byte("{}")) + w := brokerDeadlineRequest(b, brokerapi.RenewPath, renew, []byte("{}")) var proof brokerControlResponse if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &proof) != nil || proof.State != "open" || proof.LeaseGeneration != generation { t.Fatal("original invocation did not acknowledge exact renewal") @@ -244,12 +247,12 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { brokerAwait(t, func() bool { b.mu.Lock() defer b.mu.Unlock() - return b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[1].State == "uncertain" + return b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()].Invocations[1].State == "uncertain" }) } pending := tc.wantAmbiguity || tc.wantCreatePending if pending { - for _, path := range []string{brokerSettlePath, brokerRetirePath} { + for _, path := range []string{brokerapi.SettlePath, brokerapi.RetirePath} { cc, body := brokerTestControlContext(path, c) w := brokerDeadlineRequest(b, path, cc, body) var proof brokerControlResponse @@ -259,11 +262,11 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { } } } else { - proof := brokerDeadlineControl(t, b, brokerSettlePath, c) + proof := brokerDeadlineControl(t, b, brokerapi.SettlePath, c) if !proof.SettlementProven { t.Fatal("exact synthetic prompt settlement missing") } - proof = brokerDeadlineControl(t, b, brokerRetirePath, c) + proof = brokerDeadlineControl(t, b, brokerapi.RetirePath, c) if !proof.RetirementProven { t.Fatal("known exact owner retirement missing") } @@ -272,7 +275,7 @@ func TestBrokerRemoteHeaderDeadline(t *testing.T) { // delayed HTTP write before inspecting the final evidence. shutdown() b.mu.Lock() - owner := b.ledger.Sessions[brokerJSONDigest(c.Owner)] + owner := b.ledger.Sessions[foundry.JSONDigest(c.Owner)] prompt := owner.Prompts[c.promptKey()] inv := prompt.Invocations[1] if prompt.LeaseGeneration != 3 || !prompt.LeaseExpiresAt.Equal(started.Add(60*time.Second)) || len(prompt.Invocations) != 1 { diff --git a/broker_unsent_dispatch_test.go b/internal/broker/unsent_dispatch_test.go similarity index 99% rename from broker_unsent_dispatch_test.go rename to internal/broker/unsent_dispatch_test.go index e0c5380..3c40849 100644 --- a/broker_unsent_dispatch_test.go +++ b/internal/broker/unsent_dispatch_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "context" diff --git a/broker_zero_invocation_test.go b/internal/broker/zero_invocation_test.go similarity index 78% rename from broker_zero_invocation_test.go rename to internal/broker/zero_invocation_test.go index bb0e477..7e23f9c 100644 --- a/broker_zero_invocation_test.go +++ b/internal/broker/zero_invocation_test.go @@ -1,4 +1,4 @@ -package main +package broker import ( "bytes" @@ -11,6 +11,9 @@ import ( "sync/atomic" "testing" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestBrokerUnsentPromptPreservesRemoteConversation(t *testing.T) { @@ -25,7 +28,7 @@ func TestBrokerUnsentPromptPreservesRemoteConversation(t *testing.T) { } if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/protocols/openai/responses") { raw, err := io.ReadAll(r.Body) - var request foundryResponseRequest + var request foundry.ResponseRequest if err != nil || json.Unmarshal(raw, &request) != nil { w.WriteHeader(http.StatusBadRequest) return @@ -45,33 +48,33 @@ func TestBrokerUnsentPromptPreservesRemoteConversation(t *testing.T) { cfg := brokerTestConfig(t, f) b, server := startBrokerTest(t, cfg) c := brokerTestContext(cfg) - status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody("")) - var first foundryResponse + status, data, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody("")) + var first foundry.Response if err != nil || status != http.StatusOK || json.Unmarshal(data, &first) != nil { t.Fatal("initial fixture response failed") } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) c.TaskUID, c.PromptID, c.OperationID = "unsent-task", "unsent-prompt", "unsent-invocation" if mode == "expiry-before-inference" { c.LeaseExpiresAt = time.Now().Add(500 * time.Millisecond).UTC().Format(time.RFC3339Nano) } - _ = brokerTestControl(t, server.URL, brokerRenewPath, c) + _ = brokerTestControl(t, server.URL, brokerapi.RenewPath, c) switch mode { case "expiry-before-inference": brokerAwait(t, func() bool { b.mu.Lock() defer b.mu.Unlock() - return b.ledger.Sessions[brokerJSONDigest(c.Owner)].Prompts[c.promptKey()].Settled + return b.ledger.Sessions[foundry.JSONDigest(c.Owner)].Prompts[c.promptKey()].Settled }) case "reserved-on-restart": // A crash after durable reservation leaves no live request and // never resumes the reserved invocation on broker restart. - c.BodySHA256 = brokerSHA(brokerTestBody(first.ID)) + c.BodySHA256 = foundry.Digest(brokerTestBody(first.ID)) b.mu.Lock() err := b.commitLocked(func(next *brokerLedger) error { - session := next.Sessions[brokerJSONDigest(c.Owner)] - if _, err := brokerRecordOperation(session, brokerResponsesPath, c); err != nil { + session := next.Sessions[foundry.JSONDigest(c.Owner)] + if _, err := brokerRecordOperation(session, brokerapi.ResponsesPath, c); err != nil { return err } prompt := session.Prompts[c.promptKey()] @@ -91,13 +94,13 @@ func TestBrokerUnsentPromptPreservesRemoteConversation(t *testing.T) { b, server = startBrokerTest(t, cfg) case "rejected": rejectNext.Store(true) - status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody(first.ID)) + status, _, err := brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody(first.ID)) if err != nil || status == http.StatusOK { t.Fatal("definite admission rejection was exposed as a response") } } - proof := brokerTestControl(t, server.URL, brokerSettlePath, c) - if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 || !brokerDigestValid(proof.ProofDigest) { + proof := brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + if !proof.SettlementProven || proof.ActiveInvocations != 0 || proof.AmbiguousInvocations != 0 || !foundry.DigestValid(proof.ProofDigest) { t.Fatal("unsent prompt did not obtain durable settlement") } _, _, stops, _ := f.counts() @@ -106,12 +109,12 @@ func TestBrokerUnsentPromptPreservesRemoteConversation(t *testing.T) { } c.TaskUID, c.PromptID, c.OperationID = "continued-task", "continued-prompt", "continued-invocation" c.LeaseExpiresAt = time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339Nano) - status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerResponsesPath, c, brokerTestBody(first.ID)) + status, _, err = brokerTestHTTP(context.Background(), server.URL, brokerapi.ResponsesPath, c, brokerTestBody(first.ID)) if err != nil || status != http.StatusOK { t.Fatalf("unsent prompt destroyed previous-response continuation: status=%d", status) } - _ = brokerTestControl(t, server.URL, brokerSettlePath, c) - proof = brokerTestControl(t, server.URL, brokerRetirePath, c) + _ = brokerTestControl(t, server.URL, brokerapi.SettlePath, c) + proof = brokerTestControl(t, server.URL, brokerapi.RetirePath, c) creates, inferences, stops, deletes := f.counts() if !proof.RetirementProven || creates != 1 || inferences != 2 || stops != 0 || deletes != 1 { t.Fatal("unsent prompt cleanup replayed work or lost retirement ownership") diff --git a/internal/brokerapi/paths.go b/internal/brokerapi/paths.go new file mode 100644 index 0000000..887879a --- /dev/null +++ b/internal/brokerapi/paths.go @@ -0,0 +1,9 @@ +package brokerapi + +const ( + ResponsesPath = "/v1/responses" + RenewPath = "/internal/v1/renew" + SettlePath = "/internal/v1/settle" + RetirePath = "/internal/v1/retire" + StatusPath = "/internal/v1/status" +) diff --git a/store_directory.go b/internal/durablestore/directory.go similarity index 82% rename from store_directory.go rename to internal/durablestore/directory.go index 0984e88..b333340 100644 --- a/store_directory.go +++ b/internal/durablestore/directory.go @@ -1,4 +1,4 @@ -package main +package durablestore import ( "os" @@ -8,8 +8,8 @@ import ( // The permanent lock is also an initialization witness. Only its exclusive // creator may initialize absent state; existing ledgers do not need a new marker. -func openStoreLock(dir, name string, syncParent func(string) error) (*os.File, bool, error) { - if err := makeStoreDirectory(dir, syncParent); err != nil { +func OpenLock(dir, name string, syncParent func(string) error) (*os.File, bool, error) { + if err := makeDirectory(dir, syncParent); err != nil { return nil, false, err } info, err := os.Lstat(dir) @@ -28,18 +28,18 @@ func openStoreLock(dir, name string, syncParent func(string) error) (*os.File, b if err != nil { return nil, false, err } - if info, err := lock.Stat(); err != nil || !brokerPrivateFile(info) { + if info, err := lock.Stat(); err != nil || !PrivateFile(info) { _ = lock.Close() return nil, false, os.ErrPermission } - if err := flockStoreFile(lock, created); err != nil { + if err := LockFile(lock, created); err != nil { _ = lock.Close() return nil, false, err } return lock, created, nil } -func flockStoreFile(lock *os.File, created bool) error { +func LockFile(lock *os.File, created bool) error { operation := syscall.LOCK_EX | syscall.LOCK_NB if created { _, err := os.Lstat(filepath.Join(filepath.Dir(lock.Name()), "state.json")) @@ -66,7 +66,7 @@ func flockStoreFile(lock *os.File, created bool) error { // records. An existing directory may remain after a failed sync or be created // by a competing initializer, so it needs the same parent barrier. Recursion // stops at the first existing ancestor; every new descendant follows its sync. -func makeStoreDirectory(dir string, syncParent func(string) error) error { +func makeDirectory(dir string, syncParent func(string) error) error { parent := filepath.Dir(dir) info, err := os.Stat(dir) if err == nil { @@ -84,7 +84,7 @@ func makeStoreDirectory(dir string, syncParent func(string) error) error { if parent == dir { return err } - if err := makeStoreDirectory(parent, syncParent); err != nil { + if err := makeDirectory(parent, syncParent); err != nil { return err } if err := os.Mkdir(dir, 0o700); err != nil { @@ -98,7 +98,7 @@ func makeStoreDirectory(dir string, syncParent func(string) error) error { return syncParent(parent) } -func syncStoreDirectory(dir string) error { +func SyncDirectory(dir string) error { file, err := os.Open(dir) if err != nil { return err diff --git a/store_directory_retry_test.go b/internal/durablestore/directory_retry_test.go similarity index 88% rename from store_directory_retry_test.go rename to internal/durablestore/directory_retry_test.go index a149dea..220a581 100644 --- a/store_directory_retry_test.go +++ b/internal/durablestore/directory_retry_test.go @@ -1,4 +1,4 @@ -package main +package durablestore import ( "errors" @@ -23,12 +23,12 @@ func TestStoreDirectoryRetriesFailedParentBarrier(t *testing.T) { failedParent := filepath.Dir(failedPath) failedCalls := 0 for attempt := range 2 { - lock, _, err := openStoreLock(dir, "retry.lock", func(parent string) error { + lock, _, err := OpenLock(dir, "retry.lock", func(parent string) error { if parent == failedParent { failedCalls++ return syscall.EIO } - return syncStoreDirectory(parent) + return SyncDirectory(parent) }) if lock != nil { _ = lock.Close() @@ -48,14 +48,14 @@ func TestStoreDirectoryRetriesFailedParentBarrier(t *testing.T) { } } retried := false - lock, created, err := openStoreLock(dir, "retry.lock", func(parent string) error { + lock, created, err := OpenLock(dir, "retry.lock", func(parent string) error { if parent == failedParent { retried = true } if _, statErr := os.Lstat(filepath.Join(dir, "retry.lock")); !os.IsNotExist(statErr) { t.Error("initialization witness preceded retry durability") } - return syncStoreDirectory(parent) + return SyncDirectory(parent) }) if err != nil || lock == nil || !created || !retried { t.Fatal("successful parent barrier could not resume initialization") @@ -72,7 +72,7 @@ func TestStoreDirectoryCompetingMkdirRequiresParentBarrier(t *testing.T) { parent := filepath.Join(root, "first") dir := filepath.Join(parent, "ledger") competingMkdir, checked := false, false - lock, _, err := openStoreLock(dir, "retry.lock", func(path string) error { + lock, _, err := OpenLock(dir, "retry.lock", func(path string) error { if path == root { // The target was absent at entry. Materialize it while the first // ancestor barrier runs, so its later mkdir takes the EEXIST branch. @@ -85,7 +85,7 @@ func TestStoreDirectoryCompetingMkdirRequiresParentBarrier(t *testing.T) { checked = true return syscall.EIO } - return syncStoreDirectory(path) + return SyncDirectory(path) }) if lock != nil { _ = lock.Close() @@ -97,7 +97,7 @@ func TestStoreDirectoryCompetingMkdirRequiresParentBarrier(t *testing.T) { if _, err := os.Lstat(filepath.Join(dir, "retry.lock")); !os.IsNotExist(err) { t.Fatal("competing mkdir permitted ownership initialization after failed sync") } - lock, created, err := openStoreLock(dir, "retry.lock", syncStoreDirectory) + lock, created, err := OpenLock(dir, "retry.lock", SyncDirectory) if err != nil || lock == nil || !created { t.Fatal("durable retry after competing mkdir failed") } diff --git a/store_directory_test.go b/internal/durablestore/directory_test.go similarity index 88% rename from store_directory_test.go rename to internal/durablestore/directory_test.go index 707977a..e08ba03 100644 --- a/store_directory_test.go +++ b/internal/durablestore/directory_test.go @@ -1,4 +1,4 @@ -package main +package durablestore import ( "errors" @@ -14,12 +14,12 @@ func TestStoreDirectorySyncsNewEntriesBeforeInitialization(t *testing.T) { parents := []string{root, filepath.Join(root, "first"), filepath.Join(root, "first", "second")} dir := filepath.Join(root, "first", "second", "ledger") var synced []string - lock, created, err := openStoreLock(dir, "fixture.lock", func(parent string) error { + lock, created, err := OpenLock(dir, "fixture.lock", func(parent string) error { synced = append(synced, parent) if _, err := os.Lstat(filepath.Join(dir, "fixture.lock")); !os.IsNotExist(err) { t.Error("initialization witness preceded parent durability") } - return syncStoreDirectory(parent) + return SyncDirectory(parent) }) if err != nil { t.Fatal("could not initialize nested private state directory") @@ -37,9 +37,9 @@ func TestStoreDirectorySyncsNewEntriesBeforeInitialization(t *testing.T) { } } synced = nil - lock, created, err = openStoreLock(dir, "fixture.lock", func(parent string) error { + lock, created, err = OpenLock(dir, "fixture.lock", func(parent string) error { synced = append(synced, parent) - return syncStoreDirectory(parent) + return SyncDirectory(parent) }) if err != nil { t.Fatal("existing directory could not recover after parent durability") @@ -66,7 +66,7 @@ func TestStoreDirectoryParentSyncFailurePreventsInitialization(t *testing.T) { failureIndex = i } } - lock, _, err := openStoreLock(dir, "fixture.lock", func(parent string) error { + lock, _, err := OpenLock(dir, "fixture.lock", func(parent string) error { index := calls calls++ if parent != parents[index] { @@ -75,7 +75,7 @@ func TestStoreDirectoryParentSyncFailurePreventsInitialization(t *testing.T) { if index == failureIndex+1 { return syscall.EIO } - return syncStoreDirectory(parent) + return SyncDirectory(parent) }) if lock != nil { _ = lock.Close() diff --git a/internal/durablestore/file.go b/internal/durablestore/file.go new file mode 100644 index 0000000..be09630 --- /dev/null +++ b/internal/durablestore/file.go @@ -0,0 +1,14 @@ +package durablestore + +import ( + "os" + "syscall" +) + +func PrivateFile(info os.FileInfo) bool { + if info == nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && int(stat.Uid) == os.Geteuid() && stat.Nlink == 1 +} diff --git a/internal/durablestore/storetest/initialization.go b/internal/durablestore/storetest/initialization.go new file mode 100644 index 0000000..cc74a9b --- /dev/null +++ b/internal/durablestore/storetest/initialization.go @@ -0,0 +1,358 @@ +package storetest + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore" +) + +type Fixture struct { + LockName string + Digest string + Open func(string) (func(), string, error) + Save func(string) error +} + +func MissingLedgerFailsClosed(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + closeStore, _, err := fixture.Open(dir) + if err != nil { + t.Fatal("could not initialize ownership fixture") + } + err = fixture.Save(dir) + closeStore() + if err != nil { + t.Fatal("could not persist owned lifetime") + } + lockPath := filepath.Join(dir, fixture.LockName) + lockInfo, err := os.Lstat(lockPath) + if err != nil { + t.Fatal("ownership witness is missing") + } + statePath := filepath.Join(dir, "state.json") + retained := filepath.Join(t.TempDir(), "retained-state.json") + if os.Rename(statePath, retained) != nil { + t.Fatal("could not simulate missing ownership state") + } + for range 2 { + closeStore, _, err = fixture.Open(dir) + if err == nil { + closeStore() + t.Fatal("missing ownership ledger was silently reinitialized") + } + if _, err := os.Lstat(statePath); !os.IsNotExist(err) { + t.Fatal("rejected recovery recreated state") + } + after, err := os.Lstat(lockPath) + if err != nil || !os.SameFile(lockInfo, after) { + t.Fatal("rejected recovery replaced its initialization witness") + } + } + if os.Rename(retained, statePath) != nil { + t.Fatal("could not restore exact original ownership") + } + closeStore, restored, err := fixture.Open(dir) + if err != nil { + t.Fatal("valid original ownership could not recover") + } + closeStore() + if restored != fixture.Digest { + t.Fatal("recovery changed the owned lifetime") + } + +} + +func EmptyDirectoryAndLegacyRecovery(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + if os.Mkdir(dir, 0o700) != nil { + t.Fatal("could not create private pre-existing directory") + } + closeStore, _, err := fixture.Open(dir) + if err != nil { + t.Fatal("pre-existing empty directory could not initialize") + } + err = fixture.Save(dir) + closeStore() + if err != nil { + t.Fatal("could not persist owned lifetime") + } + for _, missingLock := range []bool{false, true} { + if missingLock && os.Remove(filepath.Join(dir, fixture.LockName)) != nil { + t.Fatal("could not prepare valid ledger without a lock") + } + closeStore, restored, err := fixture.Open(dir) + if err != nil { + t.Fatal("valid existing ownership could not recover") + } + closeStore() + if restored != fixture.Digest { + t.Fatal("existing ownership was rebound during recovery") + } + } + +} + +func InitializerRace(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + if os.Mkdir(dir, 0o700) != nil { + t.Fatal("could not create private initializer directory") + } + // Pause the original creator after exclusive lock-file creation, + // before flock. A competing opener can acquire flock first, but + // it must not claim the original creator's initialization rights. + witness, err := os.OpenFile(filepath.Join(dir, fixture.LockName), os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) + if err != nil { + t.Fatal("could not pause original initialization") + } + defer witness.Close() + closeStore, _, err := fixture.Open(dir) + if err == nil { + closeStore() + t.Fatal("competing opener initialized another process's store") + } + if _, err := os.Lstat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) { + t.Fatal("competing opener published a ledger") + } + // Resume the original creator using its same descriptor. The + // rejected contender must release flock and preserve that inode. + if syscall.Flock(int(witness.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + t.Fatal("rejected contender retained the initialization lock") + } + if fixture.Save(dir) != nil { + t.Fatal("original creator could not publish its ledger") + } + if syscall.Flock(int(witness.Fd()), syscall.LOCK_UN) != nil { + t.Fatal("original creator could not release the store") + } + closeStore, restored, err := fixture.Open(dir) + if err != nil { + t.Fatal("competing opener could not read the completed original store") + } + closeStore() + if restored != fixture.Digest { + t.Fatal("competing opener replaced original ownership") + } + +} + +func CreatorKeepsLock(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal("could not create private initializer directory") + } + path := filepath.Join(dir, fixture.LockName) + // Pause creator A after its successful O_EXCL, before the exact + // acquisition helper used by openStoreLock. B opens A's same inode. + creator, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) + if err != nil { + t.Fatal("could not create initialization witness") + } + defer creator.Close() + original, err := creator.Stat() + if err != nil { + t.Fatal("could not inspect creator witness") + } + contender, created, err := durablestore.OpenLock(dir, fixture.LockName, durablestore.SyncDirectory) + if err != nil || contender == nil || created { + t.Fatal("contender did not acquire the existing initialization inode") + } + defer contender.Close() + contenderInfo, err := contender.Stat() + if err != nil || !os.SameFile(original, contenderInfo) { + t.Fatal("contender did not lock the creator's exact inode") + } + if _, err := os.Lstat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) { + t.Fatal("contender found unexpected ownership state") + } + started := make(chan struct{}) + acquired := make(chan error, 1) + go func() { + close(started) + acquired <- durablestore.LockFile(creator, true) + }() + <-started + select { + case err := <-acquired: + // This is the original bug: A abandons O_EXCL authority while + // B is still checking the absent ledger. Neither can initialize. + _ = contender.Close() + _ = creator.Close() + for range 2 { + closeStore, _, openErr := fixture.Open(dir) + if openErr == nil { + closeStore() + t.Fatal("a later opener fabricated absent initialization authority") + } + } + if errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal("exclusive creator lost authority to transient flock contention; later openers remain stranded") + } + t.Fatal("creator completed acquisition while a contender still held the inode") + case <-time.After(75 * time.Millisecond): + // B has no O_EXCL authority. Its caller rejects the absent + // ledger and closes; A must keep its descriptor while waiting. + } + if err := contender.Close(); err != nil { + t.Fatal("could not release the rejected contender") + } + select { + case err := <-acquired: + if err != nil { + t.Fatal("original creator could not resume after contender release") + } + case <-time.After(2 * time.Second): + t.Fatal("original creator remained blocked after contender release") + } + after, err := creator.Stat() + pathInfo, pathErr := os.Lstat(path) + if err != nil || pathErr != nil || !os.SameFile(original, after) || !os.SameFile(original, pathInfo) { + t.Fatal("creator replaced or reopened the initialization witness") + } + if err := fixture.Save(dir); err != nil { + t.Fatal("retained creator could not publish original ownership") + } + if err := creator.Close(); err != nil { + t.Fatal("could not close initialized creator") + } + closeStore, restored, err := fixture.Open(dir) + if err != nil { + t.Fatal("completed original initialization could not recover") + } + closeStore() + if restored != fixture.Digest { + t.Fatal("recovery replaced the original ownership ledger") + } + +} + +func ExistingWriterNonblocking(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + closeWriter, _, err := fixture.Open(dir) + if err != nil { + t.Fatal("could not open original writer") + } + defer closeWriter() + if fixture.Save(dir) != nil { + t.Fatal("could not preserve original ownership") + } + finished := make(chan error, 1) + go func() { + other, created, err := durablestore.OpenLock(dir, fixture.LockName, durablestore.SyncDirectory) + if other != nil { + _ = other.Close() + } + if other != nil || created { + finished <- errors.New("existing writer was not excluded") + return + } + finished <- err + }() + select { + case err := <-finished: + if !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal("existing-inode writer did not fail with nonblocking contention") + } + case <-time.After(2 * time.Second): + t.Fatal("existing-inode writer waited for the active owner") + } + closeWriter() + closeRecovered, digest, err := fixture.Open(dir) + if err != nil { + t.Fatal("original ownership did not recover after active writer closed") + } + closeRecovered() + if digest != fixture.Digest { + t.Fatal("excluded writer changed original ownership") + } + +} + +func LegacyRecoveryNonblocking(t *testing.T, fixture Fixture) { + t.Helper() + + dir := filepath.Join(t.TempDir(), "ledger") + if os.Mkdir(dir, 0o700) != nil || fixture.Save(dir) != nil { + t.Fatal("could not prepare valid legacy ownership without a lock") + } + statePath := filepath.Join(dir, "state.json") + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal("could not inspect original legacy ownership") + } + path := filepath.Join(dir, fixture.LockName) + // A creates the missing lock for a valid legacy ledger, then B + // recovers that ledger on the same inode before A calls flock. + creator, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) + if err != nil { + t.Fatal("could not create the legacy recovery lock") + } + defer creator.Close() + original, err := creator.Stat() + if err != nil { + t.Fatal("could not inspect the legacy recovery lock") + } + closeWriter, restored, err := fixture.Open(dir) + if err != nil || restored != fixture.Digest { + if closeWriter != nil { + closeWriter() + } + t.Fatal("contender could not recover the original legacy ledger") + } + defer closeWriter() + contenderInfo, err := os.Lstat(path) + if err != nil || !os.SameFile(original, contenderInfo) { + t.Fatal("contender replaced the creator's lock inode") + } + finished := make(chan error, 1) + go func() { finished <- durablestore.LockFile(creator, true) }() + select { + case err := <-finished: + if !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal("new legacy recovery lock did not fail with nonblocking contention") + } + case <-time.After(2 * time.Second): + // Release B only after proving A waited behind a valid owner. + // Join A so the regression never leaks a blocked syscall. + closeWriter() + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("legacy recovery acquisition did not finish after owner release") + } + t.Fatal("newly created legacy recovery lock waited behind the retained ledger owner") + } + after, err := os.ReadFile(statePath) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("excluded legacy recovery changed original ownership") + } + pathInfo, err := os.Lstat(path) + if err != nil || !os.SameFile(original, pathInfo) { + t.Fatal("excluded legacy recovery replaced the lock inode") + } + closeWriter() + closeRecovered, restored, err := fixture.Open(dir) + if err != nil { + t.Fatal("legacy ownership could not recover after the active owner closed") + } + closeRecovered() + if restored != fixture.Digest { + t.Fatal("legacy recovery changed the owned lifetime") + } + +} diff --git a/auth.go b/internal/foundry/auth.go similarity index 75% rename from auth.go rename to internal/foundry/auth.go index 57dd1b3..2e2791c 100644 --- a/auth.go +++ b/internal/foundry/auth.go @@ -1,4 +1,4 @@ -package main +package foundry import ( "context" @@ -12,23 +12,23 @@ import ( const foundryResourceScope = "https://ai.azure.com/.default" -type foundryTokenProvider interface { +type TokenProvider interface { AccessToken(ctx context.Context) (string, error) } -type azureFoundryTokenProvider struct { +type azureTokenProvider struct { source azcore.TokenCredential } -func newAzureFoundryTokenProvider() (*azureFoundryTokenProvider, error) { +func NewTokenProvider() (*azureTokenProvider, error) { source, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, fmt.Errorf("configure Azure credential: %w", err) } - return &azureFoundryTokenProvider{source: source}, nil + return &azureTokenProvider{source: source}, nil } -func (p *azureFoundryTokenProvider) AccessToken(ctx context.Context) (string, error) { +func (p *azureTokenProvider) AccessToken(ctx context.Context) (string, error) { if p == nil || p.source == nil { return "", fmt.Errorf("azure credential is not configured") } diff --git a/internal/foundry/config.go b/internal/foundry/config.go new file mode 100644 index 0000000..163b0c4 --- /dev/null +++ b/internal/foundry/config.go @@ -0,0 +1,82 @@ +package foundry + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "strings" + "unicode" + + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" +) + +const ( + AgentConfigPath = "/agent/foundry.json" + ModelEnv = "ORKA_FOUNDRY_ACP_MODEL" + AgentConfigDigestEnv = "ORKA_FOUNDRY_ACP_AGENT_CONFIGURATION_DIGEST" + MaxAgentConfigBytes = 64 << 10 + IsolationModeEnv = "ORKA_FOUNDRY_ISOLATION_MODE" + + ToolSchemaModeRequest = "request" + ToolSchemaModeProviderStatic = "provider-static" +) + +var ErrAgentConfig = errors.New("invalid Foundry ACP configuration") + +type AgentConfig struct { + Model string `json:"model"` + ToolSchemaMode string `json:"toolSchemaMode"` + HostedTarget HostedTarget `json:"hostedTarget"` +} + +type HostedTarget struct { + ProjectEndpoint string `json:"projectEndpoint"` + AgentName string `json:"agentName"` + AgentVersion string `json:"agentVersion"` +} + +// Both entry points verify one immutable buffer, while only the privileged +// broker uses HostedTarget. No child proxy credential is needed to parse it. +func DecodeAgentConfig(data []byte, expectedDigest, expectedModel string) (AgentConfig, error) { + actual := sha256.Sum256(data) + encoded := "sha256:" + hex.EncodeToString(actual[:]) + if len(data) > MaxAgentConfigBytes || subtle.ConstantTimeCompare([]byte(expectedDigest), []byte(encoded)) != 1 { + return AgentConfig{}, ErrAgentConfig + } + var agent AgentConfig + if strictjson.Decode(data, &agent, true) != nil || !SafeString(agent.Model, 512) || agent.Model != expectedModel { + return AgentConfig{}, ErrAgentConfig + } + if agent.ToolSchemaMode != ToolSchemaModeRequest && agent.ToolSchemaMode != ToolSchemaModeProviderStatic { + return AgentConfig{}, ErrAgentConfig + } + if strings.TrimSpace(agent.HostedTarget.ProjectEndpoint) != agent.HostedTarget.ProjectEndpoint || + !EndpointIsSafe(agent.HostedTarget.ProjectEndpoint) || + ValidateAgentName(agent.HostedTarget.AgentName) != nil || agent.HostedTarget.AgentVersion == "" || + strings.EqualFold(agent.HostedTarget.AgentVersion, "latest") || ValidateAgentVersion(agent.HostedTarget.AgentVersion) != nil { + return AgentConfig{}, ErrAgentConfig + } + return agent, nil +} + +func SafeString(value string, limit int) bool { + if value == "" || len(value) > limit { + return false + } + for _, ch := range value { + if unicode.IsControl(ch) { + return false + } + } + return true +} + +func FirstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/foundry/digest.go b/internal/foundry/digest.go new file mode 100644 index 0000000..990be1a --- /dev/null +++ b/internal/foundry/digest.go @@ -0,0 +1,30 @@ +package foundry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" +) + +func Digest(data []byte) string { + digest := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func JSONDigest(value any) string { + data, _ := json.Marshal(value) // Only concrete, JSON-safe broker structs are used. + return Digest(data) +} + +func DigestValid(value string) bool { + if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, char := range value[len("sha256:"):] { + if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') { + return false + } + } + return true +} diff --git a/internal/foundry/endpoints.go b/internal/foundry/endpoints.go new file mode 100644 index 0000000..ba05364 --- /dev/null +++ b/internal/foundry/endpoints.go @@ -0,0 +1,54 @@ +package foundry + +import ( + "errors" + "net" + "net/url" + "regexp" + "strings" +) + +var ( + agentNameRE = regexp.MustCompile(`^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$`) + agentVersionRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +) + +func ValidateAgentName(name string) error { + if name == "" { + return errors.New("foundry agent name is required") + } + if len(name) > 63 || !agentNameRE.MatchString(name) { + return errors.New("foundry agent name must be 1-63 alphanumeric or hyphen characters without leading, trailing, or repeated hyphens") + } + return nil +} + +func ValidateAgentVersion(version string) error { + if version == "" { + return nil + } + if strings.HasPrefix(version, "@") || !agentVersionRE.MatchString(version) { + return errors.New("foundry agent version must be a concrete version identifier") + } + return nil +} + +func EndpointIsSafe(raw string) bool { + trimmed := strings.TrimSpace(raw) + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + if parsed.User != nil || parsed.ForceQuery || parsed.RawQuery != "" || strings.Contains(trimmed, "#") { + return false + } + if strings.EqualFold(parsed.Scheme, "https") { + return true + } + if !strings.EqualFold(parsed.Scheme, "http") { + return false + } + host := strings.Trim(strings.ToLower(parsed.Hostname()), "[]") + ip := net.ParseIP(host) + return host == "localhost" || (ip != nil && ip.IsLoopback()) +} diff --git a/internal/foundry/endpoints_test.go b/internal/foundry/endpoints_test.go new file mode 100644 index 0000000..d7c4752 --- /dev/null +++ b/internal/foundry/endpoints_test.go @@ -0,0 +1,47 @@ +package foundry + +import "testing" + +func TestValidateAgentNameAndVersion(t *testing.T) { + for _, name := range []string{"agent", "agent-1", "Agent-1"} { + if err := ValidateAgentName(name); err != nil { + t.Fatalf("validateAgentName(%q): %v", name, err) + } + } + for _, name := range []string{"", "-agent", "agent-", "agent--one", "agent_one", string(make([]byte, 64))} { + if err := ValidateAgentName(name); err == nil { + t.Fatalf("validateAgentName(%q) succeeded", name) + } + } + for _, version := range []string{"", "1", "2026.07.15", "v2-build_1"} { + if err := ValidateAgentVersion(version); err != nil { + t.Fatalf("validateAgentVersion(%q): %v", version, err) + } + } + for _, version := range []string{"@latest", " bad", "bad/version"} { + if err := ValidateAgentVersion(version); err == nil { + t.Fatalf("validateAgentVersion(%q) succeeded", version) + } + } +} + +func TestFoundryEndpointIsSafe(t *testing.T) { + tests := []struct { + endpoint string + want bool + }{ + {endpoint: "https://example.services.ai.azure.com/api/projects/demo", want: true}, + {endpoint: "http://localhost:8080", want: true}, + {endpoint: "http://127.0.0.1:8080", want: true}, + {endpoint: "http://[::1]:8080", want: true}, + {endpoint: "http://example.services.ai.azure.com", want: false}, + {endpoint: "https://user@example.services.ai.azure.com", want: false}, + {endpoint: "https://example.services.ai.azure.com?api-version=v1", want: false}, + {endpoint: "https://example.services.ai.azure.com#fragment", want: false}, + } + for _, test := range tests { + if got := EndpointIsSafe(test.endpoint); got != test.want { + t.Fatalf("foundryEndpointIsSafe(%q) = %v, want %v", test.endpoint, got, test.want) + } + } +} diff --git a/internal/foundry/limits.go b/internal/foundry/limits.go new file mode 100644 index 0000000..6fdc240 --- /dev/null +++ b/internal/foundry/limits.go @@ -0,0 +1,14 @@ +package foundry + +const ( + MaxPromptBytes = 4 << 20 + MaxToolSchemaBytes = 2 << 20 + MaxIdentifierBytes = 4 << 10 + DefaultMaxOutputBytes = 1 << 20 + DefaultMaxStreamBytes = 16 << 20 + DefaultMaxEventBytes = 8 << 20 + DefaultMaxBrokeredBytes = 4 << 20 + DefaultMaxBrokeredTurnBytes = 16 << 20 + DefaultMaxBrokeredCalls = 256 + DefaultMaxEvents = 4096 +) diff --git a/internal/foundry/responses.go b/internal/foundry/responses.go new file mode 100644 index 0000000..0f6e86f --- /dev/null +++ b/internal/foundry/responses.go @@ -0,0 +1,333 @@ +package foundry + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +type ResponseRequest struct { + Input any `json:"input"` + Stream bool `json:"stream"` + Store bool `json:"store"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + AgentSessionID string `json:"agent_session_id,omitempty"` + Tools []ToolSchema `json:"tools,omitempty"` +} + +type ToolSchema struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters"` +} + +type FunctionOutput struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output string `json:"output"` +} + +type ResponseEvent struct { + Type string `json:"type"` + Delta string `json:"delta,omitempty"` + SequenceNumber int64 `json:"sequence_number,omitempty"` + Response *Response `json:"response,omitempty"` + Item *OutputItem `json:"item,omitempty"` + Error *ResponseError `json:"error,omitempty"` +} + +type Response struct { + ID string `json:"id"` + Status string `json:"status"` + AgentSessionID string `json:"agent_session_id,omitempty"` + Output []OutputItem `json:"output,omitempty"` + Error *ResponseError `json:"error,omitempty"` + Incomplete *Incomplete `json:"incomplete_details,omitempty"` +} + +type OutputItem struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Content []OutputContent `json:"content,omitempty"` +} + +type OutputContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` +} + +type ResponseError struct { + Type string `json:"type,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Param string `json:"param,omitempty"` +} + +type Incomplete struct { + Reason string `json:"reason,omitempty"` +} + +type StreamSummary struct { + ResponseID string + AgentSessionID string + Status string + Text string + FunctionCalls []OutputItem + Error *ResponseError + Incomplete *Incomplete +} + +type ResponseCallbacks struct { + OnCreated func(Response) error + OnTextDelta func(string) error + OnFunctionCall func(OutputItem) error +} + +func ParseJSON(r io.Reader, maxBytes int64, callbacks ResponseCallbacks) (StreamSummary, error) { + data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) + if err != nil { + return StreamSummary{}, err + } + if int64(len(data)) > maxBytes { + return StreamSummary{}, errors.New("foundry response exceeded adapter stream limit") + } + var response Response + if err := json.Unmarshal(data, &response); err != nil { + return StreamSummary{}, errors.New("foundry response was invalid JSON") + } + return CompleteResponse(response, callbacks) +} + +func ParseSSE( + r io.Reader, + maxBytes int64, + maxEventBytes int64, + maxEvents int, + callbacks ResponseCallbacks, +) (StreamSummary, error) { + reader := bufio.NewReader(io.LimitReader(r, maxBytes+1)) + var summary StreamSummary + var total int64 + var eventData []byte + var eventCount int + flushEvent := func() error { + if len(eventData) == 0 { + return nil + } + eventCount++ + if eventCount > maxEvents { + return errors.New("foundry response exceeded adapter event limit") + } + if int64(len(eventData)) > maxEventBytes { + return errors.New("foundry response event exceeded adapter limit") + } + if bytes.Equal(bytes.TrimSpace(eventData), []byte("[DONE]")) { + eventData = nil + return nil + } + var event ResponseEvent + if err := json.Unmarshal(eventData, &event); err != nil { + return errors.New("foundry response stream contained invalid JSON") + } + eventData = nil + return applyEvent(&summary, event, callbacks) + } + for { + line, err := reader.ReadBytes('\n') + total += int64(len(line)) + if total > maxBytes { + return StreamSummary{}, errors.New("foundry response exceeded adapter stream limit") + } + trimmed := bytes.TrimRight(line, "\r\n") + if len(trimmed) == 0 { + if err := flushEvent(); err != nil { + return StreamSummary{}, err + } + } else if rawData, ok := bytes.CutPrefix(trimmed, []byte("data:")); ok { + part := bytes.TrimSpace(rawData) + if len(eventData)+len(part)+1 > int(maxEventBytes) { + return StreamSummary{}, errors.New("foundry response event exceeded adapter limit") + } + if len(eventData) > 0 { + eventData = append(eventData, '\n') + } + eventData = append(eventData, part...) + } + if err != nil { + if !errors.Is(err, io.EOF) { + return StreamSummary{}, err + } + if flushErr := flushEvent(); flushErr != nil { + return StreamSummary{}, flushErr + } + break + } + } + if summary.Status == "" { + return StreamSummary{}, errors.New("foundry response stream ended without a terminal event") + } + return summary, nil +} + +func applyEvent(summary *StreamSummary, event ResponseEvent, callbacks ResponseCallbacks) error { + switch event.Type { + case "response.created", "response.in_progress", "response.queued": + if event.Response != nil { + mergeResponse(summary, *event.Response) + if event.Type == "response.created" && callbacks.OnCreated != nil { + return callbacks.OnCreated(*event.Response) + } + } + case "response.output_text.delta": + summary.Text += event.Delta + if callbacks.OnTextDelta != nil && event.Delta != "" { + return callbacks.OnTextDelta(event.Delta) + } + case "response.output_item.done": + if event.Item != nil && event.Item.Type == "function_call" { + summary.FunctionCalls = append(summary.FunctionCalls, *event.Item) + if callbacks.OnFunctionCall != nil { + return callbacks.OnFunctionCall(*event.Item) + } + } + case "response.completed", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + if event.Response != nil { + mergeResponse(summary, *event.Response) + if event.Type == "response.completed" { + if err := applyTerminalOutputFallback(summary, *event.Response, callbacks); err != nil { + return err + } + } + } + if summary.Status == "" { + summary.Status = strings.TrimPrefix(event.Type, "response.") + } + case "error": + summary.Status = "failed" + summary.Error = event.Error + } + return nil +} + +func applyTerminalOutputFallback( + summary *StreamSummary, + response Response, + callbacks ResponseCallbacks, +) error { + var terminalText strings.Builder + for _, item := range response.Output { + if item.Type != "message" { + continue + } + for _, content := range item.Content { + terminalText.WriteString(content.Text) + } + } + fullText := terminalText.String() + if fullText != "" { + if !strings.HasPrefix(fullText, summary.Text) { + return errors.New("foundry terminal output does not match streamed text") + } + remainder := strings.TrimPrefix(fullText, summary.Text) + if remainder != "" { + summary.Text += remainder + if callbacks.OnTextDelta != nil { + if err := callbacks.OnTextDelta(remainder); err != nil { + return err + } + } + } + } + for _, item := range response.Output { + if item.Type != "function_call" { + continue + } + seen, conflict := functionCallState(summary.FunctionCalls, item) + if conflict { + return fmt.Errorf("foundry function call %q changed within one response", item.CallID) + } + if seen { + continue + } + summary.FunctionCalls = append(summary.FunctionCalls, item) + if callbacks.OnFunctionCall != nil { + if err := callbacks.OnFunctionCall(item); err != nil { + return err + } + } + } + return nil +} + +func functionCallState(calls []OutputItem, item OutputItem) (bool, bool) { + for _, call := range calls { + if call.CallID != item.CallID { + continue + } + return true, call.Name != item.Name || !bytes.Equal(call.Arguments, item.Arguments) + } + return false, false +} + +func CompleteResponse(response Response, callbacks ResponseCallbacks) (StreamSummary, error) { + summary := StreamSummary{} + mergeResponse(&summary, response) + if callbacks.OnCreated != nil { + if err := callbacks.OnCreated(response); err != nil { + return StreamSummary{}, err + } + } + if !strings.EqualFold(response.Status, "completed") { + return summary, nil + } + for _, item := range response.Output { + switch item.Type { + case "message": + for _, content := range item.Content { + if content.Text == "" { + continue + } + summary.Text += content.Text + if callbacks.OnTextDelta != nil { + if err := callbacks.OnTextDelta(content.Text); err != nil { + return StreamSummary{}, err + } + } + } + case "function_call": + summary.FunctionCalls = append(summary.FunctionCalls, item) + if callbacks.OnFunctionCall != nil { + if err := callbacks.OnFunctionCall(item); err != nil { + return StreamSummary{}, err + } + } + } + } + return summary, nil +} + +func mergeResponse(summary *StreamSummary, response Response) { + if response.ID != "" { + summary.ResponseID = response.ID + } + if response.AgentSessionID != "" { + summary.AgentSessionID = response.AgentSessionID + } + if response.Status != "" { + summary.Status = response.Status + } + if response.Error != nil { + summary.Error = response.Error + } + if response.Incomplete != nil { + summary.Incomplete = response.Incomplete + } +} diff --git a/internal/foundry/responses_test.go b/internal/foundry/responses_test.go new file mode 100644 index 0000000..ce562b4 --- /dev/null +++ b/internal/foundry/responses_test.go @@ -0,0 +1,116 @@ +package foundry + +import ( + "strings" + "testing" +) + +func TestParseFoundrySSETextAndCompletion(t *testing.T) { + stream := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp-1","status":"in_progress","agent_session_id":"session-1"}}`, + "", + `data: {"type":"response.output_text.delta","delta":"hello "}`, + "", + `data: {"type":"response.output_text.delta","delta":"world"}`, + "", + `data: {"type":"response.completed","response":{"id":"resp-1","status":"completed","agent_session_id":"session-1"}}`, + "", + }, "\n") + var deltas []string + summary, err := ParseSSE(strings.NewReader(stream), 1<<20, 1<<16, 32, ResponseCallbacks{ + OnTextDelta: func(delta string) error { + deltas = append(deltas, delta) + return nil + }, + }) + if err != nil { + t.Fatalf("parseFoundrySSE: %v", err) + } + if summary.ResponseID != "resp-1" || summary.AgentSessionID != "session-1" || summary.Status != "completed" { + t.Fatalf("summary = %#v", summary) + } + if got := strings.Join(deltas, ""); got != "hello world" { + t.Fatalf("deltas = %q", got) + } +} + +func TestParseFoundrySSEFunctionCall(t *testing.T) { + stream := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"lookup\",\"arguments\":\"{\\\"id\\\":\\\"1\\\"}\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}\n\n" + var calls []OutputItem + summary, err := ParseSSE(strings.NewReader(stream), 1<<20, 1<<16, 32, ResponseCallbacks{ + OnFunctionCall: func(call OutputItem) error { + calls = append(calls, call) + return nil + }, + }) + if err != nil { + t.Fatalf("parseFoundrySSE: %v", err) + } + if len(calls) != 1 || calls[0].CallID != "call-1" || calls[0].Name != "lookup" { + t.Fatalf("calls = %#v", calls) + } + if len(summary.FunctionCalls) != 1 { + t.Fatalf("summary calls = %#v", summary.FunctionCalls) + } +} + +func TestParseFoundryJSONFailedAndIncomplete(t *testing.T) { + failed, err := ParseJSON(strings.NewReader(`{"id":"resp-f","status":"failed","error":{"code":"server_error"}}`), 1<<20, ResponseCallbacks{}) + if err != nil { + t.Fatalf("failed parse: %v", err) + } + if failed.Status != "failed" || failed.Error == nil || failed.Error.Code != "server_error" { + t.Fatalf("failed = %#v", failed) + } + incomplete, err := ParseJSON(strings.NewReader(`{"id":"resp-i","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`), 1<<20, ResponseCallbacks{}) + if err != nil { + t.Fatalf("incomplete parse: %v", err) + } + if incomplete.Status != "incomplete" || incomplete.Incomplete == nil || incomplete.Incomplete.Reason != "max_output_tokens" { + t.Fatalf("incomplete = %#v", incomplete) + } +} + +func TestParseFoundrySSERejectsMalformedAndOversizedStreams(t *testing.T) { + if _, err := ParseSSE(strings.NewReader("data: {not-json}\n\n"), 1024, 512, 8, ResponseCallbacks{}); err == nil { + t.Fatal("expected malformed stream error") + } + large := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"" + strings.Repeat("x", 1024) + "\"}\n\n" + if _, err := ParseSSE(strings.NewReader(large), 256, 2048, 8, ResponseCallbacks{}); err == nil { + t.Fatal("expected oversized stream error") + } + if _, err := ParseSSE(strings.NewReader("data: {\"type\":\"response.output_text.delta\",\"delta\":\"x\"}\n\n"), 1024, 512, 8, ResponseCallbacks{}); err == nil { + t.Fatal("expected missing terminal event error") + } +} + +func TestParseFoundrySSETerminalOutputFallback(t *testing.T) { + stream := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp-fallback","status":"in_progress"}}`, + "", + `data: {"type":"response.completed","response":{"id":"resp-fallback","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"fallback text"}]},{"type":"function_call","call_id":"call-fallback","name":"lookup","arguments":"{}"}]}}`, + "", + }, "\n") + var text strings.Builder + var calls []OutputItem + summary, err := ParseSSE(strings.NewReader(stream), 1<<20, 1<<16, 32, ResponseCallbacks{ + OnTextDelta: func(delta string) error { + text.WriteString(delta) + return nil + }, + OnFunctionCall: func(call OutputItem) error { + calls = append(calls, call) + return nil + }, + }) + if err != nil { + t.Fatalf("parseFoundrySSE: %v", err) + } + if text.String() != "fallback text" || summary.Text != "fallback text" { + t.Fatalf("text callback=%q summary=%q", text.String(), summary.Text) + } + if len(calls) != 1 || calls[0].CallID != "call-fallback" { + t.Fatalf("calls = %#v", calls) + } +} diff --git a/internal/foundry/session.go b/internal/foundry/session.go new file mode 100644 index 0000000..8de11ef --- /dev/null +++ b/internal/foundry/session.go @@ -0,0 +1,30 @@ +package foundry + +import ( + "net/http" + "net/url" +) + +type RemoteSession struct { + ID string `json:"agent_session_id"` + Version struct { + Type string `json:"type"` + Version string `json:"agent_version"` + } `json:"version_indicator"` + Status string `json:"status"` +} + +func SessionSuffix(id string) string { return "/endpoint/sessions/" + url.PathEscape(id) } + +func DefiniteRejection(status int) bool { + // A gateway timeout or server error may follow a forwarded request whose + // response was lost. Only explicit admission rejections close this ambiguity. + switch status { + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge, + http.StatusUnsupportedMediaType, http.StatusUnprocessableEntity, http.StatusTooManyRequests: + return true + default: + return false + } +} diff --git a/internal/foundry/strict_responses.go b/internal/foundry/strict_responses.go new file mode 100644 index 0000000..0c17023 --- /dev/null +++ b/internal/foundry/strict_responses.go @@ -0,0 +1,235 @@ +package foundry + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" +) + +var ErrResponse = errors.New("Foundry ACP provider request failed") + +type ModelResponseRequest struct { + ResponseRequest + Model string `json:"model"` +} + +func DecodeResponse(data []byte) (Response, error) { + var response Response + if strictjson.DecodeStruct(data, &response, false) != nil || response.ID == "" || ValidateIdentifier("response", response.ID) != nil || + response.Error != nil || response.Incomplete != nil { + return Response{}, ErrResponse + } + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil { + return Response{}, ErrResponse + } + var rawOutput json.RawMessage + for name, value := range fields { + if strings.EqualFold(name, "output") { + if rawOutput != nil { + return Response{}, ErrResponse + } + rawOutput = value + } + } + var output []json.RawMessage + if rawOutput != nil && json.Unmarshal(rawOutput, &output) != nil { + return Response{}, ErrResponse + } + // Return the validated objects, never a separately decoded typed slice + // whose elements could retain fields across folded output aliases. + response.Output = nil + for _, rawItem := range output { + item, err := decodeOutputItem(rawItem, true) + if err != nil { + return Response{}, err + } + response.Output = append(response.Output, item) + } + return response, nil +} + +func decodeOutputItem(data []byte, done bool) (OutputItem, error) { + var item struct { + OutputItem + Status string `json:"status"` + Role string `json:"role"` + } + if strictjson.DecodeStruct(data, &item, false) != nil || (done && item.Status != "" && item.Status != "completed") { + return OutputItem{}, ErrResponse + } + switch item.Type { + case "message": + if item.Role != "" && item.Role != "assistant" { + return OutputItem{}, ErrResponse + } + for _, content := range item.Content { + if content.Type != "output_text" { + return OutputItem{}, ErrResponse + } + } + case "function_call", "reasoning": + default: + // Hosted/native tools have no authority in the child. Only ordinary + // function calls, later checked against tools/list, may execute. + return OutputItem{}, ErrResponse + } + return item.OutputItem, nil +} + +func ValidateSummary(summary StreamSummary) error { + if summary.Status != "completed" || summary.ResponseID == "" || + ValidateIdentifier("response", summary.ResponseID) != nil || + summary.Error != nil || summary.Incomplete != nil || len(summary.Text) > DefaultMaxOutputBytes || + len(summary.FunctionCalls) > DefaultMaxBrokeredCalls { + return ErrResponse + } + return nil +} + +// Keep the existing Responses types and terminal-output reconciliation, but +// require one explicit, coherent terminal event. A created/in-progress status, +// [DONE] alone, or a complete function-call item does not settle a response. +func ParseStrictSSE(reader io.Reader) (StreamSummary, error) { + limited := &io.LimitedReader{R: reader, N: DefaultMaxStreamBytes + 1} + scanner := bufio.NewScanner(limited) + scanner.Buffer(make([]byte, 32<<10), DefaultMaxEventBytes) + var summary StreamSummary + var data []byte + terminal, done := false, false + events := 0 + pending := map[string]bool{} + apply := func() error { + if len(data) == 0 { + return nil + } + events++ + if events > DefaultMaxEvents || done { + return ErrResponse + } + if bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + if !terminal { + return ErrResponse + } + done = true + return nil + } + if terminal { + return ErrResponse + } + var event ResponseEvent + var rawFields map[string]json.RawMessage + if strictjson.DecodeStruct(data, &event, false) != nil || json.Unmarshal(data, &rawFields) != nil { + return ErrResponse + } + // Match the struct decoder's Unicode field folding without allowing two + // envelope members to validate one value and apply another. Tool argument + // objects keep their case-sensitive keys. + fields := make(map[string]json.RawMessage, 7) + for key, value := range rawFields { + for _, name := range []string{"type", "delta", "sequence_number", "response", "item", "error", "item_id"} { + if strings.EqualFold(key, name) { + if _, exists := fields[name]; exists { + return ErrResponse + } + fields[name] = value + break + } + } + } + if event.Response != nil { + response, err := DecodeResponse(fields["response"]) + if err != nil || (summary.ResponseID != "" && summary.ResponseID != response.ID) { + return ErrResponse + } + event.Response = &response + } + switch event.Type { + case "response.created", "response.in_progress", "response.queued": + if event.Response == nil || (event.Response.Status != "in_progress" && event.Response.Status != "queued") { + return ErrResponse + } + case "response.completed": + if event.Response == nil || event.Response.Status != "completed" { + return ErrResponse + } + for _, item := range event.Response.Output { + if item.Type == "function_call" { + delete(pending, item.ID) + } + } + if len(pending) != 0 { + return ErrResponse + } + terminal = true + case "response.output_item.added", "response.output_item.done": + item, err := decodeOutputItem(fields["item"], event.Type == "response.output_item.done") + if err != nil { + return err + } + event.Item = &item + if item.Type == "function_call" { + if event.Type == "response.output_item.added" { + if !SafeString(item.ID, MaxIdentifierBytes) { + return ErrResponse + } + pending[item.ID] = true + } else { + delete(pending, item.ID) + } + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + var itemID string + if json.Unmarshal(fields["item_id"], &itemID) != nil || !SafeString(itemID, MaxIdentifierBytes) { + return ErrResponse + } + pending[itemID] = true + case "response.output_text.delta": + if len(fields["delta"]) == 0 || fields["delta"][0] != '"' || len(summary.Text)+len(event.Delta) > DefaultMaxOutputBytes { + return ErrResponse + } + case "response.output_text.done", "response.content_part.added", "response.content_part.done", + "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done", + "response.reasoning_summary_part.added", "response.reasoning_summary_part.done", + "response.reasoning_text.delta", "response.reasoning_text.done": + default: + return ErrResponse + } + if err := applyEvent(&summary, event, ResponseCallbacks{}); err != nil || len(summary.Text) > DefaultMaxOutputBytes { + return ErrResponse + } + return nil + } + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + if err := apply(); err != nil { + return StreamSummary{}, err + } + data = nil + continue + } + if part, ok := bytes.CutPrefix(line, []byte("data:")); ok { + part = bytes.TrimPrefix(part, []byte(" ")) + if len(data)+len(part)+1 > DefaultMaxEventBytes { + return StreamSummary{}, ErrResponse + } + if len(data) != 0 { + data = append(data, '\n') + } + data = append(data, part...) + } else if !bytes.HasPrefix(line, []byte(":")) && !strings.HasPrefix(string(line), "event:") && + !strings.HasPrefix(string(line), "id:") && !strings.HasPrefix(string(line), "retry:") { + return StreamSummary{}, ErrResponse + } + } + if scanner.Err() != nil || limited.N <= 0 || len(data) != 0 || !terminal || ValidateSummary(summary) != nil { + return StreamSummary{}, ErrResponse + } + return summary, nil +} diff --git a/internal/foundry/strict_responses_test.go b/internal/foundry/strict_responses_test.go new file mode 100644 index 0000000..2259b86 --- /dev/null +++ b/internal/foundry/strict_responses_test.go @@ -0,0 +1,223 @@ +package foundry + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestACPFoldedResponseNestedFieldsRejected(t *testing.T) { + for name, document := range map[string]string{ + "response identity": `{"id":"wrong","ID":"response-1","status":"completed"}`, + "Session identity": `{"id":"response-1","status":"completed","agent_session_id":"wrong","Agent_Session_ID":"owned"}`, + "item identity": `{"id":"response-1","status":"completed","output":[{"id":"wrong","ID":"item-1","type":"message"}]}`, + "item role": `{"id":"response-1","status":"completed","output":[{"type":"message","role":"user","Role":"assistant","content":[{"type":"output_text","text":"fixture"}]}]}`, + "content type": `{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"refusal","Type":"output_text","text":"fixture"}]}]}`, + "Unicode status": `{"id":"response-1","status":"in_progress","ſtatus":"completed"}`, + "escaped status": `{"id":"response-1","status":"in_progress","\u0053tatus":"completed"}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodeResponse([]byte(document)); err == nil { + t.Error("ambiguous response or nested field accepted") + } + if _, err := ParseStrictSSE(strings.NewReader(testSSE(`{"type":"response.completed","response":` + document + `}`))); err == nil { + t.Error("ambiguous SSE response or nested field accepted") + } + }) + } +} + +func TestACPStructFieldsSingleAliasesPreserveResponseAndArguments(t *testing.T) { + data := []byte(`{"ID":"response-1","ſtatus":"completed","OuTpUt":[{"ID":"item-1","TyPe":"function_call","ſtatus":"completed","Name":"probe","CALL_ID":"call-1","Arguments":{"Key":1,"key":2}}]}`) + response, err := DecodeResponse(data) + if err != nil || response.Status != "completed" || len(response.Output) != 1 || + response.Output[0].Type != "function_call" || !bytes.Equal(response.Output[0].Arguments, []byte(`{"Key":1,"key":2}`)) { + t.Fatal("single response/item aliases changed case-sensitive arguments") + } +} + +func TestACPResponsesRequireExplicitCoherentCompletion(t *testing.T) { + const created = `{"type":"response.created","response":{"id":"response-1","status":"in_progress"}}` + const delta = `{"type":"response.output_text.delta","delta":"héllo"}` + const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"héllo"}]}]}}` + for name, stream := range map[string]string{ + "streamed text": testSSE(created, delta, completed, "[DONE]"), + "terminal fallback": testSSE(completed), + } { + t.Run(name, func(t *testing.T) { + summary, err := ParseStrictSSE(strings.NewReader(stream)) + if err != nil || summary.Text != "héllo" || summary.ResponseID != "response-1" || summary.Status != "completed" { + t.Fatal("valid terminal response rejected") + } + }) + } + for name, stream := range map[string]string{ + "created only": testSSE(created), + "partial text": testSSE(created, delta), + "done without terminal": testSSE(created, delta, "[DONE]"), + "created lies completed": testSSE(`{"type":"response.created","response":{"id":"response-1","status":"completed"}}`), + "error after completed": testSSE(created, delta, completed, `{"type":"error","error":{"message":"test-only-private-detail"}}`), + "duplicate terminal": testSSE(completed, completed), + "missing terminal response": testSSE(created, `{"type":"response.completed"}`), + "wrong terminal status": testSSE(created, `{"type":"response.completed","response":{"id":"response-1","status":"in_progress"}}`), + "terminal error": testSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed","error":{"message":"private"}}}`), + "terminal incomplete": testSSE(`{"type":"response.completed","response":{"id":"response-1","status":"completed","incomplete_details":{"reason":"max_output_tokens"}}}`), + "wrong response identity": testSSE(created, strings.Replace(completed, "response-1", "response-2", 1)), + "changed streamed text": testSSE(created, strings.Replace(delta, "héllo", "wrong", 1), completed), + "native tool event": testSSE(created, `{"type":"response.web_search_call.completed"}`, completed), + "native tool item": testSSE(created, `{"type":"response.output_item.done","item":{"type":"web_search_call","id":"native"}}`, completed), + "duplicate JSON field": testSSE(`{"type":"error","type":"response.completed","response":{"id":"response-1","status":"completed"}}`), + "truncated JSON": testSSE(created, `{"type":"response.completed","response":`), + "truncated event": strings.TrimSuffix(testSSE(completed), "\n"), + "missing delta": testSSE(created, `{"type":"response.output_text.delta"}`, completed), + "null delta": testSSE(created, `{"type":"response.output_text.delta","delta":null}`, completed), + "failed terminal": testSSE(created, `{"type":"response.failed","response":{"id":"response-1","status":"failed"}}`), + "cancelled terminal": testSSE(created, `{"type":"response.cancelled","response":{"id":"response-1","status":"cancelled"}}`), + "oversized output": testSSE(created, `{"type":"response.output_text.delta","delta":"`+strings.Repeat("x", DefaultMaxOutputBytes+1)+`"}`, completed), + } { + t.Run(name, func(t *testing.T) { + if _, err := ParseStrictSSE(strings.NewReader(stream)); err == nil { + t.Fatal("incomplete or malformed provider stream settled successfully") + } + }) + } +} + +func TestACPResponsesWaitForCompleteFunctionCallItems(t *testing.T) { + const added = `{"type":"response.output_item.added","item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"","status":"in_progress"}}` + const delta = `{"type":"response.function_call_arguments.delta","item_id":"item-1","delta":"{"}` + const argsDone = `{"type":"response.function_call_arguments.done","item_id":"item-1","arguments":"{}"}` + const itemDone = `{"type":"response.output_item.done","item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"completed"}}` + const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}` + summary, err := ParseStrictSSE(strings.NewReader(testSSE(added, delta, argsDone, itemDone, completed))) + if err != nil || len(summary.FunctionCalls) != 1 || summary.FunctionCalls[0].CallID != "call-1" { + t.Fatal("complete function call stream rejected") + } + for name, stream := range map[string]string{ + "only added": testSSE(added, completed), + "partial arguments": testSSE(added, delta, completed), + "arguments done without item": testSSE(added, delta, argsDone, completed), + "item done without response terminal": testSSE(itemDone), + "item still incomplete": testSSE(strings.Replace(itemDone, `"status":"completed"`, `"status":"in_progress"`, 1), completed), + "terminal omits pending item": testSSE(added, strings.Replace(itemDone, "item-1", "item-2", 1), completed), + } { + t.Run(name, func(t *testing.T) { + if _, err := ParseStrictSSE(strings.NewReader(stream)); err == nil { + t.Fatal("partial tool-call stream accepted") + } + }) + } +} + +func TestACPResponsesEventAndStreamBounds(t *testing.T) { + for name, stream := range map[string]string{ + "event count": strings.Repeat(testSSE(`{"type":"response.reasoning_text.delta","delta":"x"}`), DefaultMaxEvents+1), + "single event": testSSE(`{"type":"response.reasoning_text.delta","delta":"` + strings.Repeat("x", DefaultMaxEventBytes) + `"}`), + "stream bytes": strings.Repeat(":"+strings.Repeat("x", 1<<20)+"\n", 17), + } { + t.Run(name, func(t *testing.T) { + if _, err := ParseStrictSSE(strings.NewReader(stream)); err == nil { + t.Fatal("unbounded provider stream accepted") + } + }) + } +} + +func TestACPResponsesRejectAmbiguousFoldedEventFields(t *testing.T) { + const response = `{"id":"response-1","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checked"}]}]}` + const changed = `{"id":"response-2","status":"completed","output":[{"type":"message","role":"user","content":[{"type":"output_text","text":"unchecked"}]}]}` + const completed = `{"type":"response.completed","response":{"id":"response-1","status":"completed"}}` + const call = `{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{}","status":"in_progress"}` + for name, stream := range map[string]string{ + "response identity and role": testSSE(`{"type":"response.completed","response":` + response + `,"Response":` + changed + `}`), + "response reverse order": testSSE(`{"type":"response.completed","Response":` + changed + `,"response":` + response + `}`), + "Unicode folded response": testSSE(`{"type":"response.completed","response":` + response + `,"reſponſe":` + changed + `}`), + "escaped folded response": testSSE(`{"type":"response.completed","response":` + response + `,"re\u017fpon\u017fe":` + changed + `}`), + "item skips status validation": testSSE(`{"type":"response.output_item.done","item":{"type":"message"},"Item":`+call+`}`, completed), + "item reverse order": testSSE(`{"type":"response.output_item.done","Item":`+call+`,"item":{"type":"message"}}`, completed), + "delta changes value": testSSE(`{"type":"response.output_text.delta","delta":"checked","Delta":"unchecked"}`, completed), + "type changes terminal": testSSE(`{"type":"error","Type":"response.completed","response":` + response + `}`), + "item identity aliases": testSSE(`{"type":"response.function_call_arguments.done","item_id":"item-1","Item_ID":"other","arguments":"{}"}`, `{"type":"response.output_item.done","item":`+strings.Replace(call, "in_progress", "completed", 1)+`}`, completed), + } { + t.Run(name, func(t *testing.T) { + summary, err := ParseStrictSSE(strings.NewReader(stream)) + if err == nil || summary.ResponseID != "" || summary.Text != "" || len(summary.FunctionCalls) != 0 { + t.Fatal("ambiguous provider event exposed a response or tool call") + } + }) + } +} + +func TestACPResponsesMatchSingleFoldedEnvelopeFields(t *testing.T) { + const response = `{"id":"response-1","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"checked"}]}]}` + for name, stream := range map[string]string{ + "response": testSSE(`{"Type":"response.completed","Reſponſe":` + response + `}`), + "delta": testSSE(`{"type":"response.output_text.delta","Delta":"checked"}`, `{"type":"response.completed","response":`+response+`}`), + } { + t.Run(name, func(t *testing.T) { + summary, err := ParseStrictSSE(strings.NewReader(stream)) + if err != nil || summary.ResponseID != "response-1" || summary.Text != "checked" || len(summary.FunctionCalls) != 0 { + t.Fatal("unambiguous folded event fields changed the response") + } + }) + } + stream := testSSE( + `{"type":"response.function_call_arguments.done","Item_ID":"item-1","arguments":"{\"Key\":1,\"key\":2}"}`, + `{"type":"response.output_item.done","Item":{"id":"item-1","type":"function_call","name":"probe","call_id":"call-1","arguments":"{\"Key\":1,\"key\":2}","status":"completed"}}`, + `{"Type":"response.completed","Response":{"id":"response-1","status":"completed"}}`) + summary, err := ParseStrictSSE(strings.NewReader(stream)) + if err != nil || len(summary.FunctionCalls) != 1 { + t.Fatal("unambiguous folded item fields rejected a complete tool call") + } + var arguments string + if json.Unmarshal(summary.FunctionCalls[0].Arguments, &arguments) != nil || arguments != `{"Key":1,"key":2}` { + t.Fatal("case-sensitive tool argument keys changed") + } +} + +func TestACPResponsesRejectAmbiguousFoldedOutput(t *testing.T) { + const userMessage = `[{"type":"message","role":"user","content":[{"type":"output_text","text":"unchecked"}]}]` + const emptyAssistant = `[{"type":"message","role":"assistant"}]` + const incompleteCall = `[{"id":"item-1","type":"function_call","status":"in_progress","name":"probe","call_id":"call-1","arguments":"{}"}]` + const emptyCall = `[{"type":"function_call"}]` + for name, fields := range map[string]string{ + "message content preserved": `"output":` + userMessage + `,"Output":` + emptyAssistant, + "message reverse casing": `"Output":` + userMessage + `,"output":` + emptyAssistant, + "escaped output alias": `"output":` + userMessage + `,"\u004futput":` + emptyAssistant, + "incomplete call preserved": `"output":` + incompleteCall + `,"Output":` + emptyCall, + "call reverse casing": `"Output":` + incompleteCall + `,"output":` + emptyCall, + } { + t.Run(name, func(t *testing.T) { + response := `{"id":"response-1","status":"completed",` + fields + `}` + document, err := DecodeResponse([]byte(response)) + if err == nil || document.ID != "" || len(document.Output) != 0 { + t.Error("ambiguous response output exposed a decoded document") + } + summary, err := ParseStrictSSE(strings.NewReader(testSSE(`{"type":"response.completed","response":` + response + `}`))) + if err == nil || summary.ResponseID != "" || summary.Text != "" || len(summary.FunctionCalls) != 0 { + t.Error("ambiguous response output exposed a response or tool call") + } + }) + } +} + +func TestACPResponsesSingleFoldedOutputPreservesValidatedItems(t *testing.T) { + const response = `{"id":"response-1","status":"completed","OuTpUt":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"checked"}]},{"id":"item-1","type":"function_call","status":"completed","name":"probe","call_id":"call-1","arguments":"{\"Key\":1,\"key\":2}"}]}` + document, err := DecodeResponse([]byte(response)) + if err != nil || len(document.Output) != 2 || document.Output[0].Type != "message" || document.Output[1].Type != "function_call" { + t.Fatal("single folded output lost validated items") + } + summary, err := ParseStrictSSE(strings.NewReader(testSSE(`{"type":"response.completed","response":` + response + `}`))) + if err != nil || summary.ResponseID != "response-1" || summary.Text != "checked" || len(summary.FunctionCalls) != 1 || summary.FunctionCalls[0].Name != "probe" || summary.FunctionCalls[0].CallID != "call-1" { + t.Fatal("single folded output changed text or function-call identity") + } + var arguments string + if json.Unmarshal(summary.FunctionCalls[0].Arguments, &arguments) != nil || arguments != `{"Key":1,"key":2}` { + t.Fatal("single folded output changed case-sensitive tool arguments") + } +} + +func testSSE(events ...string) string { + return "data: " + strings.Join(events, "\n\ndata: ") + "\n\n" +} diff --git a/internal/foundry/tools.go b/internal/foundry/tools.go new file mode 100644 index 0000000..7ab702f --- /dev/null +++ b/internal/foundry/tools.go @@ -0,0 +1,68 @@ +package foundry + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "unicode" +) + +var toolNameRE = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func ValidateFunctionName(name string) error { + if len(name) == 0 || len(name) > 128 || !toolNameRE.MatchString(name) { + return errors.New("foundry function call name is invalid") + } + return nil +} + +func ValidateCallID(callID string) error { + if callID == "" { + return errors.New("foundry function call omitted call_id") + } + if len([]rune(callID)) > 64 { + return errors.New("foundry function call id exceeds 64 characters") + } + for _, char := range callID { + if unicode.IsControl(char) { + return errors.New("foundry function call id contains control characters") + } + } + return nil +} + +func NormalizeToolArguments(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return json.RawMessage(`{}`), nil + } + var encoded string + if err := json.Unmarshal(raw, &encoded); err == nil { + encoded = strings.TrimSpace(encoded) + if encoded != "" && json.Valid([]byte(encoded)) { + return json.RawMessage(encoded), nil + } + return nil, errors.New("foundry tool arguments are not valid JSON") + } + if json.Valid(raw) { + return slices.Clone(raw), nil + } + return nil, errors.New("foundry tool arguments are not valid JSON") +} + +func ValidateIdentifier(label, value string) error { + if value == "" { + return nil + } + if len(value) > MaxIdentifierBytes { + return fmt.Errorf("foundry %s exceeds adapter limit", label) + } + for _, char := range value { + if unicode.IsSpace(char) || unicode.IsControl(char) { + return fmt.Errorf("foundry %s contains unsafe characters", label) + } + } + return nil +} diff --git a/internal/foundry/tools_test.go b/internal/foundry/tools_test.go new file mode 100644 index 0000000..f9f05cc --- /dev/null +++ b/internal/foundry/tools_test.go @@ -0,0 +1,15 @@ +package foundry + +import ( + "strings" + "testing" +) + +func TestValidateProviderIdentifierBoundsAndRejectsWhitespace(t *testing.T) { + if err := ValidateIdentifier("response id", strings.Repeat("x", MaxIdentifierBytes+1)); err == nil { + t.Fatal("oversized provider identifier was accepted") + } + if err := ValidateIdentifier("response id", "response id"); err == nil { + t.Fatal("provider identifier with whitespace was accepted") + } +} diff --git a/hosted_boundary_test.go b/internal/hosted/boundary_test.go similarity index 93% rename from hosted_boundary_test.go rename to internal/hosted/boundary_test.go index e12189d..965c266 100644 --- a/hosted_boundary_test.go +++ b/internal/hosted/boundary_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bufio" @@ -23,6 +23,9 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func hostedBoundaryLedgerFixture(t *testing.T) (hostedGatewayConfig, hostedGatewayLedger) { @@ -30,14 +33,14 @@ func hostedBoundaryLedgerFixture(t *testing.T) (hostedGatewayConfig, hostedGatew f := newHostedProtocolFixture(t) cfg := hostedGatewayConfig{ Protocol: hostedProtocol, Image: f.config, - ContainerImage: "example.invalid/hosted@" + brokerSHA([]byte("fixture image")), - SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: brokerSHA([]byte("fixture profile")), + ContainerImage: "example.invalid/hosted@" + foundry.Digest([]byte("fixture image")), + SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: foundry.Digest([]byte("fixture profile")), RuntimeEnvironment: maps.Clone(f.bootstrap.Environment), OrkaBaseURL: "http://orka.test:8080", BrokerBaseURL: "http://127.0.0.1:8091", } ledger := hostedGatewayLedger{ - Version: 1, ConfigDigest: brokerJSONDigest(cfg), SessionID: cfg.SessionID, - PrincipalDigest: brokerSHA([]byte("fixture principal")), CreateAttempted: true, SessionCreated: true, + Version: 1, ConfigDigest: foundry.JSONDigest(cfg), SessionID: cfg.SessionID, + PrincipalDigest: foundry.Digest([]byte("fixture principal")), CreateAttempted: true, SessionCreated: true, ExposurePossible: true, Challenge: f.hello.Challenge, PairID: f.hello.PairID, BootstrapDigest: f.hello.BootstrapDigest, } @@ -60,7 +63,7 @@ func TestHostedBoundaryLedgerRejectsMixedAndMalformedExposure(t *testing.T) { cfg, baseline := hostedBoundaryLedgerFixture(t) for name, mutate := range map[string]func(*hostedGatewayLedger){ "version": func(v *hostedGatewayLedger) { v.Version++ }, - "config digest": func(v *hostedGatewayLedger) { v.ConfigDigest = brokerSHA([]byte("other config")) }, + "config digest": func(v *hostedGatewayLedger) { v.ConfigDigest = foundry.Digest([]byte("other config")) }, "logical session": func(v *hostedGatewayLedger) { v.SessionID = uuid.NewString() }, "missing principal": func(v *hostedGatewayLedger) { v.PrincipalDigest = "" }, "malformed principal": func(v *hostedGatewayLedger) { v.PrincipalDigest = "invalid" }, @@ -78,7 +81,7 @@ func TestHostedBoundaryLedgerRejectsMixedAndMalformedExposure(t *testing.T) { "challenge protocol": func(v *hostedGatewayLedger) { v.Challenge.Protocol = "other" }, "challenge deployment": func(v *hostedGatewayLedger) { v.Challenge.DeploymentID = uuid.NewString() }, "challenge config": func(v *hostedGatewayLedger) { - v.Challenge.ConfigurationDigest = brokerSHA([]byte("other image config")) + v.Challenge.ConfigurationDigest = foundry.Digest([]byte("other image config")) }, "challenge agent": func(v *hostedGatewayLedger) { v.Challenge.AgentName = "other-agent" }, "challenge version": func(v *hostedGatewayLedger) { v.Challenge.AgentVersion = "9" }, @@ -155,9 +158,9 @@ func TestHostedBoundaryLedgerRejectsMalformedEncodingAndConfigChanges(t *testing } for name, mutate := range map[string]func(*hostedGatewayConfig){ "session": func(c *hostedGatewayConfig) { c.SessionID = uuid.NewString() }, - "profile": func(c *hostedGatewayConfig) { c.RuntimeProfileDigest = brokerSHA([]byte("other profile")) }, + "profile": func(c *hostedGatewayConfig) { c.RuntimeProfileDigest = foundry.Digest([]byte("other profile")) }, "image": func(c *hostedGatewayConfig) { - c.ContainerImage = "example.invalid/hosted@" + brokerSHA([]byte("other image")) + c.ContainerImage = "example.invalid/hosted@" + foundry.Digest([]byte("other image")) }, "target": func(c *hostedGatewayConfig) { c.Image.Target.AgentVersion = "9" }, "destination": func(c *hostedGatewayConfig) { c.OrkaBaseURL = "http://other.test:8080" }, @@ -210,7 +213,7 @@ func TestHostedBoundaryLedgerPersistsExposureWithExclusivePrivateOwnership(t *te } for _, name := range []string{"state.json", "gateway.lock"} { info, err := os.Lstat(filepath.Join(dir, name)) - if err != nil || !brokerPrivateFile(info) { + if err != nil || !durablestore.PrivateFile(info) { t.Fatal("gateway ownership file is not private, singly linked and locally owned") } } @@ -325,14 +328,14 @@ func TestHostedBoundaryProxyRoutes(t *testing.T) { {"v1 denied", http.MethodPost, "/v1/sessions", hostedV2Route, false}, {"v2 lookalike", http.MethodPost, "/v20/sessions", hostedV2Route, false}, {"v2 unsupported method", http.MethodTrace, "/v2/health", hostedV2Route, false}, - {"broker responses", http.MethodPost, brokerResponsesPath, hostedBrokerRoute, true}, - {"broker renew", http.MethodPost, brokerRenewPath, hostedBrokerRoute, true}, - {"broker settle", http.MethodPost, brokerSettlePath, hostedBrokerRoute, true}, - {"broker retire", http.MethodPost, brokerRetirePath, hostedBrokerRoute, true}, - {"broker status GET", http.MethodGet, brokerStatusPath, hostedBrokerRoute, true}, - {"broker status POST denied", http.MethodPost, brokerStatusPath, hostedBrokerRoute, false}, - {"broker response GET denied", http.MethodGet, brokerResponsesPath, hostedBrokerRoute, false}, - {"broker query denied", http.MethodPost, brokerResponsesPath + "?target=other", hostedBrokerRoute, false}, + {"broker responses", http.MethodPost, brokerapi.ResponsesPath, hostedBrokerRoute, true}, + {"broker renew", http.MethodPost, brokerapi.RenewPath, hostedBrokerRoute, true}, + {"broker settle", http.MethodPost, brokerapi.SettlePath, hostedBrokerRoute, true}, + {"broker retire", http.MethodPost, brokerapi.RetirePath, hostedBrokerRoute, true}, + {"broker status GET", http.MethodGet, brokerapi.StatusPath, hostedBrokerRoute, true}, + {"broker status POST denied", http.MethodPost, brokerapi.StatusPath, hostedBrokerRoute, false}, + {"broker response GET denied", http.MethodGet, brokerapi.ResponsesPath, hostedBrokerRoute, false}, + {"broker query denied", http.MethodPost, brokerapi.ResponsesPath + "?target=other", hostedBrokerRoute, false}, {"broker arbitrary path denied", http.MethodPost, "/v1/other", hostedBrokerRoute, false}, {"Orka tool", http.MethodPost, "/internal/v2/acp/mcp/tools/call", hostedOrkaRoute, true}, {"Orka artifact authorization", http.MethodPost, "/internal/v2/acp/artifact-authorizations", hostedOrkaRoute, true}, @@ -405,7 +408,7 @@ func TestHostedBoundaryProxyPinsDestinationAndPreservesOnlySuppliedAuthorization name, path string allow func(*http.Request) bool }{ - {"broker", brokerRenewPath, hostedBrokerRoute}, + {"broker", brokerapi.RenewPath, hostedBrokerRoute}, {"Orka", "/internal/v2/acp/mcp/tools/call", hostedOrkaRoute}, {"supervisor", "/v2/sessions", hostedV2Route}, } { diff --git a/hosted_config.go b/internal/hosted/config.go similarity index 91% rename from hosted_config.go rename to internal/hosted/config.go index f953ade..d604b59 100644 --- a/hosted_config.go +++ b/internal/hosted/config.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "crypto/ed25519" @@ -10,6 +10,9 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) const ( @@ -44,7 +47,7 @@ type hostedGatewaySettings struct { func readHostedConfig(path string, target any) error { data, err := readHostedFile(path, hostedMaxHandshakeBytes) - if err != nil || acpDecode(data, target, true) != nil { + if err != nil || strictjson.Decode(data, target, true) != nil { return errHostedInvalid } return nil @@ -84,7 +87,7 @@ func loadHostedGatewaySettings(path string, getenv func(string) string) (hostedG return settings, errHostedInvalid } settings.stateDir = getenv("ORKA_FOUNDRY_GATEWAY_STATE_DIR") - settings.address = firstNonBlank(getenv("ORKA_FOUNDRY_GATEWAY_ADDR"), ":8080") + settings.address = foundry.FirstNonBlank(getenv("ORKA_FOUNDRY_GATEWAY_ADDR"), ":8080") if !filepath.IsAbs(settings.stateDir) || filepath.Clean(settings.stateDir) == "/" || !hostedListenAddressValid(settings.address) { return settings, errHostedInvalid } @@ -132,8 +135,8 @@ func loadHostedGatewaySettings(path string, getenv func(string) string) (hostedG func validateHostedGatewayConfig(cfg hostedGatewayConfig) error { imageName, digest, pinned := strings.Cut(cfg.ContainerImage, "@") if cfg.Protocol != hostedProtocol || validateHostedImageConfig(cfg.Image) != nil || - !hostedUUIDValid(cfg.SessionID) || !brokerDigestValid(cfg.RuntimeProfileDigest) || - !pinned || !brokerDigestValid(digest) || !acpSafeString(imageName, 512) || + !hostedUUIDValid(cfg.SessionID) || !foundry.DigestValid(cfg.RuntimeProfileDigest) || + !pinned || !foundry.DigestValid(digest) || !foundry.SafeString(imageName, 512) || strings.ContainsAny(imageName, " @\\?#") { return errHostedInvalid } @@ -152,7 +155,7 @@ func validateHostedGatewayConfig(cfg hostedGatewayConfig) error { func hostedRelayTargetValid(raw string, loopback bool) bool { u, err := url.Parse(raw) - if err != nil || !acpSafeString(raw, 2048) || (u.Scheme != "http" && u.Scheme != "https") || + if err != nil || !foundry.SafeString(raw, 2048) || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(raw, "#") { return false diff --git a/hosted_create_rejection_test.go b/internal/hosted/create_rejection_test.go similarity index 97% rename from hosted_create_rejection_test.go rename to internal/hosted/create_rejection_test.go index d8de3a5..067ca8a 100644 --- a/hosted_create_rejection_test.go +++ b/internal/hosted/create_rejection_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -49,7 +49,8 @@ func TestHostedRejectedCreateAllowsOneLaterStartup(t *testing.T) { type hostedIncompleteRejectionBody struct{} func (hostedIncompleteRejectionBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } -func (hostedIncompleteRejectionBody) Close() error { return nil } + +func (hostedIncompleteRejectionBody) Close() error { return nil } func TestHostedAmbiguousCreateCannotClearIntent(t *testing.T) { for _, failure := range []string{"conflict", "server-error", "incomplete-rejection", "rollback-storage"} { diff --git a/hosted_gateway.go b/internal/hosted/gateway.go similarity index 94% rename from hosted_gateway.go rename to internal/hosted/gateway.go index e47e812..c3bd593 100644 --- a/hosted_gateway.go +++ b/internal/hosted/gateway.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -13,13 +13,15 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) type hostedWebSocketDial func(context.Context, string, http.Header) (*websocket.Conn, *http.Response, error) type hostedGateway struct { cfg hostedGatewayConfig - provider foundryTokenProvider + provider foundry.TokenProvider httpClient *http.Client dial hostedWebSocketDial store *hostedGatewayStore @@ -35,7 +37,7 @@ type hostedGateway struct { channelLog *log.Logger } -func newHostedGateway(ctx context.Context, settings hostedGatewaySettings, provider foundryTokenProvider) (*hostedGateway, error) { +func newHostedGateway(ctx context.Context, settings hostedGatewaySettings, provider foundry.TokenProvider) (*hostedGateway, error) { defer clear(settings.signingKey) if validateHostedGatewayConfig(settings.config) != nil || validateHostedBootstrap(settings.config.Image, settings.bootstrap) != nil || provider == nil { return nil, errHostedInvalid @@ -92,7 +94,7 @@ func (g *hostedGateway) initialize(settings hostedGatewaySettings) error { if otherChallenge != challenge { return errHostedInvalid } - bootstrapDigest, pairID := brokerSHA(body), uuid.NewString() + bootstrapDigest, pairID := foundry.Digest(body), uuid.NewString() for _, channel := range []struct { ws *websocket.Conn role string @@ -201,7 +203,7 @@ func (g *hostedGateway) validateCapabilities(ctx context.Context, transport http AdapterDigests map[string]string `json:"adapterDigests"` } if err != nil || len(data) > hostedMaxHandshakeBytes || response.StatusCode != http.StatusOK || - acpDecode(data, &value, false) != nil || value.Protocol != "orka.harness.v2" || value.Transport != "http+ndjson" || + strictjson.Decode(data, &value, false) != nil || value.Protocol != "orka.harness.v2" || value.Transport != "http+ndjson" || value.RuntimeProfileDigest != g.cfg.RuntimeProfileDigest || value.AdapterDigests["foundry-serve-acp"] != g.cfg.RuntimeEnvironment["ORKA_ACP_FOUNDRY_ADAPTER_DIGEST"] { return errHostedInvalid diff --git a/hosted_gateway_test.go b/internal/hosted/gateway_test.go similarity index 95% rename from hosted_gateway_test.go rename to internal/hosted/gateway_test.go index e5eee3d..d7615a2 100644 --- a/hosted_gateway_test.go +++ b/internal/hosted/gateway_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bufio" @@ -24,6 +24,9 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" "golang.org/x/net/http2" ) @@ -117,8 +120,8 @@ func newHostedGatewayTestFixture(t *testing.T, options hostedGatewayTestOptions) f.settings = hostedGatewaySettings{ config: hostedGatewayConfig{ Protocol: hostedProtocol, Image: p.config, - ContainerImage: "example.invalid/hosted@" + brokerSHA([]byte("gateway fixture image")), - SessionID: p.hello.Challenge.SessionID, RuntimeProfileDigest: brokerSHA([]byte("gateway fixture profile")), + ContainerImage: "example.invalid/hosted@" + foundry.Digest([]byte("gateway fixture image")), + SessionID: p.hello.Challenge.SessionID, RuntimeProfileDigest: foundry.Digest([]byte("gateway fixture profile")), RuntimeEnvironment: maps.Clone(p.bootstrap.Environment), OrkaBaseURL: f.server.URL, BrokerBaseURL: f.server.URL, }, @@ -199,7 +202,7 @@ func hostedGatewayTestJWT(claims map[string]any) string { } func (f *hostedGatewayTestFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == brokerStatusPath || r.URL.Path == "/internal/v2/acp/mcp/tools/call" { + if r.URL.Path == brokerapi.StatusPath || r.URL.Path == "/internal/v2/acp/mcp/tools/call" { f.callbacks.Add(1) if r.Header.Get("Authorization") != "Bearer fixture-operation-authorization" { f.t.Error("reverse channel changed caller authorization") @@ -241,7 +244,7 @@ func (f *hostedGatewayTestFixture) serveHTTP(w http.ResponseWriter, r *http.Requ "definition": map[string]any{"kind": "hosted", "container_configuration": map[string]any{"image": f.settings.config.ContainerImage}, "protocol_versions": []any{map[string]any{"protocol": "invocations_ws", "version": "2.0.0"}}}} - case r.Method == http.MethodGet && r.URL.Path == base+brokerSessionSuffix(f.settings.config.SessionID): + case r.Method == http.MethodGet && r.URL.Path == base+foundry.SessionSuffix(f.settings.config.SessionID): stage, body = "session-get", f.sessionBody() if !exists { status, body = http.StatusNotFound, map[string]any{} @@ -249,8 +252,8 @@ func (f *hostedGatewayTestFixture) serveHTTP(w http.ResponseWriter, r *http.Requ case r.Method == http.MethodPost && r.URL.Path == base+"/endpoint/sessions": f.creates.Add(1) data, err := io.ReadAll(io.LimitReader(r.Body, hostedMaxHandshakeBytes+1)) - var request brokerRemoteSession - if err != nil || acpDecode(data, &request, true) != nil || + var request foundry.RemoteSession + if err != nil || strictjson.Decode(data, &request, true) != nil || request.ID != f.settings.config.SessionID || request.Version.Type != "version_ref" || request.Version.Version != target.AgentVersion { f.t.Error("session create omitted the exact session or concrete version") http.Error(w, "fixture create rejected", http.StatusBadRequest) @@ -300,7 +303,7 @@ func (f *hostedGatewayTestFixture) serveWebSocket(w http.ResponseWriter, r *http _ = ws.SetWriteDeadline(time.Now().Add(5 * time.Second)) challenge := hostedChallenge{ Protocol: hostedProtocol, DeploymentID: f.settings.config.Image.DeploymentID, - ConfigurationDigest: brokerJSONDigest(f.settings.config.Image), + ConfigurationDigest: foundry.JSONDigest(f.settings.config.Image), AgentName: f.settings.config.Image.Target.AgentName, AgentVersion: f.settings.config.Image.Target.AgentVersion, SessionID: f.settings.config.SessionID, BootID: f.settings.config.Image.DeploymentID, Nonce: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{11}, 32)), @@ -364,8 +367,8 @@ func (f *hostedGatewayTestFixture) serveWebSocket(w http.ResponseWriter, r *http } f.bootstraps.Add(1) var bootstrap hostedBootstrap - valid := kind == websocket.TextMessage && brokerSHA(data) == hello.BootstrapDigest && - acpDecode(data, &bootstrap, true) == nil && reflect.DeepEqual(bootstrap, f.settings.bootstrap) + valid := kind == websocket.TextMessage && foundry.Digest(data) == hello.BootstrapDigest && + strictjson.Decode(data, &bootstrap, true) == nil && reflect.DeepEqual(bootstrap, f.settings.bootstrap) clear(data) if !valid { f.t.Error("gateway changed the signed bootstrap body") @@ -406,7 +409,7 @@ func (f *hostedGatewayTestFixture) serveSupervisor(w http.ResponseWriter, r *htt func (f *hostedGatewayTestFixture) observeBootstrapWrite() { data, err := os.ReadFile(filepath.Join(f.settings.stateDir, "state.json")) var observed hostedGatewayBootstrapWrite - observed.persisted = err == nil && acpDecode(data, &observed.ledger, true) == nil && + observed.persisted = err == nil && strictjson.Decode(data, &observed.ledger, true) == nil && hostedGatewayLedgerValid(observed.ledger, f.settings.config) for _, token := range []string{f.settings.bootstrap.ControllerToken, f.settings.bootstrap.CapabilitySecret, f.settings.bootstrap.ProviderToken} { observed.containsToken = observed.containsToken || bytes.Contains(data, []byte(token)) @@ -503,7 +506,7 @@ func TestHostedGatewayBindsLifetimeAndPersistsBeforeBootstrap(t *testing.T) { } if !observed.persisted || observed.containsToken || !observed.ledger.ExposurePossible || observed.ledger.Ready || observed.ledger.Closed || !observed.ledger.CreateAttempted || !observed.ledger.SessionCreated || - observed.ledger.PrincipalDigest == "" || observed.ledger.BootstrapDigest != brokerJSONDigest(f.settings.bootstrap) { + observed.ledger.PrincipalDigest == "" || observed.ledger.BootstrapDigest != foundry.JSONDigest(f.settings.bootstrap) { t.Fatal("complete private exposure record was not durable before the first bootstrap write") } f.mu.Lock() @@ -518,9 +521,9 @@ func TestHostedGatewayBindsLifetimeAndPersistsBeforeBootstrap(t *testing.T) { base := "/api/projects/test-project/agents/" + f.settings.config.Image.Target.AgentName want := []string{"GET " + base + "?api-version=v1", "GET " + base + "/versions/" + f.settings.config.Image.Target.AgentVersion + "?api-version=v1", - "GET " + base + brokerSessionSuffix(f.settings.config.SessionID) + "?api-version=v1", + "GET " + base + foundry.SessionSuffix(f.settings.config.SessionID) + "?api-version=v1", "POST " + base + "/endpoint/sessions?api-version=v1", - "GET " + base + brokerSessionSuffix(f.settings.config.SessionID) + "?api-version=v1"} + "GET " + base + foundry.SessionSuffix(f.settings.config.SessionID) + "?api-version=v1"} if !reflect.DeepEqual(requests, want) || f.creates.Load() != 1 { t.Fatal("gateway did not validate, create and confirm exactly the configured session") } @@ -544,7 +547,7 @@ func TestHostedGatewayBindsLifetimeAndPersistsBeforeBootstrap(t *testing.T) { t.Fatal("reverse HTTP/2 channel did not connect") } for _, endpoint := range []struct{ method, url string }{ - {http.MethodGet, "http://broker" + brokerStatusPath}, + {http.MethodGet, "http://broker" + brokerapi.StatusPath}, {http.MethodPost, "http://orka/internal/v2/acp/mcp/tools/call"}, } { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -581,7 +584,7 @@ func TestHostedGatewayRejectsRemoteTargetDriftBeforeChannels(t *testing.T) { {"inactive version", "version", func(v map[string]any) { v["status"] = "inactive" }}, {"definition kind", "version", func(v map[string]any) { v["definition"].(map[string]any)["kind"] = "prompt" }}, {"container digest", "version", func(v map[string]any) { - v["definition"].(map[string]any)["container_configuration"] = map[string]any{"image": "example.invalid/hosted@" + brokerSHA([]byte("other image"))} + v["definition"].(map[string]any)["container_configuration"] = map[string]any{"image": "example.invalid/hosted@" + foundry.Digest([]byte("other image"))} }}, {"missing WebSocket", "version", func(v map[string]any) { v["definition"].(map[string]any)["protocol_versions"] = []any{} }}, {"WebSocket protocol version", "version", func(v map[string]any) { @@ -621,7 +624,7 @@ func TestHostedGatewayRejectsChallengeAndChannelBindingDrift(t *testing.T) { for name, mutate := range map[string]func(*hostedChallenge){ "protocol": func(c *hostedChallenge) { c.Protocol = "other" }, "deployment": func(c *hostedChallenge) { c.DeploymentID = uuid.NewString() }, - "configuration": func(c *hostedChallenge) { c.ConfigurationDigest = brokerSHA([]byte("other configuration")) }, + "configuration": func(c *hostedChallenge) { c.ConfigurationDigest = foundry.Digest([]byte("other configuration")) }, "agent": func(c *hostedChallenge) { c.AgentName = "other-agent" }, "version": func(c *hostedChallenge) { c.AgentVersion = "9" }, "session": func(c *hostedChallenge) { c.SessionID = uuid.NewString() }, @@ -663,7 +666,7 @@ func TestHostedGatewayRejectsChallengeAndChannelBindingDrift(t *testing.T) { "pair": func(a *hostedAccepted) { a.PairID = uuid.NewString() }, "role": func(a *hostedAccepted) { a.Role = "other" }, "boot": func(a *hostedAccepted) { a.BootID = uuid.NewString() }, - "bootstrap": func(a *hostedAccepted) { a.BootstrapDigest = brokerSHA([]byte("other bootstrap")) }, + "bootstrap": func(a *hostedAccepted) { a.BootstrapDigest = foundry.Digest([]byte("other bootstrap")) }, } { for _, role := range []string{"forward", "reverse"} { t.Run(role+" acknowledgment "+name, func(t *testing.T) { @@ -804,9 +807,9 @@ func TestHostedGatewayRejectsReadyAndCapabilityDriftAfterExposure(t *testing.T) for name, mutate := range map[string]func(map[string]any){ "protocol": func(v map[string]any) { v["protocol"] = "orka.harness.v1" }, "transport": func(v map[string]any) { v["transport"] = "other" }, - "profile": func(v map[string]any) { v["runtimeProfileDigest"] = brokerSHA([]byte("other profile")) }, + "profile": func(v map[string]any) { v["runtimeProfileDigest"] = foundry.Digest([]byte("other profile")) }, "adapter": func(v map[string]any) { - v["adapterDigests"] = map[string]string{"foundry-serve-acp": brokerSHA([]byte("other adapter"))} + v["adapterDigests"] = map[string]string{"foundry-serve-acp": foundry.Digest([]byte("other adapter"))} }, } { t.Run("capabilities "+name, func(t *testing.T) { diff --git a/hosted_handshake_deadline_test.go b/internal/hosted/handshake_deadline_test.go similarity index 99% rename from hosted_handshake_deadline_test.go rename to internal/hosted/handshake_deadline_test.go index e2a27c3..bc9a434 100644 --- a/hosted_handshake_deadline_test.go +++ b/internal/hosted/handshake_deadline_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" diff --git a/hosted_lifecycle_test.go b/internal/hosted/lifecycle_test.go similarity index 99% rename from hosted_lifecycle_test.go rename to internal/hosted/lifecycle_test.go index 4429ff9..d4e70b5 100644 --- a/hosted_lifecycle_test.go +++ b/internal/hosted/lifecycle_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bufio" @@ -64,6 +64,7 @@ func TestHostedHTTPWaitsForSupervisorShutdown(t *testing.T) { }) } } + func TestHostedHTTPStreamsBodyBeyondThirtySeconds(t *testing.T) { synctest.Test(t, func(t *testing.T) { serverConn, clientConn := net.Pipe() diff --git a/hosted_observation_test.go b/internal/hosted/observation_test.go similarity index 99% rename from hosted_observation_test.go rename to internal/hosted/observation_test.go index 52bf459..1d74927 100644 --- a/hosted_observation_test.go +++ b/internal/hosted/observation_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" diff --git a/hosted_process_linux.go b/internal/hosted/process_linux.go similarity index 99% rename from hosted_process_linux.go rename to internal/hosted/process_linux.go index 6b66a5e..04f2f2a 100644 --- a/hosted_process_linux.go +++ b/internal/hosted/process_linux.go @@ -1,6 +1,6 @@ //go:build linux -package main +package hosted import ( "context" diff --git a/hosted_process_linux_test.go b/internal/hosted/process_linux_test.go similarity index 99% rename from hosted_process_linux_test.go rename to internal/hosted/process_linux_test.go index cda3ad5..b4c4b58 100644 --- a/hosted_process_linux_test.go +++ b/internal/hosted/process_linux_test.go @@ -1,6 +1,6 @@ //go:build linux -package main +package hosted import ( "context" diff --git a/hosted_process_other.go b/internal/hosted/process_other.go similarity index 91% rename from hosted_process_other.go rename to internal/hosted/process_other.go index 58cf063..7968429 100644 --- a/hosted_process_other.go +++ b/internal/hosted/process_other.go @@ -1,6 +1,6 @@ //go:build !linux -package main +package hosted import "context" diff --git a/hosted_protocol.go b/internal/hosted/protocol.go similarity index 88% rename from hosted_protocol.go rename to internal/hosted/protocol.go index 648897e..8b75ed2 100644 --- a/hosted_protocol.go +++ b/internal/hosted/protocol.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "crypto/ed25519" @@ -15,6 +15,7 @@ import ( "filippo.io/edwards25519" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) const hostedProtocol = "orka.foundry.hosted.v1" @@ -22,11 +23,11 @@ const hostedProtocol = "orka.foundry.hosted.v1" var errHostedInvalid = errors.New("invalid Foundry hosted configuration or handshake") type hostedImageConfig struct { - Protocol string `json:"protocol"` - DeploymentID string `json:"deploymentID"` - Target acpHostedTarget `json:"target"` - SigningPublicKey string `json:"signingPublicKey"` - AgentConfigurationDigest string `json:"agentConfigurationDigest"` + Protocol string `json:"protocol"` + DeploymentID string `json:"deploymentID"` + Target foundry.HostedTarget `json:"target"` + SigningPublicKey string `json:"signingPublicKey"` + AgentConfigurationDigest string `json:"agentConfigurationDigest"` } type hostedBootstrap struct { @@ -66,7 +67,7 @@ type hostedAccepted struct { func validateHostedImageConfig(cfg hostedImageConfig) error { if cfg.Protocol != hostedProtocol || !hostedUUIDValid(cfg.DeploymentID) || - !brokerDigestValid(cfg.AgentConfigurationDigest) || !hostedTargetValid(cfg.Target) { + !foundry.DigestValid(cfg.AgentConfigurationDigest) || !hostedTargetValid(cfg.Target) { return errHostedInvalid } if _, ok := hostedPublicKey(cfg.SigningPublicKey); !ok { @@ -118,10 +119,10 @@ func verifyHostedHello(value hostedHello, expected hostedChallenge, publicKey st func hostedHelloSigningBytes(value hostedHello) ([]byte, error) { c := value.Challenge if c.Protocol != hostedProtocol || !hostedUUIDValid(c.DeploymentID) || - !brokerDigestValid(c.ConfigurationDigest) || validateAgentName(c.AgentName) != nil || + !foundry.DigestValid(c.ConfigurationDigest) || foundry.ValidateAgentName(c.AgentName) != nil || !hostedPositiveUint(c.AgentVersion) || !hostedUUIDValid(c.SessionID) || !hostedUUIDValid(c.BootID) || !hostedUUIDValid(value.PairID) || (value.Role != "forward" && value.Role != "reverse") || - !brokerDigestValid(value.BootstrapDigest) || value.ExpiresAt <= 0 { + !foundry.DigestValid(value.BootstrapDigest) || value.ExpiresAt <= 0 { return nil, errHostedInvalid } if _, ok := hostedCanonicalBytes(c.Nonce, 32); !ok { @@ -155,10 +156,10 @@ func validateHostedBootstrap(cfg hostedImageConfig, value hostedBootstrap) error case "ORKA_ACP_PROVIDER": valid = value == "foundry" case "ORKA_ACP_MODEL": - valid = utf8.ValidString(value) && acpSafeString(value, 512) && strings.TrimSpace(value) == value + valid = utf8.ValidString(value) && foundry.SafeString(value, 512) && strings.TrimSpace(value) == value case "ORKA_ACP_FOUNDRY_ADAPTER_DIGEST", "ORKA_ACP_TOOL_POLICY_DIGEST", "ORKA_ACP_APPROVAL_POLICY_DIGEST", "ORKA_ACP_MCP_CONFIGURATION_DIGEST": - valid = brokerDigestValid(value) + valid = foundry.DigestValid(value) case "ORKA_ACP_AGENT_CONFIGURATION_DIGEST": valid = value == cfg.AgentConfigurationDigest case "ORKA_ACP_WORKSPACE_INTENT": @@ -196,9 +197,9 @@ func validateHostedBootstrap(cfg hostedImageConfig, value hostedBootstrap) error return nil } -func hostedTargetValid(target acpHostedTarget) bool { - if validateAgentName(target.AgentName) != nil || !hostedPositiveUint(target.AgentVersion) || - !acpSafeString(target.ProjectEndpoint, 2048) || strings.ContainsAny(target.ProjectEndpoint, "%#") { +func hostedTargetValid(target foundry.HostedTarget) bool { + if foundry.ValidateAgentName(target.AgentName) != nil || !hostedPositiveUint(target.AgentVersion) || + !foundry.SafeString(target.ProjectEndpoint, 2048) || strings.ContainsAny(target.ProjectEndpoint, "%#") { return false } u, err := url.Parse(target.ProjectEndpoint) @@ -274,7 +275,7 @@ func hostedNonzeroBytes(value []byte) bool { } func hostedCredentialValid(value string) bool { - if len(value) < 32 || !utf8.ValidString(value) || !acpSafeString(value, 16<<10) { + if len(value) < 32 || !utf8.ValidString(value) || !foundry.SafeString(value, 16<<10) { return false } return strings.IndexFunc(value, unicode.IsSpace) < 0 diff --git a/hosted_protocol_test.go b/internal/hosted/protocol_test.go similarity index 95% rename from hosted_protocol_test.go rename to internal/hosted/protocol_test.go index c3c3305..259274f 100644 --- a/hosted_protocol_test.go +++ b/internal/hosted/protocol_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" @@ -12,6 +12,8 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) type hostedProtocolFixture struct { @@ -30,13 +32,13 @@ func newHostedProtocolFixture(t *testing.T) hostedProtocolFixture { } cfg := hostedImageConfig{ Protocol: hostedProtocol, DeploymentID: uuid.NewString(), - Target: acpHostedTarget{ + Target: foundry.HostedTarget{ ProjectEndpoint: "https://test-account.services.ai.azure.com/api/projects/test-project", AgentName: "test-agent", AgentVersion: "8", }, SigningPublicKey: base64.RawURLEncoding.EncodeToString(public), - AgentConfigurationDigest: brokerSHA([]byte("test agent configuration")), + AgentConfigurationDigest: foundry.Digest([]byte("test agent configuration")), } bootstrap := hostedBootstrap{ ControllerToken: strings.Repeat("test-controller-", 3), @@ -45,12 +47,12 @@ func newHostedProtocolFixture(t *testing.T) hostedProtocolFixture { Environment: map[string]string{ "ORKA_ACP_PROVIDER": "foundry", "ORKA_ACP_MODEL": "test-model", - "ORKA_ACP_FOUNDRY_ADAPTER_DIGEST": brokerSHA([]byte("test adapter")), + "ORKA_ACP_FOUNDRY_ADAPTER_DIGEST": foundry.Digest([]byte("test adapter")), "ORKA_ACP_WORKSPACE_INTENT": "read", "ORKA_ACP_AGENT_CONFIGURATION_DIGEST": cfg.AgentConfigurationDigest, - "ORKA_ACP_TOOL_POLICY_DIGEST": brokerSHA([]byte("test tool policy")), - "ORKA_ACP_APPROVAL_POLICY_DIGEST": brokerSHA([]byte("test approval policy")), - "ORKA_ACP_MCP_CONFIGURATION_DIGEST": brokerSHA([]byte("test MCP configuration")), + "ORKA_ACP_TOOL_POLICY_DIGEST": foundry.Digest([]byte("test tool policy")), + "ORKA_ACP_APPROVAL_POLICY_DIGEST": foundry.Digest([]byte("test approval policy")), + "ORKA_ACP_MCP_CONFIGURATION_DIGEST": foundry.Digest([]byte("test MCP configuration")), "ORKA_ACP_PROXY_CREDENTIAL_ROLE": "operator-managed", "ORKA_ACP_PROXY_CREDENTIAL_SCOPE": "external-runtime", "ORKA_ACP_RESOURCE_CLASS": "external", @@ -64,11 +66,11 @@ func newHostedProtocolFixture(t *testing.T) hostedProtocolFixture { hello := hostedHello{ Challenge: hostedChallenge{ Protocol: hostedProtocol, DeploymentID: cfg.DeploymentID, - ConfigurationDigest: brokerJSONDigest(cfg), AgentName: cfg.Target.AgentName, + ConfigurationDigest: foundry.JSONDigest(cfg), AgentName: cfg.Target.AgentName, AgentVersion: cfg.Target.AgentVersion, SessionID: uuid.NewString(), BootID: uuid.NewString(), Nonce: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), }, - PairID: uuid.NewString(), Role: "forward", BootstrapDigest: brokerJSONDigest(bootstrap), + PairID: uuid.NewString(), Role: "forward", BootstrapDigest: foundry.JSONDigest(bootstrap), ExpiresAt: now.Add(time.Minute).Unix(), } return hostedProtocolFixture{config: cfg, bootstrap: bootstrap, hello: hello, key: private, now: now} @@ -108,7 +110,7 @@ func TestHostedHelloBindsEverySignedField(t *testing.T) { for name, mutate := range map[string]func(*hostedHello){ "protocol": func(v *hostedHello) { v.Challenge.Protocol = "other-protocol" }, "deployment": func(v *hostedHello) { v.Challenge.DeploymentID = uuid.NewString() }, - "configuration": func(v *hostedHello) { v.Challenge.ConfigurationDigest = brokerSHA([]byte("other configuration")) }, + "configuration": func(v *hostedHello) { v.Challenge.ConfigurationDigest = foundry.Digest([]byte("other configuration")) }, "agent": func(v *hostedHello) { v.Challenge.AgentName = "other-agent" }, "version": func(v *hostedHello) { v.Challenge.AgentVersion = "9" }, "session": func(v *hostedHello) { v.Challenge.SessionID = uuid.NewString() }, @@ -123,12 +125,12 @@ func TestHostedHelloBindsEverySignedField(t *testing.T) { body := f.bootstrap body.Environment = maps.Clone(body.Environment) body.Environment["ORKA_ACP_WORKSPACE_INTENT"] = "write" - v.BootstrapDigest = brokerJSONDigest(body) + v.BootstrapDigest = foundry.JSONDigest(body) }, "bootstrap credential": func(v *hostedHello) { body := f.bootstrap body.ControllerToken = strings.Repeat("different-test-controller-", 2) - v.BootstrapDigest = brokerJSONDigest(body) + v.BootstrapDigest = foundry.JSONDigest(body) }, } { t.Run(name, func(t *testing.T) { @@ -336,7 +338,7 @@ func TestHostedHelloCanonicalJSONWire(t *testing.T) { t.Fatal("could not encode test handshake") } var reordered map[string]json.RawMessage - if acpDecode(raw, &reordered, true) != nil { + if strictjson.Decode(raw, &reordered, true) != nil { t.Fatal("could not decode test wire fields") } // Marshalling a map sorts the keys differently from the signing struct. @@ -345,13 +347,13 @@ func TestHostedHelloCanonicalJSONWire(t *testing.T) { t.Fatal("could not reorder test wire fields") } var decoded hostedHello - if acpDecode(raw, &decoded, true) != nil || + if strictjson.Decode(raw, &decoded, true) != nil || verifyHostedHello(decoded, signed.Challenge, f.config.SigningPublicKey, f.now) != nil { t.Fatal("valid reordered wire representation rejected") } reordered["unexpected"] = json.RawMessage(`true`) raw, _ = json.Marshal(reordered) - if acpDecode(raw, &decoded, true) == nil { + if strictjson.Decode(raw, &decoded, true) == nil { t.Fatal("unexpected handshake field accepted by strict wire decoder") } } @@ -487,7 +489,7 @@ func TestHostedBootstrapRejectsInvalidValues(t *testing.T) { "ORKA_ACP_MODEL": {"", " ", " model", "model ", "model\n", string([]byte{0xff}), strings.Repeat("m", 513)}, "ORKA_ACP_WORKSPACE_INTENT": {"", "Read", "read-write", "execute"}, "ORKA_ACP_FOUNDRY_ADAPTER_DIGEST": {"", "invalid", "sha256:" + strings.Repeat("A", 64)}, - "ORKA_ACP_AGENT_CONFIGURATION_DIGEST": {"", brokerSHA([]byte("other agent configuration"))}, + "ORKA_ACP_AGENT_CONFIGURATION_DIGEST": {"", foundry.Digest([]byte("other agent configuration"))}, "ORKA_ACP_TOOL_POLICY_DIGEST": {"", "invalid"}, "ORKA_ACP_APPROVAL_POLICY_DIGEST": {"", "invalid"}, "ORKA_ACP_MCP_CONFIGURATION_DIGEST": {"", "invalid"}, diff --git a/hosted_proxy.go b/internal/hosted/proxy.go similarity index 89% rename from hosted_proxy.go rename to internal/hosted/proxy.go index a841d67..d652f39 100644 --- a/hosted_proxy.go +++ b/internal/hosted/proxy.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "crypto/tls" @@ -11,6 +11,9 @@ import ( "path" "strings" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) // A fresh HTTP/1 connection for each local hop prevents the standard @@ -77,14 +80,14 @@ func hostedBrokerRoute(r *http.Request) bool { if r.URL.RawQuery != "" { return false } - if r.URL.Path == brokerStatusPath { + if r.URL.Path == brokerapi.StatusPath { return r.Method == http.MethodGet } if r.Method != http.MethodPost { return false } switch r.URL.Path { - case brokerResponsesPath, brokerRenewPath, brokerSettlePath, brokerRetirePath: + case brokerapi.ResponsesPath, brokerapi.RenewPath, brokerapi.SettlePath, brokerapi.RetirePath: return true default: return false @@ -101,7 +104,7 @@ func hostedOrkaRoute(r *http.Request) bool { } const prefix = "/internal/v2/acp/artifacts/sha256/" if (r.Method == http.MethodGet || r.Method == http.MethodPut || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, prefix) { - return brokerDigestValid("sha256:" + strings.TrimPrefix(r.URL.Path, prefix)) + return foundry.DigestValid("sha256:" + strings.TrimPrefix(r.URL.Path, prefix)) } return false } diff --git a/hosted_remote.go b/internal/hosted/remote.go similarity index 89% rename from hosted_remote.go rename to internal/hosted/remote.go index 839ade4..bbc35a4 100644 --- a/hosted_remote.go +++ b/internal/hosted/remote.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" @@ -12,6 +12,8 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" "golang.org/x/net/http/httpguts" ) @@ -35,12 +37,12 @@ func (g *hostedGateway) accessToken(ctx context.Context) (string, error) { App string `json:"appid"` AZP string `json:"azp"` } - if err != nil || acpDecodeStruct(raw, &claims, false) != nil || + if err != nil || strictjson.DecodeStruct(raw, &claims, false) != nil || strings.TrimRight(claims.Audience, "/") != "https://ai.azure.com" || !hostedUUIDValid(claims.Tenant) || !hostedUUIDValid(claims.Object) { return "", errHostedInvalid } - digest := brokerJSONDigest(claims) + digest := foundry.JSONDigest(claims) g.mu.Lock() defer g.mu.Unlock() if g.ledger.PrincipalDigest == "" { @@ -99,12 +101,12 @@ func (g *hostedGateway) sendRemoteJSON(request *http.Request, target any) (int, return 0, errHostedInvalid } defer response.Body.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(response.Body, acpMaxConfigBytes+1)) + data, err := io.ReadAll(io.LimitReader(response.Body, foundry.MaxAgentConfigBytes+1)) defer clear(data) - if err != nil || len(data) > acpMaxConfigBytes { + if err != nil || len(data) > foundry.MaxAgentConfigBytes { return response.StatusCode, errHostedInvalid } - if target != nil && response.StatusCode >= 200 && response.StatusCode < 300 && acpDecodeStruct(data, target, false) != nil { + if target != nil && response.StatusCode >= 200 && response.StatusCode < 300 && strictjson.DecodeStruct(data, target, false) != nil { return response.StatusCode, errHostedInvalid } return response.StatusCode, nil @@ -125,7 +127,7 @@ func (g *hostedGateway) validateRemote(ctx context.Context) error { var value struct { Type string `json:"type"` } - if acpDecodeStruct(scheme, &value, true) != nil || !strings.EqualFold(value.Type, "entra") { + if strictjson.DecodeStruct(scheme, &value, true) != nil || !strings.EqualFold(value.Type, "entra") { return errHostedInvalid } } @@ -170,8 +172,8 @@ func (g *hostedGateway) ensureSession(ctx context.Context) error { if g.ledger.CreateAttempted && !g.ledger.SessionCreated { return errHostedInvalid } - var session brokerRemoteSession - status, err := g.remoteJSON(ctx, http.MethodGet, brokerSessionSuffix(g.cfg.SessionID), nil, &session) + var session foundry.RemoteSession + status, err := g.remoteJSON(ctx, http.MethodGet, foundry.SessionSuffix(g.cfg.SessionID), nil, &session) if err != nil { return err } @@ -195,7 +197,7 @@ func (g *hostedGateway) ensureSession(ctx context.Context) error { } g.ledger = next status, err = g.sendRemoteJSON(request, &session) - if err == nil && brokerDefiniteRejection(status) { + if err == nil && foundry.DefiniteRejection(status) { // A complete admission rejection proves that this attempt created no // session. Persist that result before permitting a later startup. // Transport errors and incomplete responses retain the original intent. @@ -216,7 +218,7 @@ func (g *hostedGateway) ensureSession(ctx context.Context) error { return errHostedInvalid } g.ledger = next - status, err = g.remoteJSON(ctx, http.MethodGet, brokerSessionSuffix(g.cfg.SessionID), nil, &session) + status, err = g.remoteJSON(ctx, http.MethodGet, foundry.SessionSuffix(g.cfg.SessionID), nil, &session) } if err != nil || status != http.StatusOK || !g.sessionMatches(session) || session.Status != "active" { return errHostedInvalid @@ -224,7 +226,7 @@ func (g *hostedGateway) ensureSession(ctx context.Context) error { return nil } -func (g *hostedGateway) sessionMatches(value brokerRemoteSession) bool { +func (g *hostedGateway) sessionMatches(value foundry.RemoteSession) bool { return value.ID == g.cfg.SessionID && value.Version.Type == "version_ref" && value.Version.Version == g.cfg.Image.Target.AgentVersion } @@ -253,7 +255,7 @@ func (g *hostedGateway) openChannel(ctx context.Context, role string) (*websocke ws.SetReadLimit(hostedMaxHandshakeBytes) _ = ws.SetReadDeadline(time.Now().Add(30 * time.Second)) if readHostedWSJSON(ws, &challenge) != nil || challenge.Protocol != hostedProtocol || - challenge.DeploymentID != g.cfg.Image.DeploymentID || challenge.ConfigurationDigest != brokerJSONDigest(g.cfg.Image) || + challenge.DeploymentID != g.cfg.Image.DeploymentID || challenge.ConfigurationDigest != foundry.JSONDigest(g.cfg.Image) || challenge.AgentName != g.cfg.Image.Target.AgentName || challenge.AgentVersion != g.cfg.Image.Target.AgentVersion || challenge.SessionID != g.cfg.SessionID || !hostedUUIDValid(challenge.BootID) { _ = ws.Close() diff --git a/internal/hosted/remote_header_preflight_test.go b/internal/hosted/remote_header_preflight_test.go new file mode 100644 index 0000000..4f43fd3 --- /dev/null +++ b/internal/hosted/remote_header_preflight_test.go @@ -0,0 +1,60 @@ +package hosted + +import ( + "context" + "net/http" + "sync/atomic" + "testing" +) + +func TestHostedInvalidRemoteHeaderDoesNotReserveCreation(t *testing.T) { + for name, corrupt := range invalidRemoteHeaderTokens() { + t.Run(name, func(t *testing.T) { + f := newHostedGatewayTestFixture(t, hostedGatewayTestOptions{}) + var posts atomic.Int64 + transport := f.gateway.httpClient.Transport + f.gateway.httpClient.Transport = hostedGatewayTestRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodPost { + posts.Add(1) + } + return transport.RoundTrip(request) + }) + f.gateway.provider = hostedGatewayTestTokenProvider(func(context.Context) (string, error) { + token := f.token.Load().(string) + if f.tokenCalls.Add(1) == 4 { + return corrupt(token), nil + } + return token, nil + }) + if f.initialize() == nil || f.tokenCalls.Load() != 4 { + t.Fatal("fixture did not reach the invalid creation header") + } + if posts.Load() != 0 || f.creates.Load() != 0 || f.dials.Load() != 0 || f.httpCalls.Load() != 3 { + t.Error("invalid creation header reached transport or channel setup") + } + f.assertNoBootstrap(t) + store, ledger, err := openHostedGatewayStore(f.settings.stateDir, f.settings.config) + if err != nil { + t.Fatal("could not reopen gateway after local header rejection") + } + defer store.close() + if ledger.CreateAttempted || ledger.SessionCreated || ledger.ExposurePossible { + t.Fatal("local header rejection stranded the hosted creation") + } + restarted := &hostedGateway{cfg: f.settings.config, provider: f.gateway.provider, + httpClient: f.gateway.httpClient, store: store, ledger: ledger} + if restarted.ensureSession(t.Context()) != nil || posts.Load() != 1 || f.creates.Load() != 1 || !restarted.ledger.SessionCreated { + t.Fatal("definitely-unsent header rejection prevented a later owned creation") + } + }) + } +} + +func invalidRemoteHeaderTokens() map[string]func(string) string { + return map[string]func(string) string{ + "signature-nul": func(token string) string { return token + "\x00" }, + "signature-lf": func(token string) string { return token + "\n" }, + "signature-cr": func(token string) string { return token + "\r" }, + "header-del": func(token string) string { return "\x7f" + token }, + } +} diff --git a/hosted_review_pairing_test.go b/internal/hosted/review_pairing_test.go similarity index 99% rename from hosted_review_pairing_test.go rename to internal/hosted/review_pairing_test.go index 9a74377..3906956 100644 --- a/hosted_review_pairing_test.go +++ b/internal/hosted/review_pairing_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" diff --git a/hosted_review_shutdown_test.go b/internal/hosted/review_shutdown_test.go similarity index 94% rename from hosted_review_shutdown_test.go rename to internal/hosted/review_shutdown_test.go index 7c06957..054103d 100644 --- a/hosted_review_shutdown_test.go +++ b/internal/hosted/review_shutdown_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -11,6 +11,8 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/brokerapi" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestHostedReviewLocalStopPreservesCleanupRelays(t *testing.T) { @@ -33,7 +35,7 @@ func TestHostedReviewLocalStopPreservesCleanupRelays(t *testing.T) { client := &http.Client{Transport: transport, Timeout: 2 * time.Second} ok := true for _, target := range []string{ - "http://" + hostedBrokerRelayAddr + brokerSettlePath, + "http://" + hostedBrokerRelayAddr + brokerapi.SettlePath, "http://" + hostedOrkaRelayAddr + "/internal/v2/acp/artifact-authorizations", } { request, _ := http.NewRequest(http.MethodPost, target, nil) @@ -114,7 +116,7 @@ func TestHostedReviewLocalRelayStartupFailureIsNotCleanExit(t *testing.T) { } defer reserved.Close() //nolint:errcheck f := newHostedServerTestFixture(t, false) - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) _ = hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) @@ -145,7 +147,7 @@ func TestHostedReviewPeerCancellationWhileStartingIsNotLocalFailure(t *testing.T go func() { <-ctx.Done(); done <- nil; close(done) }() return done, nil } - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) reverse := hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) diff --git a/hosted_main.go b/internal/hosted/run.go similarity index 92% rename from hosted_main.go rename to internal/hosted/run.go index e1ea745..1c8d7c5 100644 --- a/hosted_main.go +++ b/internal/hosted/run.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -12,9 +12,11 @@ import ( "strconv" "syscall" "time" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) -func maybeServeHosted(args []string) (bool, error) { +func MaybeServe(args []string) (bool, error) { mode := "" for index, arg := range args { for _, value := range []string{"hosted", "hosted-gateway"} { @@ -44,7 +46,7 @@ func maybeServeHosted(args []string) (bool, error) { if err != nil { return true, err } - provider, err := newAzureFoundryTokenProvider() + provider, err := foundry.NewTokenProvider() if err != nil { clear(settings.signingKey) return true, errHostedInvalid @@ -55,7 +57,7 @@ func maybeServeHosted(args []string) (bool, error) { if err != nil { return true, err } - port := firstNonBlank(os.Getenv("PORT"), "8088") + port := foundry.FirstNonBlank(os.Getenv("PORT"), "8088") n, err := strconv.Atoi(port) // The supervisor and reverse relays bind these loopback ports after bootstrap. if err != nil || n < 1 || n > 65535 || n == 8080 || n == 8091 || n == 8092 { @@ -69,7 +71,7 @@ func maybeServeHosted(args []string) (bool, error) { return true, serveHostedHTTP(server.ctx, ":"+port, server) } -func serveHostedGateway(ctx context.Context, settings hostedGatewaySettings, provider foundryTokenProvider) error { +func serveHostedGateway(ctx context.Context, settings hostedGatewaySettings, provider foundry.TokenProvider) error { defer clear(settings.signingKey) // Retain the listener before creating a one-shot remote lifetime. A local // bind failure must not reserve a session or expose bootstrap credentials. diff --git a/hosted_server.go b/internal/hosted/server.go similarity index 94% rename from hosted_server.go rename to internal/hosted/server.go index c83c2ea..49c3ccb 100644 --- a/hosted_server.go +++ b/internal/hosted/server.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bufio" @@ -19,6 +19,8 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) type hostedReady struct { @@ -83,8 +85,8 @@ func newHostedServer(ctx context.Context, cfg hostedImageConfig, getenv func(str return nil, errHostedInvalid } digest := getenv("ORKA_ACP_FOUNDRY_ADAPTER_DIGEST") - agent, err := readHostedFile(acpConfigPath, acpMaxConfigBytes) - if err != nil || brokerSHA(agent) != cfg.AgentConfigurationDigest || !brokerDigestValid(digest) { + agent, err := readHostedFile(foundry.AgentConfigPath, foundry.MaxAgentConfigBytes) + if err != nil || foundry.Digest(agent) != cfg.AgentConfigurationDigest || !foundry.DigestValid(digest) { return nil, errHostedInvalid } var nonce [32]byte @@ -94,7 +96,7 @@ func newHostedServer(ctx context.Context, cfg hostedImageConfig, getenv func(str lifetime, cancel := context.WithCancel(ctx) return &hostedServer{cfg: cfg, adapterDigest: digest, agentConfig: agent, runner: runner, ctx: lifetime, cancel: cancel, challenge: hostedChallenge{Protocol: hostedProtocol, - DeploymentID: cfg.DeploymentID, ConfigurationDigest: brokerJSONDigest(cfg), + DeploymentID: cfg.DeploymentID, ConfigurationDigest: foundry.JSONDigest(cfg), AgentName: cfg.Target.AgentName, AgentVersion: cfg.Target.AgentVersion, SessionID: sid.String(), BootID: uuid.NewString(), Nonce: base64.RawURLEncoding.EncodeToString(nonce[:])}}, nil } @@ -188,15 +190,15 @@ func (s *hostedServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = ws.SetReadDeadline(pair.setupDeadline) kind, data, err := ws.ReadMessage() var bootstrap hostedBootstrap - if err != nil || kind != websocket.TextMessage || brokerSHA(data) != pair.bootstrapDigest || - acpDecode(data, &bootstrap, true) != nil || validateHostedBootstrap(s.cfg, bootstrap) != nil || + if err != nil || kind != websocket.TextMessage || foundry.Digest(data) != pair.bootstrapDigest || + strictjson.Decode(data, &bootstrap, true) != nil || validateHostedBootstrap(s.cfg, bootstrap) != nil || bootstrap.Environment["ORKA_ACP_FOUNDRY_ADAPTER_DIGEST"] != s.adapterDigest { clear(data) pair.close() return } clear(data) - if _, err := decodeACPAgentConfiguration(s.agentConfig, s.cfg.AgentConfigurationDigest, bootstrap.Environment["ORKA_ACP_MODEL"]); err != nil { + if _, err := foundry.DecodeAgentConfig(s.agentConfig, s.cfg.AgentConfigurationDigest, bootstrap.Environment["ORKA_ACP_MODEL"]); err != nil { pair.close() return } @@ -233,7 +235,7 @@ func hostedRoutingQuery(r *http.Request) bool { return false } for key, values := range query { - if (key != "api-version" && key != "agent_session_id") || len(values) != 1 || len(values[0]) > 512 || !acpSafeString(values[0], 512) { + if (key != "api-version" && key != "agent_session_id") || len(values) != 1 || len(values[0]) > 512 || !foundry.SafeString(values[0], 512) { return false } } @@ -242,7 +244,7 @@ func hostedRoutingQuery(r *http.Request) bool { func readHostedWSJSON(ws *websocket.Conn, value any) error { kind, data, err := ws.ReadMessage() - if err != nil || kind != websocket.TextMessage || len(data) > hostedMaxHandshakeBytes || acpDecode(data, value, true) != nil { + if err != nil || kind != websocket.TextMessage || len(data) > hostedMaxHandshakeBytes || strictjson.Decode(data, value, true) != nil { return errHostedInvalid } return nil diff --git a/hosted_server_test.go b/internal/hosted/server_test.go similarity index 93% rename from hosted_server_test.go rename to internal/hosted/server_test.go index 6bdd96e..adf31bb 100644 --- a/hosted_server_test.go +++ b/internal/hosted/server_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" "golang.org/x/net/http2" ) @@ -38,19 +39,19 @@ func newHostedServerTestFixture(t *testing.T, runnerOK bool) *hostedServerTestFi t.Helper() f := &hostedServerTestFixture{protocol: newHostedProtocolFixture(t), captured: make(chan map[string]string, 8), stopped: make(chan struct{}, 8), runnerOK: runnerOK} - agent, err := json.Marshal(acpAgentConfiguration{Model: "test-model", ToolSchemaMode: toolSchemaModeRequest, + agent, err := json.Marshal(foundry.AgentConfig{Model: "test-model", ToolSchemaMode: foundry.ToolSchemaModeRequest, HostedTarget: f.protocol.config.Target}) if err != nil { t.Fatal("could not prepare hosted agent fixture") } - f.protocol.config.AgentConfigurationDigest = brokerSHA(agent) - f.protocol.bootstrap.Environment["ORKA_ACP_AGENT_CONFIGURATION_DIGEST"] = brokerSHA(agent) - f.protocol.hello.Challenge.ConfigurationDigest = brokerJSONDigest(f.protocol.config) - f.protocol.hello.BootstrapDigest = brokerJSONDigest(f.protocol.bootstrap) + f.protocol.config.AgentConfigurationDigest = foundry.Digest(agent) + f.protocol.bootstrap.Environment["ORKA_ACP_AGENT_CONFIGURATION_DIGEST"] = foundry.Digest(agent) + f.protocol.hello.Challenge.ConfigurationDigest = foundry.JSONDigest(f.protocol.config) + f.protocol.hello.BootstrapDigest = foundry.JSONDigest(f.protocol.bootstrap) if validateHostedBootstrap(f.protocol.config, f.protocol.bootstrap) != nil { t.Fatal("invalid canonical bootstrap fixture") } - if _, err := decodeACPAgentConfiguration(agent, f.protocol.config.AgentConfigurationDigest, "test-model"); err != nil { + if _, err := foundry.DecodeAgentConfig(agent, f.protocol.config.AgentConfigurationDigest, "test-model"); err != nil { t.Fatal("invalid canonical agent configuration fixture") } ctx, cancel := context.WithCancel(context.Background()) @@ -172,7 +173,7 @@ func (f *hostedServerTestFixture) hello(t *testing.T, role, pairID, digest strin func (f *hostedServerTestFixture) sendBootstrap(t *testing.T, ws *websocket.Conn) { t.Helper() data, err := json.Marshal(f.protocol.bootstrap) - if err != nil || brokerSHA(data) != f.protocol.hello.BootstrapDigest || ws.WriteMessage(websocket.TextMessage, data) != nil { + if err != nil || foundry.Digest(data) != f.protocol.hello.BootstrapDigest || ws.WriteMessage(websocket.TextMessage, data) != nil { t.Fatal("could not send exact signed bootstrap bytes") } } @@ -243,7 +244,7 @@ func TestHostedServerUnauthenticatedInputCannotReservePair(t *testing.T) { t.Run(name, func(t *testing.T) { f := newHostedServerTestFixture(t, false) ws := f.connect(t) - hello := f.hello(t, "forward", uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap)) + hello := f.hello(t, "forward", uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap)) kind := websocket.TextMessage switch name { case "missing signature": @@ -280,7 +281,7 @@ func TestHostedServerConflictingClaimsCannotReplaceAuthenticatedRole(t *testing. for _, name := range []string{"forward replay", "different pair", "different bootstrap"} { t.Run(name, func(t *testing.T) { f := newHostedServerTestFixture(t, false) - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) _ = f.claim(t, "forward", pairID, digest) hostedServerWait(t, func() bool { return f.state(func(p *hostedPair) bool { return p != nil && p.forwardAcked }) }) f.server.mu.Lock() @@ -293,7 +294,7 @@ func TestHostedServerConflictingClaimsCannotReplaceAuthenticatedRole(t *testing. case "different pair": otherPair = uuid.NewString() case "different bootstrap": - otherDigest = brokerSHA([]byte("different bootstrap")) + otherDigest = foundry.Digest([]byte("different bootstrap")) } ws := f.connect(t) if ws.WriteJSON(f.hello(t, role, otherPair, otherDigest)) != nil { @@ -311,7 +312,7 @@ func TestHostedServerConflictingClaimsCannotReplaceAuthenticatedRole(t *testing. func TestHostedServerConcurrentRoleReplayHasSingleWinner(t *testing.T) { f := newHostedServerTestFixture(t, false) - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) hello := f.hello(t, "forward", pairID, digest) const count = 8 results := make(chan *websocket.Conn, count) @@ -369,7 +370,7 @@ func TestHostedServerRejectsMalformedOrUntrustedBootstrapBeforeLaunch(t *testing case "model mismatch": bootstrap.Environment["ORKA_ACP_MODEL"] = "other-model" case "adapter mismatch": - bootstrap.Environment["ORKA_ACP_FOUNDRY_ADAPTER_DIGEST"] = brokerSHA([]byte("other adapter")) + bootstrap.Environment["ORKA_ACP_FOUNDRY_ADAPTER_DIGEST"] = foundry.Digest([]byte("other adapter")) case "inherited identity": bootstrap.Environment["IDENTITY_HEADER"] = "fixture-untrusted-identity" case "supervisor override": @@ -383,9 +384,9 @@ func TestHostedServerRejectsMalformedOrUntrustedBootstrapBeforeLaunch(t *testing } else if name == "duplicate member" { data = append([]byte(`{"environment":{},`), data[1:]...) } - digest := brokerSHA(data) + digest := foundry.Digest(data) if name == "wrong digest" { - digest = brokerSHA([]byte("other bytes")) + digest = foundry.Digest([]byte("other bytes")) } pairID := uuid.NewString() forward := f.claim(t, "forward", pairID, digest) @@ -412,7 +413,7 @@ func TestHostedServerRequiresBothRolesAndExactConstructedEnvironment(t *testing. t.Setenv(name, "fixture-parent-only") } f := newHostedServerTestFixture(t, false) - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) var forward, reverse *websocket.Conn if order == "forward first" { forward = f.claim(t, "forward", pairID, digest) @@ -500,7 +501,7 @@ func hostedServerTestHealth(t *testing.T, status int) <-chan struct{} { func hostedServerTestRunningPair(t *testing.T, f *hostedServerTestFixture) (*hostedWSConn, *hostedWSConn, *http2.ClientConn) { t.Helper() - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) reverse := hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) @@ -536,7 +537,7 @@ func TestHostedServerRunningLifetimeCannotLaunchSecondSupervisor(t *testing.T) { } for _, role := range []string{"forward", "reverse"} { ws := f.connect(t) - if ws.WriteJSON(f.hello(t, role, uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap))) != nil { + if ws.WriteJSON(f.hello(t, role, uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap))) != nil { t.Fatal("could not send second-launch fixture") } hostedServerRejected(t, ws) @@ -625,7 +626,7 @@ func TestHostedServerEitherChannelLossWhileStartingCancelsRunner(t *testing.T) { t.Run(role, func(t *testing.T) { healthCalled := hostedServerTestHealth(t, http.StatusServiceUnavailable) f := newHostedServerTestFixture(t, true) - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) reverse := hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) diff --git a/hosted_startup_config_test.go b/internal/hosted/startup_config_test.go similarity index 93% rename from hosted_startup_config_test.go rename to internal/hosted/startup_config_test.go index e151ed9..6c49e51 100644 --- a/hosted_startup_config_test.go +++ b/internal/hosted/startup_config_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestHostedGatewayRejectsUnusableSigningKeyDuringSettingsLoad(t *testing.T) { @@ -28,8 +30,8 @@ func TestHostedGatewayRejectsUnusableSigningKeyDuringSettingsLoad(t *testing.T) } cfg := hostedGatewayConfig{ Protocol: hostedProtocol, Image: f.config, - ContainerImage: "example.invalid/hosted@" + brokerSHA([]byte("fixture image")), - SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: brokerSHA([]byte("fixture profile")), + ContainerImage: "example.invalid/hosted@" + foundry.Digest([]byte("fixture image")), + SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: foundry.Digest([]byte("fixture profile")), RuntimeEnvironment: maps.Clone(f.bootstrap.Environment), OrkaBaseURL: "http://orka.test:8080", BrokerBaseURL: "http://127.0.0.1:8091", } diff --git a/hosted_startup_exit_test.go b/internal/hosted/startup_exit_test.go similarity index 94% rename from hosted_startup_exit_test.go rename to internal/hosted/startup_exit_test.go index 60d8370..1cf0d96 100644 --- a/hosted_startup_exit_test.go +++ b/internal/hosted/startup_exit_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" ) func TestHostedStartupProcessCompletionBeforeReadiness(t *testing.T) { @@ -23,7 +24,7 @@ func TestHostedStartupProcessCompletionBeforeReadiness(t *testing.T) { f.captured <- maps.Clone(environment) return processDone, nil } - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) reverse := hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) diff --git a/hosted_startup_review_test.go b/internal/hosted/startup_review_test.go similarity index 95% rename from hosted_startup_review_test.go rename to internal/hosted/startup_review_test.go index 6e4a5f1..e212945 100644 --- a/hosted_startup_review_test.go +++ b/internal/hosted/startup_review_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bytes" @@ -15,6 +15,8 @@ import ( "time" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) func hostedStartupGatewaySettings(t *testing.T) hostedGatewaySettings { @@ -23,8 +25,8 @@ func hostedStartupGatewaySettings(t *testing.T) hostedGatewaySettings { settings := hostedGatewaySettings{ config: hostedGatewayConfig{ Protocol: hostedProtocol, Image: f.config, - ContainerImage: "example.invalid/hosted@" + brokerSHA([]byte("startup fixture image")), - SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: brokerSHA([]byte("startup fixture profile")), + ContainerImage: "example.invalid/hosted@" + foundry.Digest([]byte("startup fixture image")), + SessionID: f.hello.Challenge.SessionID, RuntimeProfileDigest: foundry.Digest([]byte("startup fixture profile")), RuntimeEnvironment: maps.Clone(f.bootstrap.Environment), OrkaBaseURL: "http://orka.test:8080", BrokerBaseURL: "http://127.0.0.1:8091", }, @@ -206,7 +208,7 @@ func TestHostedStartupCreateReservationPrecedesSubmission(t *testing.T) { submissions.Add(1) data, err := readHostedFile(filepath.Join(f.settings.stateDir, "state.json"), hostedMaxHandshakeBytes) var ledger hostedGatewayLedger - if err != nil || acpDecode(data, &ledger, true) != nil || !ledger.CreateAttempted || ledger.SessionCreated || ledger.ExposurePossible || ledger.PrincipalDigest == "" { + if err != nil || strictjson.Decode(data, &ledger, true) != nil || !ledger.CreateAttempted || ledger.SessionCreated || ledger.ExposurePossible || ledger.PrincipalDigest == "" { t.Error("session creation reached transport before durable ownership") } if request.GetBody != nil { @@ -267,7 +269,7 @@ func TestHostedStartupRunnerFailureClassification(t *testing.T) { // Match the production runner's rejection before command.Start. return nil, errHostedInvalid } - pairID, digest := uuid.NewString(), brokerJSONDigest(f.protocol.bootstrap) + pairID, digest := uuid.NewString(), foundry.JSONDigest(f.protocol.bootstrap) forward := f.claim(t, "forward", pairID, digest) reverse := hostedServerReverse(t, f.claim(t, "reverse", pairID, digest)) f.sendBootstrap(t, forward) diff --git a/hosted_store.go b/internal/hosted/store.go similarity index 79% rename from hosted_store.go rename to internal/hosted/store.go index 6d68e90..b4563e4 100644 --- a/hosted_store.go +++ b/internal/hosted/store.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "encoding/json" @@ -8,6 +8,9 @@ import ( "syscall" "github.com/google/uuid" + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) // The gateway records possible credential exposure BEFORE sending bootstrap. @@ -38,22 +41,22 @@ func openHostedGatewayStore(dir string, cfg hostedGatewayConfig) (*hostedGateway if !filepath.IsAbs(dir) || filepath.Clean(dir) == "/" { return nil, empty, errHostedInvalid } - lock, created, err := openStoreLock(dir, "gateway.lock", syncStoreDirectory) + lock, created, err := durablestore.OpenLock(dir, "gateway.lock", durablestore.SyncDirectory) if err != nil { return nil, empty, errHostedInvalid } store := &hostedGatewayStore{dir: dir, lock: lock} - ledger := hostedGatewayLedger{Version: 1, ConfigDigest: brokerJSONDigest(cfg), SessionID: cfg.SessionID} + ledger := hostedGatewayLedger{Version: 1, ConfigDigest: foundry.JSONDigest(cfg), SessionID: cfg.SessionID} path := filepath.Join(dir, "state.json") info, err := os.Lstat(path) if os.IsNotExist(err) { if created && store.save(ledger) == nil { return store, ledger, nil } - } else if err == nil && brokerPrivateFile(info) && info.Size() <= hostedMaxHandshakeBytes { + } else if err == nil && durablestore.PrivateFile(info) && info.Size() <= hostedMaxHandshakeBytes { data, readErr := readHostedFile(path, hostedMaxHandshakeBytes) var stored hostedGatewayLedger - if readErr == nil && acpDecode(data, &stored, true) == nil && hostedGatewayLedgerValid(stored, cfg) { + if readErr == nil && strictjson.Decode(data, &stored, true) == nil && hostedGatewayLedgerValid(stored, cfg) { return store, stored, nil } } @@ -62,8 +65,8 @@ func openHostedGatewayStore(dir string, cfg hostedGatewayConfig) (*hostedGateway } func hostedGatewayLedgerValid(value hostedGatewayLedger, cfg hostedGatewayConfig) bool { - if value.Version != 1 || value.ConfigDigest != brokerJSONDigest(cfg) || value.SessionID != cfg.SessionID || - (value.PrincipalDigest != "" && !brokerDigestValid(value.PrincipalDigest)) || + if value.Version != 1 || value.ConfigDigest != foundry.JSONDigest(cfg) || value.SessionID != cfg.SessionID || + (value.PrincipalDigest != "" && !foundry.DigestValid(value.PrincipalDigest)) || (value.CreateAttempted && value.PrincipalDigest == "") || (value.SessionCreated && !value.CreateAttempted) || (value.Ready && !value.ExposurePossible) { return false @@ -74,8 +77,8 @@ func hostedGatewayLedgerValid(value hostedGatewayLedger, cfg hostedGatewayConfig _, nonceValid := hostedCanonicalBytes(value.Challenge.Nonce, 32) return value.SessionCreated && value.PrincipalDigest != "" && hostedUUIDValid(value.PairID) && hostedUUIDValid(value.Challenge.BootID) && nonceValid && - brokerDigestValid(value.BootstrapDigest) && value.Challenge.SessionID == cfg.SessionID && - value.Challenge.ConfigurationDigest == brokerJSONDigest(cfg.Image) && + foundry.DigestValid(value.BootstrapDigest) && value.Challenge.SessionID == cfg.SessionID && + value.Challenge.ConfigurationDigest == foundry.JSONDigest(cfg.Image) && value.Challenge.DeploymentID == cfg.Image.DeploymentID && value.Challenge.Protocol == hostedProtocol && value.Challenge.AgentName == cfg.Image.Target.AgentName && value.Challenge.AgentVersion == cfg.Image.Target.AgentVersion } diff --git a/internal/hosted/store_initialization_test.go b/internal/hosted/store_initialization_test.go new file mode 100644 index 0000000..e664e20 --- /dev/null +++ b/internal/hosted/store_initialization_test.go @@ -0,0 +1,48 @@ +package hosted + +import ( + "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/durablestore/storetest" + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" +) + +func hostedStoreFixture(t *testing.T) storetest.Fixture { + t.Helper() + cfg, exposed := hostedBoundaryLedgerFixture(t) + return storetest.Fixture{ + LockName: "gateway.lock", Digest: foundry.JSONDigest(exposed), + Open: func(dir string) (func(), string, error) { + store, ledger, err := openHostedGatewayStore(dir, cfg) + if err != nil { + return nil, "", err + } + return store.close, foundry.JSONDigest(ledger), nil + }, + Save: func(dir string) error { return (&hostedGatewayStore{dir: dir}).save(exposed) }, + } +} + +func TestDurableStoreMissingLedgerFailsClosed(t *testing.T) { + storetest.MissingLedgerFailsClosed(t, hostedStoreFixture(t)) +} + +func TestDurableStoreEmptyDirectoryAndLegacyRecovery(t *testing.T) { + storetest.EmptyDirectoryAndLegacyRecovery(t, hostedStoreFixture(t)) +} + +func TestDurableStoreInitializerRace(t *testing.T) { + storetest.InitializerRace(t, hostedStoreFixture(t)) +} + +func TestStoreInitializerKeepsCreatorAcrossFlockContention(t *testing.T) { + storetest.CreatorKeepsLock(t, hostedStoreFixture(t)) +} + +func TestStoreInitializerExistingWriterRemainsNonblocking(t *testing.T) { + storetest.ExistingWriterNonblocking(t, hostedStoreFixture(t)) +} + +func TestStoreInitializerLegacyRecoveryRemainsNonblocking(t *testing.T) { + storetest.LegacyRecoveryNonblocking(t, hostedStoreFixture(t)) +} diff --git a/hosted_transport.go b/internal/hosted/transport.go similarity index 99% rename from hosted_transport.go rename to internal/hosted/transport.go index c0dd060..cb96aea 100644 --- a/hosted_transport.go +++ b/internal/hosted/transport.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "context" @@ -278,7 +278,8 @@ func (c *hostedWSConn) finishWrite(write *hostedWSWrite) bool { return write.timedOut } -func (c *hostedWSConn) LocalAddr() net.Addr { return c.ws.LocalAddr() } +func (c *hostedWSConn) LocalAddr() net.Addr { return c.ws.LocalAddr() } + func (c *hostedWSConn) RemoteAddr() net.Addr { return c.ws.RemoteAddr() } func (c *hostedWSConn) SetDeadline(deadline time.Time) error { diff --git a/hosted_transport_test.go b/internal/hosted/transport_test.go similarity index 99% rename from hosted_transport_test.go rename to internal/hosted/transport_test.go index 0e31217..cfe7718 100644 --- a/hosted_transport_test.go +++ b/internal/hosted/transport_test.go @@ -1,4 +1,4 @@ -package main +package hosted import ( "bufio" diff --git a/internal/strictjson/decode.go b/internal/strictjson/decode.go new file mode 100644 index 0000000..fcd6922 --- /dev/null +++ b/internal/strictjson/decode.go @@ -0,0 +1,142 @@ +package strictjson + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "strconv" + "unicode/utf8" +) + +var errInvalid = errors.New("invalid JSON") + +// Go's ordinary JSON decoder accepts duplicate object members. Reject them at +// every depth before interpreting authority-bearing envelopes or tool arguments. +func Decode(data []byte, value any, strictFields bool) error { + return decodeJSON(data, value, strictFields, nil) +} + +// Remote authority and Responses structs accept single case-folded field names, +// as encoding/json does, but two names must never replace or merge one field. +// Maps, interfaces and custom JSON values remain opaque to field folding. +func DecodeStruct(data []byte, value any, strictFields bool) error { + return decodeJSON(data, value, strictFields, reflect.TypeOf(value)) +} + +func decodeJSON(data []byte, value any, strictFields bool, shape reflect.Type) error { + if !utf8.Valid(data) || !json.Valid(data) || !validStringEscapes(data) { + return errInvalid + } + check := json.NewDecoder(bytes.NewReader(data)) + check.UseNumber() + if jsonValue(check, 0, shape) != nil { + return errInvalid + } + if _, err := check.Token(); !errors.Is(err, io.EOF) { + return errInvalid + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if strictFields { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(value); err != nil { + return errInvalid + } + return nil +} + +// encoding/json replaces unpaired UTF-16 escapes with U+FFFD. Tool arguments +// must retain their exact Unicode value, so reject malformed surrogate pairs. +func validStringEscapes(data []byte) bool { + quoted := false + for i := 0; i < len(data); i++ { + if data[i] == '"' { + quoted = !quoted + continue + } + if !quoted || data[i] != '\\' { + continue + } + i++ + if data[i] != 'u' { + continue + } + value, err := strconv.ParseUint(string(data[i+1:i+5]), 16, 16) + if err != nil { + return false + } + i += 4 + if value >= 0xdc00 && value <= 0xdfff { + return false + } + if value < 0xd800 || value > 0xdbff { + continue + } + if i+6 >= len(data) || data[i+1] != '\\' || data[i+2] != 'u' { + return false + } + low, err := strconv.ParseUint(string(data[i+3:i+7]), 16, 16) + if err != nil || low < 0xdc00 || low > 0xdfff { + return false + } + i += 6 + } + return true +} + +func jsonValue(decoder *json.Decoder, depth int, shape reflect.Type) error { + if depth > 64 { + return errInvalid + } + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + fields, err := structFields(shape, 0) + if err != nil { + return err + } + seen := make(map[string]bool) + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return err + } + name, ok := key.(string) + if !ok { + return errInvalid + } + name, fieldType := matchField(fields, name) + if seen[name] { + return errInvalid + } + seen[name] = true + if err := jsonValue(decoder, depth+1, fieldType); err != nil { + return err + } + } + case '[': + var element reflect.Type + if shape := structType(shape); shape != nil && (shape.Kind() == reflect.Slice || shape.Kind() == reflect.Array) { + element = shape.Elem() + } + for decoder.More() { + if err := jsonValue(decoder, depth+1, element); err != nil { + return err + } + } + default: + return errInvalid + } + _, err = decoder.Token() + return err +} diff --git a/acp_json_fields.go b/internal/strictjson/fields.go similarity index 73% rename from acp_json_fields.go rename to internal/strictjson/fields.go index 29476a9..ada9639 100644 --- a/acp_json_fields.go +++ b/internal/strictjson/fields.go @@ -1,4 +1,4 @@ -package main +package strictjson import ( "encoding/json" @@ -6,14 +6,14 @@ import ( "strings" ) -type acpJSONStructField struct { +type structField struct { name string typeOf reflect.Type depth int tagged bool } -func acpJSONStructType(value reflect.Type) reflect.Type { +func structType(value reflect.Type) reflect.Type { for value != nil && value.Kind() == reflect.Pointer { value = value.Elem() } @@ -25,15 +25,15 @@ func acpJSONStructType(value reflect.Type) reflect.Type { // Only struct fields define folded names. In particular, RawMessage tool // arguments, map keys and unknown metadata retain their case-sensitive JSON. -func acpJSONStructFields(value reflect.Type, depth int) ([]acpJSONStructField, error) { +func structFields(value reflect.Type, depth int) ([]structField, error) { if depth > 64 { - return nil, acpInvalidParams + return nil, errInvalid } - value = acpJSONStructType(value) + value = structType(value) if value == nil || value.Kind() != reflect.Struct { return nil, nil } - var candidates []acpJSONStructField + var candidates []structField for i := 0; i < value.NumField(); i++ { field := value.Field(i) name, _, _ := strings.Cut(field.Tag.Get("json"), ",") @@ -41,8 +41,8 @@ func acpJSONStructFields(value reflect.Type, depth int) ([]acpJSONStructField, e continue } if name == "" && field.Anonymous { - if embedded := acpJSONStructType(field.Type); embedded != nil && embedded.Kind() == reflect.Struct { - fields, err := acpJSONStructFields(field.Type, depth+1) + if embedded := structType(field.Type); embedded != nil && embedded.Kind() == reflect.Struct { + fields, err := structFields(field.Type, depth+1) if err != nil { return nil, err } @@ -57,11 +57,11 @@ func acpJSONStructFields(value reflect.Type, depth int) ([]acpJSONStructField, e if name == "" { name = field.Name } - candidates = append(candidates, acpJSONStructField{name, field.Type, depth, tagged}) + candidates = append(candidates, structField{name, field.Type, depth, tagged}) } // Match encoding/json's dominance rules for the selected plain structs: // shallower fields win; at equal depth a tagged field wins; ties are ignored. - var fields []acpJSONStructField + var fields []structField for i, candidate := range candidates { keep := true for j, other := range candidates { @@ -80,7 +80,7 @@ func acpJSONStructFields(value reflect.Type, depth int) ([]acpJSONStructField, e return fields, nil } -func acpJSONMatchField(fields []acpJSONStructField, name string) (string, reflect.Type) { +func matchField(fields []structField, name string) (string, reflect.Type) { for _, field := range fields { if field.name == name { return field.name, field.typeOf diff --git a/acp_json_fields_test.go b/internal/strictjson/fields_test.go similarity index 50% rename from acp_json_fields_test.go rename to internal/strictjson/fields_test.go index 0c7b429..53735d4 100644 --- a/acp_json_fields_test.go +++ b/internal/strictjson/fields_test.go @@ -1,21 +1,23 @@ -package main +package strictjson_test import ( - "bytes" "encoding/json" "strings" "testing" + + "github.com/orka-agents/agent-runtime-foundry/internal/foundry" + "github.com/orka-agents/agent-runtime-foundry/internal/strictjson" ) func TestACPStructFieldsKeepOpaqueValuesCaseSensitive(t *testing.T) { var value struct { - foundryOutputItem + foundry.OutputItem Status string `json:"status"` Map map[string]any `json:"map"` Any any `json:"any"` } data := []byte(`{"TyPe":"function_call","ſtatus":"completed","ArGuMeNtS":{"Key":1,"key":2},"Map":{"Key":3,"key":4},"Any":{"Key":5,"key":6},"unknown":{"Key":7,"key":8},"Unknown":null}`) - if err := acpDecodeStruct(data, &value, false); err != nil { + if err := strictjson.DecodeStruct(data, &value, false); err != nil { t.Fatal("single aliases or opaque case-sensitive values rejected") } if value.Type != "function_call" || value.Status != "completed" || @@ -29,7 +31,7 @@ func TestACPStructFieldsKeepOpaqueValuesCaseSensitive(t *testing.T) { `{"unknown":{"same":1,"same":2}}`, `{"arguments":"\ud800"}`, } { - if acpDecodeStruct([]byte(data), &value, false) == nil { + if strictjson.DecodeStruct([]byte(data), &value, false) == nil { t.Error("opaque value bypassed existing exact duplicate or Unicode validation") } } @@ -54,7 +56,7 @@ func TestACPStructFieldsRejectAliasesBeforeApplyingValues(t *testing.T) { Pointer *nested `json:"pointer"` } value.Status = "untouched" - if acpDecodeStruct([]byte(data), &value, false) == nil || value.Status != "untouched" || value.Pointer != nil || value.Values != nil { + if strictjson.DecodeStruct([]byte(data), &value, false) == nil || value.Status != "untouched" || value.Pointer != nil || value.Values != nil { t.Error("folded alias was accepted or applied before rejection") } } @@ -74,47 +76,17 @@ func TestACPStructFieldsPreserveDecoderCompatibility(t *testing.T) { ID string `json:"ID"` Status string `json:"status"` } - if acpDecode([]byte(data), &ordinary, false) != nil || acpDecodeStruct([]byte(data), &checked, false) != nil || ordinary != checked { + if strictjson.Decode([]byte(data), &ordinary, false) != nil || strictjson.DecodeStruct([]byte(data), &checked, false) != nil || ordinary != checked { t.Error("unambiguous fields differ from encoding/json") } } var object struct { Value json.RawMessage `json:"value"` } - if acpDecodeStruct([]byte(`{"unknown":true}`), &object, false) != nil || acpDecodeStruct([]byte(`{"unknown":true}`), &object, true) == nil { + if strictjson.DecodeStruct([]byte(`{"unknown":true}`), &object, false) != nil || strictjson.DecodeStruct([]byte(`{"unknown":true}`), &object, true) == nil { t.Error("unknown-field policy changed") } - if acpDecodeStruct([]byte(`{"value":`+strings.Repeat("[", 65)+strings.Repeat("]", 65)+`}`), &object, false) == nil { + if strictjson.DecodeStruct([]byte(`{"value":`+strings.Repeat("[", 65)+strings.Repeat("]", 65)+`}`), &object, false) == nil { t.Error("structured decoder lost the depth bound") } } - -func TestACPStructFieldsSingleAliasesPreserveResponseAndArguments(t *testing.T) { - data := []byte(`{"ID":"response-1","ſtatus":"completed","OuTpUt":[{"ID":"item-1","TyPe":"function_call","ſtatus":"completed","Name":"probe","CALL_ID":"call-1","Arguments":{"Key":1,"key":2}}]}`) - response, err := acpDecodeFoundryResponse(data) - if err != nil || response.Status != "completed" || len(response.Output) != 1 || - response.Output[0].Type != "function_call" || !bytes.Equal(response.Output[0].Arguments, []byte(`{"Key":1,"key":2}`)) { - t.Fatal("single response/item aliases changed case-sensitive arguments") - } -} - -func TestBrokerResponseEvidenceKeepsUnusableOutputOwnership(t *testing.T) { - const data = `{"id":"response-1","status":"completed","agent_session_id":"owned","output":[{"type":"web_search_call","Type":"function_call"}]}` - response, err := brokerDecodeResponseEvidence([]byte(data)) - if err != nil || response.ID != "response-1" || response.AgentSessionID != "owned" { - t.Fatal("coherent response ownership was discarded because output is unusable") - } - if _, err := acpDecodeFoundryResponse([]byte(data)); err == nil { - t.Fatal("ownership evidence admitted unusable output") - } - for _, data := range []string{ - `{"id":"wrong","ID":"response-1","status":"completed"}`, - `{"id":"response-1","status":"active","Status":"completed"}`, - `{"id":"response-1","status":"completed","agent_session_id":"wrong","Agent_Session_ID":"owned"}`, - `{"id":"response-1","status":"completed","error":{"code":"failure"},"Error":null}`, - } { - if _, err := brokerDecodeResponseEvidence([]byte(data)); err == nil { - t.Error("contradictory fields fabricated response ownership") - } - } -} diff --git a/responses.go b/responses.go deleted file mode 100644 index 8f3878d..0000000 --- a/responses.go +++ /dev/null @@ -1,571 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -const backendMetadata = "foundry-hosted-responses" - -type foundryResponsesClient struct { - cfg config - httpClient *http.Client - tokenProvider foundryTokenProvider -} - -func newResponsesClient(cfg config, httpClient *http.Client, provider foundryTokenProvider) *foundryResponsesClient { - return &foundryResponsesClient{cfg, httpClient, provider} -} - -type foundryResponseRequest struct { - Input any `json:"input"` - Stream bool `json:"stream"` - Store bool `json:"store"` - PreviousResponseID string `json:"previous_response_id,omitempty"` - AgentSessionID string `json:"agent_session_id,omitempty"` - Tools []foundryToolSchema `json:"tools,omitempty"` -} - -type foundryToolSchema struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters json.RawMessage `json:"parameters"` -} - -type foundryFunctionOutput struct { - Type string `json:"type"` - CallID string `json:"call_id"` - Output string `json:"output"` -} - -type foundryResponseEvent struct { - Type string `json:"type"` - Delta string `json:"delta,omitempty"` - SequenceNumber int64 `json:"sequence_number,omitempty"` - Response *foundryResponse `json:"response,omitempty"` - Item *foundryOutputItem `json:"item,omitempty"` - Error *foundryError `json:"error,omitempty"` -} - -type foundryResponse struct { - ID string `json:"id"` - Status string `json:"status"` - AgentSessionID string `json:"agent_session_id,omitempty"` - Output []foundryOutputItem `json:"output,omitempty"` - Error *foundryError `json:"error,omitempty"` - Incomplete *foundryIncomplete `json:"incomplete_details,omitempty"` -} - -type foundryOutputItem struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - CallID string `json:"call_id,omitempty"` - Name string `json:"name,omitempty"` - Arguments json.RawMessage `json:"arguments,omitempty"` - Content []foundryOutputContent `json:"content,omitempty"` -} - -type foundryOutputContent struct { - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` -} - -type foundryError struct { - Type string `json:"type,omitempty"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - Param string `json:"param,omitempty"` -} - -type foundryIncomplete struct { - Reason string `json:"reason,omitempty"` -} - -type foundryStreamSummary struct { - ResponseID string - AgentSessionID string - Status string - Text string - FunctionCalls []foundryOutputItem - Error *foundryError - Incomplete *foundryIncomplete -} - -type responseCallbacks struct { - OnCreated func(foundryResponse) error - OnTextDelta func(string) error - OnFunctionCall func(foundryOutputItem) error -} - -type providerHTTPError struct { - StatusCode int - Operation string -} - -func (e providerHTTPError) Error() string { - return fmt.Sprintf("Foundry %s failed with HTTP %d", e.Operation, e.StatusCode) -} - -func newFoundryHTTPClient(endpoint string) *http.Client { - base, _ := url.Parse(strings.TrimSpace(endpoint)) - return &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return errors.New("stopped after 10 Foundry redirects") - } - if base == nil || !strings.EqualFold(req.URL.Scheme, base.Scheme) || - !strings.EqualFold(req.URL.Host, base.Host) { - return errors.New("refusing Foundry redirect outside the configured origin") - } - return nil - }, - } -} - -func (c *foundryResponsesClient) createResponse( - ctx context.Context, - request foundryResponseRequest, - callbacks responseCallbacks, -) (foundryStreamSummary, error) { - request.Stream = true - request.Store = true - body, err := json.Marshal(request) - if err != nil { - return foundryStreamSummary{}, err - } - response, err := c.do(ctx, http.MethodPost, c.responsesURL(), bytes.NewReader(body)) - if err != nil { - return foundryStreamSummary{}, err - } - defer response.Body.Close() //nolint:errcheck - mediaType := strings.ToLower(response.Header.Get("Content-Type")) - if strings.Contains(mediaType, "text/event-stream") { - return parseFoundrySSE(response.Body, c.cfg.maxStreamBytes, c.cfg.maxEventBytes, c.cfg.maxEvents, callbacks) - } - return parseFoundryJSON(response.Body, c.cfg.maxStreamBytes, callbacks) -} - -func (c *foundryResponsesClient) createSession(ctx context.Context) (string, error) { - body := []byte(`{}`) - if c.cfg.agentVersion != "" { - encoded, err := json.Marshal(map[string]any{ - "version_indicator": map[string]string{ - "type": "version_ref", - "agent_version": c.cfg.agentVersion, - }, - }) - if err != nil { - return "", err - } - body = encoded - } - response, err := c.do(ctx, http.MethodPost, c.sessionsURL(), bytes.NewReader(body)) - if err != nil { - return "", err - } - defer response.Body.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(response.Body, c.cfg.maxEventBytes+1)) - if err != nil { - return "", err - } - if int64(len(data)) > c.cfg.maxEventBytes { - return "", errors.New("foundry session response exceeded adapter limit") - } - var payload struct { - AgentSessionID string `json:"agent_session_id"` - SessionID string `json:"session_id"` - ID string `json:"id"` - } - if err := json.Unmarshal(data, &payload); err != nil { - return "", errors.New("foundry session response was invalid JSON") - } - sessionID := firstNonBlank(payload.AgentSessionID, payload.SessionID, payload.ID) - if sessionID == "" { - return "", errors.New("foundry session response did not include a session id") - } - if err := validateProviderIdentifier("agent session id", sessionID); err != nil { - return "", err - } - return sessionID, nil -} - -func (c *foundryResponsesClient) validateAgent(ctx context.Context) error { - agentURL, err := c.agentURL() - if err != nil { - return err - } - response, err := c.do(ctx, http.MethodGet, agentURL, nil) - if err != nil { - return err - } - defer response.Body.Close() //nolint:errcheck - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - if c.cfg.agentVersion == "" { - return nil - } - versionURL, err := c.agentVersionURL() - if err != nil { - return err - } - versionResponse, err := c.do(ctx, http.MethodGet, versionURL, nil) - if err != nil { - return err - } - defer versionResponse.Body.Close() //nolint:errcheck - data, err := io.ReadAll(io.LimitReader(versionResponse.Body, c.cfg.maxEventBytes+1)) - if err != nil { - return err - } - if int64(len(data)) > c.cfg.maxEventBytes { - return errors.New("foundry agent-version response exceeded adapter limit") - } - var version struct { - Status string `json:"status"` - } - if err := json.Unmarshal(data, &version); err != nil { - return errors.New("foundry agent-version response was invalid JSON") - } - if !strings.EqualFold(strings.TrimSpace(version.Status), "active") { - return errors.New("configured Foundry agent version is not active") - } - return nil -} - -func (c *foundryResponsesClient) do(ctx context.Context, method, rawURL string, body io.Reader) (*http.Response, error) { - if c == nil || c.httpClient == nil || c.tokenProvider == nil { - return nil, errors.New("foundry client is not configured") - } - request, err := http.NewRequestWithContext(ctx, method, rawURL, body) - if err != nil { - return nil, err - } - if body != nil { - request.Header.Set("Content-Type", "application/json") - } - request.Header.Set("Accept", "text/event-stream, application/json") - if c.cfg.foundryFeatures != "" { - request.Header.Set("Foundry-Features", c.cfg.foundryFeatures) - } - if strings.EqualFold(c.cfg.isolationMode, "header") { - isolationKey, ok := foundryIsolationKeyFromContext(ctx) - if !ok || isolationKey == "" { - return nil, errors.New("foundry header isolation requires a scoped isolation key") - } - request.Header.Set("x-ms-user-isolation-key", isolationKey) - } - token, err := c.tokenProvider.AccessToken(ctx) - if err != nil { - return nil, err - } - request.Header.Set("Authorization", "Bearer "+token) - response, err := c.httpClient.Do(request) - if err != nil { - return nil, err - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - defer response.Body.Close() //nolint:errcheck - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - return nil, providerHTTPError{StatusCode: response.StatusCode, Operation: method + " " + request.URL.Path} - } - return response, nil -} - -func (c *foundryResponsesClient) responsesURL() string { - if c.cfg.responsesEndpoint != "" { - return withAPIVersion(c.cfg.responsesEndpoint, c.cfg.apiVersion) - } - base := strings.TrimRight(c.cfg.projectEndpoint, "/") + "/agents/" + url.PathEscape(c.cfg.agentName) + - "/endpoint/protocols/openai/responses" - return withAPIVersion(base, c.cfg.apiVersion) -} - -func (c *foundryResponsesClient) sessionsURL() string { - base := c.cfg.projectEndpoint - if base == "" { - u, _ := url.Parse(c.cfg.responsesEndpoint) - suffix := "/agents/" + url.PathEscape(c.cfg.agentName) + "/endpoint/protocols/openai/responses" - base = strings.TrimSuffix(strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/"), suffix) - } - return withAPIVersion(strings.TrimRight(base, "/")+"/agents/"+url.PathEscape(c.cfg.agentName)+"/endpoint/sessions", c.cfg.apiVersion) -} - -func (c *foundryResponsesClient) agentURL() (string, error) { - if c.cfg.projectEndpoint == "" { - return "", errors.New("foundry project endpoint is required for readiness validation") - } - return withAPIVersion(strings.TrimRight(c.cfg.projectEndpoint, "/")+"/agents/"+url.PathEscape(c.cfg.agentName), c.cfg.apiVersion), nil -} - -func (c *foundryResponsesClient) agentVersionURL() (string, error) { - agentURL, err := c.agentURL() - if err != nil { - return "", err - } - u, err := url.Parse(agentURL) - if err != nil { - return "", err - } - u.Path = strings.TrimRight(u.Path, "/") + "/versions/" + url.PathEscape(c.cfg.agentVersion) - return u.String(), nil -} - -func parseFoundryJSON(r io.Reader, maxBytes int64, callbacks responseCallbacks) (foundryStreamSummary, error) { - data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) - if err != nil { - return foundryStreamSummary{}, err - } - if int64(len(data)) > maxBytes { - return foundryStreamSummary{}, errors.New("foundry response exceeded adapter stream limit") - } - var response foundryResponse - if err := json.Unmarshal(data, &response); err != nil { - return foundryStreamSummary{}, errors.New("foundry response was invalid JSON") - } - return processCompletedResponse(response, callbacks) -} - -func parseFoundrySSE( - r io.Reader, - maxBytes int64, - maxEventBytes int64, - maxEvents int, - callbacks responseCallbacks, -) (foundryStreamSummary, error) { - reader := bufio.NewReader(io.LimitReader(r, maxBytes+1)) - var summary foundryStreamSummary - var total int64 - var eventData []byte - var eventCount int - flushEvent := func() error { - if len(eventData) == 0 { - return nil - } - eventCount++ - if eventCount > maxEvents { - return errors.New("foundry response exceeded adapter event limit") - } - if int64(len(eventData)) > maxEventBytes { - return errors.New("foundry response event exceeded adapter limit") - } - if bytes.Equal(bytes.TrimSpace(eventData), []byte("[DONE]")) { - eventData = nil - return nil - } - var event foundryResponseEvent - if err := json.Unmarshal(eventData, &event); err != nil { - return errors.New("foundry response stream contained invalid JSON") - } - eventData = nil - return applyFoundryEvent(&summary, event, callbacks) - } - for { - line, err := reader.ReadBytes('\n') - total += int64(len(line)) - if total > maxBytes { - return foundryStreamSummary{}, errors.New("foundry response exceeded adapter stream limit") - } - trimmed := bytes.TrimRight(line, "\r\n") - if len(trimmed) == 0 { - if err := flushEvent(); err != nil { - return foundryStreamSummary{}, err - } - } else if rawData, ok := bytes.CutPrefix(trimmed, []byte("data:")); ok { - part := bytes.TrimSpace(rawData) - if len(eventData)+len(part)+1 > int(maxEventBytes) { - return foundryStreamSummary{}, errors.New("foundry response event exceeded adapter limit") - } - if len(eventData) > 0 { - eventData = append(eventData, '\n') - } - eventData = append(eventData, part...) - } - if err != nil { - if !errors.Is(err, io.EOF) { - return foundryStreamSummary{}, err - } - if flushErr := flushEvent(); flushErr != nil { - return foundryStreamSummary{}, flushErr - } - break - } - } - if summary.Status == "" { - return foundryStreamSummary{}, errors.New("foundry response stream ended without a terminal event") - } - return summary, nil -} - -func applyFoundryEvent(summary *foundryStreamSummary, event foundryResponseEvent, callbacks responseCallbacks) error { - switch event.Type { - case "response.created", "response.in_progress", "response.queued": - if event.Response != nil { - mergeFoundryResponse(summary, *event.Response) - if event.Type == "response.created" && callbacks.OnCreated != nil { - return callbacks.OnCreated(*event.Response) - } - } - case "response.output_text.delta": - summary.Text += event.Delta - if callbacks.OnTextDelta != nil && event.Delta != "" { - return callbacks.OnTextDelta(event.Delta) - } - case "response.output_item.done": - if event.Item != nil && event.Item.Type == "function_call" { - summary.FunctionCalls = append(summary.FunctionCalls, *event.Item) - if callbacks.OnFunctionCall != nil { - return callbacks.OnFunctionCall(*event.Item) - } - } - case "response.completed", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - if event.Response != nil { - mergeFoundryResponse(summary, *event.Response) - if event.Type == "response.completed" { - if err := applyTerminalOutputFallback(summary, *event.Response, callbacks); err != nil { - return err - } - } - } - if summary.Status == "" { - summary.Status = strings.TrimPrefix(event.Type, "response.") - } - case "error": - summary.Status = "failed" - summary.Error = event.Error - } - return nil -} - -func applyTerminalOutputFallback( - summary *foundryStreamSummary, - response foundryResponse, - callbacks responseCallbacks, -) error { - var terminalText strings.Builder - for _, item := range response.Output { - if item.Type != "message" { - continue - } - for _, content := range item.Content { - terminalText.WriteString(content.Text) - } - } - fullText := terminalText.String() - if fullText != "" { - if !strings.HasPrefix(fullText, summary.Text) { - return errors.New("foundry terminal output does not match streamed text") - } - remainder := strings.TrimPrefix(fullText, summary.Text) - if remainder != "" { - summary.Text += remainder - if callbacks.OnTextDelta != nil { - if err := callbacks.OnTextDelta(remainder); err != nil { - return err - } - } - } - } - for _, item := range response.Output { - if item.Type != "function_call" { - continue - } - seen, conflict := foundryFunctionCallState(summary.FunctionCalls, item) - if conflict { - return fmt.Errorf("foundry function call %q changed within one response", item.CallID) - } - if seen { - continue - } - summary.FunctionCalls = append(summary.FunctionCalls, item) - if callbacks.OnFunctionCall != nil { - if err := callbacks.OnFunctionCall(item); err != nil { - return err - } - } - } - return nil -} - -func foundryFunctionCallState(calls []foundryOutputItem, item foundryOutputItem) (bool, bool) { - for _, call := range calls { - if call.CallID != item.CallID { - continue - } - return true, call.Name != item.Name || !bytes.Equal(call.Arguments, item.Arguments) - } - return false, false -} - -func processCompletedResponse(response foundryResponse, callbacks responseCallbacks) (foundryStreamSummary, error) { - summary := foundryStreamSummary{} - mergeFoundryResponse(&summary, response) - if callbacks.OnCreated != nil { - if err := callbacks.OnCreated(response); err != nil { - return foundryStreamSummary{}, err - } - } - if !strings.EqualFold(response.Status, "completed") { - return summary, nil - } - for _, item := range response.Output { - switch item.Type { - case "message": - for _, content := range item.Content { - if content.Text == "" { - continue - } - summary.Text += content.Text - if callbacks.OnTextDelta != nil { - if err := callbacks.OnTextDelta(content.Text); err != nil { - return foundryStreamSummary{}, err - } - } - } - case "function_call": - summary.FunctionCalls = append(summary.FunctionCalls, item) - if callbacks.OnFunctionCall != nil { - if err := callbacks.OnFunctionCall(item); err != nil { - return foundryStreamSummary{}, err - } - } - } - } - return summary, nil -} - -func mergeFoundryResponse(summary *foundryStreamSummary, response foundryResponse) { - if response.ID != "" { - summary.ResponseID = response.ID - } - if response.AgentSessionID != "" { - summary.AgentSessionID = response.AgentSessionID - } - if response.Status != "" { - summary.Status = response.Status - } - if response.Error != nil { - summary.Error = response.Error - } - if response.Incomplete != nil { - summary.Incomplete = response.Incomplete - } -} - -func withAPIVersion(rawURL, apiVersion string) string { - u, err := url.Parse(rawURL) - if err != nil || apiVersion == "" { - return rawURL - } - query := u.Query() - query.Set("api-version", apiVersion) - u.RawQuery = query.Encode() - return u.String() -} diff --git a/responses_test.go b/responses_test.go deleted file mode 100644 index b28b899..0000000 --- a/responses_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/orka-agents/agent-runtime-foundry/internal/harness" -) - -type staticFoundryTokenProvider string - -func (p staticFoundryTokenProvider) AccessToken(context.Context) (string, error) { - return string(p), nil -} - -func TestParseFoundrySSETextAndCompletion(t *testing.T) { - stream := strings.Join([]string{ - `data: {"type":"response.created","response":{"id":"resp-1","status":"in_progress","agent_session_id":"session-1"}}`, - "", - `data: {"type":"response.output_text.delta","delta":"hello "}`, - "", - `data: {"type":"response.output_text.delta","delta":"world"}`, - "", - `data: {"type":"response.completed","response":{"id":"resp-1","status":"completed","agent_session_id":"session-1"}}`, - "", - }, "\n") - var deltas []string - summary, err := parseFoundrySSE(strings.NewReader(stream), 1<<20, 1<<16, 32, responseCallbacks{ - OnTextDelta: func(delta string) error { - deltas = append(deltas, delta) - return nil - }, - }) - if err != nil { - t.Fatalf("parseFoundrySSE: %v", err) - } - if summary.ResponseID != "resp-1" || summary.AgentSessionID != "session-1" || summary.Status != "completed" { - t.Fatalf("summary = %#v", summary) - } - if got := strings.Join(deltas, ""); got != "hello world" { - t.Fatalf("deltas = %q", got) - } -} - -func TestParseFoundrySSEFunctionCall(t *testing.T) { - stream := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"lookup\",\"arguments\":\"{\\\"id\\\":\\\"1\\\"}\"}}\n\n" + - "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}\n\n" - var calls []foundryOutputItem - summary, err := parseFoundrySSE(strings.NewReader(stream), 1<<20, 1<<16, 32, responseCallbacks{ - OnFunctionCall: func(call foundryOutputItem) error { - calls = append(calls, call) - return nil - }, - }) - if err != nil { - t.Fatalf("parseFoundrySSE: %v", err) - } - if len(calls) != 1 || calls[0].CallID != "call-1" || calls[0].Name != "lookup" { - t.Fatalf("calls = %#v", calls) - } - if len(summary.FunctionCalls) != 1 { - t.Fatalf("summary calls = %#v", summary.FunctionCalls) - } -} - -func TestParseFoundryJSONFailedAndIncomplete(t *testing.T) { - failed, err := parseFoundryJSON(strings.NewReader(`{"id":"resp-f","status":"failed","error":{"code":"server_error"}}`), 1<<20, responseCallbacks{}) - if err != nil { - t.Fatalf("failed parse: %v", err) - } - if failed.Status != "failed" || failed.Error == nil || failed.Error.Code != "server_error" { - t.Fatalf("failed = %#v", failed) - } - incomplete, err := parseFoundryJSON(strings.NewReader(`{"id":"resp-i","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`), 1<<20, responseCallbacks{}) - if err != nil { - t.Fatalf("incomplete parse: %v", err) - } - if incomplete.Status != "incomplete" || incomplete.Incomplete == nil || incomplete.Incomplete.Reason != "max_output_tokens" { - t.Fatalf("incomplete = %#v", incomplete) - } -} - -func TestParseFoundrySSERejectsMalformedAndOversizedStreams(t *testing.T) { - if _, err := parseFoundrySSE(strings.NewReader("data: {not-json}\n\n"), 1024, 512, 8, responseCallbacks{}); err == nil { - t.Fatal("expected malformed stream error") - } - large := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"" + strings.Repeat("x", 1024) + "\"}\n\n" - if _, err := parseFoundrySSE(strings.NewReader(large), 256, 2048, 8, responseCallbacks{}); err == nil { - t.Fatal("expected oversized stream error") - } - if _, err := parseFoundrySSE(strings.NewReader("data: {\"type\":\"response.output_text.delta\",\"delta\":\"x\"}\n\n"), 1024, 512, 8, responseCallbacks{}); err == nil { - t.Fatal("expected missing terminal event error") - } -} - -func TestFoundryResponsesClientRequestShapeAndHeaders(t *testing.T) { - var requestBody map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/projects/demo/agents/hosted-agent/endpoint/protocols/openai/responses" { - t.Fatalf("path = %q", r.URL.Path) - } - if r.URL.Query().Get("api-version") != "v1" { - t.Fatalf("api-version = %q", r.URL.Query().Get("api-version")) - } - if got := r.Header.Get("Authorization"); got != "Bearer mock-token" { - t.Fatalf("authorization = %q", got) - } - if got := r.Header.Get("Foundry-Features"); got != "HostedAgents=V1Preview" { - t.Fatalf("feature header = %q", got) - } - if got := r.Header.Get("x-ms-user-isolation-key"); got != "isolation-1" { - t.Fatalf("isolation header = %q", got) - } - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - t.Fatalf("decode request: %v", err) - } - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-2\",\"status\":\"in_progress\",\"agent_session_id\":\"session-2\"}}\n\n") - _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"status\":\"completed\",\"agent_session_id\":\"session-2\"}}\n\n") - })) - defer server.Close() - - cfg := testConfig(server.URL) - cfg.isolationMode = "header" - client := testResponsesClient(cfg, server.URL) - ctx := withFoundryIsolationKey(context.Background(), "isolation-1") - _, err := client.createResponse(ctx, foundryResponseRequest{ - Input: []foundryFunctionOutput{{Type: "function_call_output", CallID: "call-1", Output: `{"ok":true}`}}, - PreviousResponseID: "resp-1", - AgentSessionID: "session-1", - Tools: []foundryToolSchema{{Type: "function", Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}, - }, responseCallbacks{}) - if err != nil { - t.Fatalf("createResponse: %v", err) - } - if requestBody["stream"] != true || requestBody["store"] != true || requestBody["previous_response_id"] != "resp-1" || requestBody["agent_session_id"] != "session-1" { - t.Fatalf("request body = %#v", requestBody) - } - input, ok := requestBody["input"].([]any) - if !ok || len(input) != 1 || input[0].(map[string]any)["type"] != "function_call_output" { - t.Fatalf("input = %#v", requestBody["input"]) - } - tools, ok := requestBody["tools"].([]any) - if !ok || len(tools) != 1 || tools[0].(map[string]any)["name"] != "lookup" { - t.Fatalf("tools = %#v", requestBody["tools"]) - } -} - -func TestFoundryResponsesClientVersionValidation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/projects/demo/agents/hosted-agent": - _, _ = io.WriteString(w, `{"name":"hosted-agent"}`) - case "/api/projects/demo/agents/hosted-agent/versions/2": - _, _ = io.WriteString(w, `{"status":"active"}`) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - cfg := testConfig(server.URL) - cfg.agentVersion = "2" - client := testResponsesClient(cfg, server.URL) - if err := client.validateAgent(context.Background()); err != nil { - t.Fatalf("validateAgent: %v", err) - } -} - -func TestProviderSafeMessageRedactsOpaqueFailures(t *testing.T) { - if got := providerSafeMessage(errors.New("token is super-secret")); got != "Foundry request failed" { - t.Fatalf("message = %q", got) - } -} - -func testConfig(baseURL string) config { - return config{ - addr: ":0", - runtimeName: "foundry-test", - adapterBearer: "adapter-token", - projectEndpoint: baseURL + "/api/projects/demo", - agentName: "hosted-agent", - apiVersion: "v1", - turnTimeout: 5 * time.Second, - isolationMode: "entra", - foundryFeatures: "HostedAgents=V1Preview", - maxOutputBytes: 1 << 20, - maxStreamBytes: 1 << 20, - maxEventBytes: 1 << 16, - maxBrokeredBytes: 1 << 16, - maxBrokeredTurnBytes: 1 << 20, - maxBrokeredCalls: 128, - maxEvents: 128, - maxConcurrent: 1, - brokeredToolClasses: []harness.BrokeredToolClass{ - harness.BrokeredToolClassRead, - harness.BrokeredToolClassWrite, - }, - } -} - -func testResponsesClient(cfg config, serverURL string) *foundryResponsesClient { - return &foundryResponsesClient{cfg, newFoundryHTTPClient(serverURL), staticFoundryTokenProvider("mock-token")} -} - -func TestParseFoundrySSETerminalOutputFallback(t *testing.T) { - stream := strings.Join([]string{ - `data: {"type":"response.created","response":{"id":"resp-fallback","status":"in_progress"}}`, - "", - `data: {"type":"response.completed","response":{"id":"resp-fallback","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"fallback text"}]},{"type":"function_call","call_id":"call-fallback","name":"lookup","arguments":"{}"}]}}`, - "", - }, "\n") - var text strings.Builder - var calls []foundryOutputItem - summary, err := parseFoundrySSE(strings.NewReader(stream), 1<<20, 1<<16, 32, responseCallbacks{ - OnTextDelta: func(delta string) error { - text.WriteString(delta) - return nil - }, - OnFunctionCall: func(call foundryOutputItem) error { - calls = append(calls, call) - return nil - }, - }) - if err != nil { - t.Fatalf("parseFoundrySSE: %v", err) - } - if text.String() != "fallback text" || summary.Text != "fallback text" { - t.Fatalf("text callback=%q summary=%q", text.String(), summary.Text) - } - if len(calls) != 1 || calls[0].CallID != "call-fallback" { - t.Fatalf("calls = %#v", calls) - } -} diff --git a/store_initialization_test.go b/store_initialization_test.go deleted file mode 100644 index ef132b5..0000000 --- a/store_initialization_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "syscall" - "testing" -) - -type storeInitializationFixture struct { - lockName string - digest string - open func(string) (func(), string, error) - save func(string) error -} - -func storeInitializationFixtures(t *testing.T) map[string]storeInitializationFixture { - t.Helper() - digest := brokerSHA([]byte("durable-broker-fixture")) - c := brokerTestContext(brokerConfiguration{configDigest: digest}) - brokerLedger := &brokerLedger{Version: 1, ConfigDigest: digest, Sessions: map[string]*brokerSession{ - brokerJSONDigest(c.Owner): {Owner: c.Owner, CreateState: "none", Retiring: true, Retired: true, - ProofDigest: brokerSHA([]byte("retired-owner")), Prompts: map[string]*brokerPrompt{}, - Responses: map[string]brokerResponseID{}, Operations: map[string]string{}}, - }} - if !brokerLedgerValid(brokerLedger, digest) { - t.Fatal("invalid retired broker fixture") - } - cfg, exposed := hostedBoundaryLedgerFixture(t) - return map[string]storeInitializationFixture{ - "broker": { - lockName: "broker.lock", digest: brokerJSONDigest(brokerLedger), - open: func(dir string) (func(), string, error) { - store, ledger, err := openBrokerStore(dir, digest) - if err != nil { - return nil, "", err - } - return store.close, brokerJSONDigest(ledger), nil - }, - save: func(dir string) error { return (&brokerStore{dir: dir}).save(brokerLedger) }, - }, - "gateway": { - lockName: "gateway.lock", digest: brokerJSONDigest(exposed), - open: func(dir string) (func(), string, error) { - store, ledger, err := openHostedGatewayStore(dir, cfg) - if err != nil { - return nil, "", err - } - return store.close, brokerJSONDigest(ledger), nil - }, - save: func(dir string) error { return (&hostedGatewayStore{dir: dir}).save(exposed) }, - }, - } -} - -func TestDurableStoreMissingLedgerFailsClosed(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - closeStore, _, err := fixture.open(dir) - if err != nil { - t.Fatal("could not initialize ownership fixture") - } - err = fixture.save(dir) - closeStore() - if err != nil { - t.Fatal("could not persist owned lifetime") - } - lockPath := filepath.Join(dir, fixture.lockName) - lockInfo, err := os.Lstat(lockPath) - if err != nil { - t.Fatal("ownership witness is missing") - } - statePath := filepath.Join(dir, "state.json") - retained := filepath.Join(t.TempDir(), "retained-state.json") - if os.Rename(statePath, retained) != nil { - t.Fatal("could not simulate missing ownership state") - } - for range 2 { - closeStore, _, err = fixture.open(dir) - if err == nil { - closeStore() - t.Fatal("missing ownership ledger was silently reinitialized") - } - if _, err := os.Lstat(statePath); !os.IsNotExist(err) { - t.Fatal("rejected recovery recreated state") - } - after, err := os.Lstat(lockPath) - if err != nil || !os.SameFile(lockInfo, after) { - t.Fatal("rejected recovery replaced its initialization witness") - } - } - if os.Rename(retained, statePath) != nil { - t.Fatal("could not restore exact original ownership") - } - closeStore, restored, err := fixture.open(dir) - if err != nil { - t.Fatal("valid original ownership could not recover") - } - closeStore() - if restored != fixture.digest { - t.Fatal("recovery changed the owned lifetime") - } - }) - } -} - -func TestDurableStoreEmptyDirectoryAndLegacyRecovery(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - if os.Mkdir(dir, 0o700) != nil { - t.Fatal("could not create private pre-existing directory") - } - closeStore, _, err := fixture.open(dir) - if err != nil { - t.Fatal("pre-existing empty directory could not initialize") - } - err = fixture.save(dir) - closeStore() - if err != nil { - t.Fatal("could not persist owned lifetime") - } - for _, missingLock := range []bool{false, true} { - if missingLock && os.Remove(filepath.Join(dir, fixture.lockName)) != nil { - t.Fatal("could not prepare valid ledger without a lock") - } - closeStore, restored, err := fixture.open(dir) - if err != nil { - t.Fatal("valid existing ownership could not recover") - } - closeStore() - if restored != fixture.digest { - t.Fatal("existing ownership was rebound during recovery") - } - } - }) - } -} - -func TestDurableStoreInitializerRace(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - if os.Mkdir(dir, 0o700) != nil { - t.Fatal("could not create private initializer directory") - } - // Pause the original creator after exclusive lock-file creation, - // before flock. A competing opener can acquire flock first, but - // it must not claim the original creator's initialization rights. - witness, err := os.OpenFile(filepath.Join(dir, fixture.lockName), os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) - if err != nil { - t.Fatal("could not pause original initialization") - } - defer witness.Close() - closeStore, _, err := fixture.open(dir) - if err == nil { - closeStore() - t.Fatal("competing opener initialized another process's store") - } - if _, err := os.Lstat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) { - t.Fatal("competing opener published a ledger") - } - // Resume the original creator using its same descriptor. The - // rejected contender must release flock and preserve that inode. - if syscall.Flock(int(witness.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { - t.Fatal("rejected contender retained the initialization lock") - } - if fixture.save(dir) != nil { - t.Fatal("original creator could not publish its ledger") - } - if syscall.Flock(int(witness.Fd()), syscall.LOCK_UN) != nil { - t.Fatal("original creator could not release the store") - } - closeStore, restored, err := fixture.open(dir) - if err != nil { - t.Fatal("competing opener could not read the completed original store") - } - closeStore() - if restored != fixture.digest { - t.Fatal("competing opener replaced original ownership") - } - }) - } -} diff --git a/store_initializer_race_test.go b/store_initializer_race_test.go deleted file mode 100644 index a902c36..0000000 --- a/store_initializer_race_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "syscall" - "testing" - "time" -) - -func TestStoreInitializerKeepsCreatorAcrossFlockContention(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - if err := os.Mkdir(dir, 0o700); err != nil { - t.Fatal("could not create private initializer directory") - } - path := filepath.Join(dir, fixture.lockName) - // Pause creator A after its successful O_EXCL, before the exact - // acquisition helper used by openStoreLock. B opens A's same inode. - creator, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) - if err != nil { - t.Fatal("could not create initialization witness") - } - defer creator.Close() - original, err := creator.Stat() - if err != nil { - t.Fatal("could not inspect creator witness") - } - contender, created, err := openStoreLock(dir, fixture.lockName, syncStoreDirectory) - if err != nil || contender == nil || created { - t.Fatal("contender did not acquire the existing initialization inode") - } - defer contender.Close() - contenderInfo, err := contender.Stat() - if err != nil || !os.SameFile(original, contenderInfo) { - t.Fatal("contender did not lock the creator's exact inode") - } - if _, err := os.Lstat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) { - t.Fatal("contender found unexpected ownership state") - } - started := make(chan struct{}) - acquired := make(chan error, 1) - go func() { - close(started) - acquired <- flockStoreFile(creator, true) - }() - <-started - select { - case err := <-acquired: - // This is the original bug: A abandons O_EXCL authority while - // B is still checking the absent ledger. Neither can initialize. - _ = contender.Close() - _ = creator.Close() - for range 2 { - closeStore, _, openErr := fixture.open(dir) - if openErr == nil { - closeStore() - t.Fatal("a later opener fabricated absent initialization authority") - } - } - if errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatal("exclusive creator lost authority to transient flock contention; later openers remain stranded") - } - t.Fatal("creator completed acquisition while a contender still held the inode") - case <-time.After(75 * time.Millisecond): - // B has no O_EXCL authority. Its caller rejects the absent - // ledger and closes; A must keep its descriptor while waiting. - } - if err := contender.Close(); err != nil { - t.Fatal("could not release the rejected contender") - } - select { - case err := <-acquired: - if err != nil { - t.Fatal("original creator could not resume after contender release") - } - case <-time.After(2 * time.Second): - t.Fatal("original creator remained blocked after contender release") - } - after, err := creator.Stat() - pathInfo, pathErr := os.Lstat(path) - if err != nil || pathErr != nil || !os.SameFile(original, after) || !os.SameFile(original, pathInfo) { - t.Fatal("creator replaced or reopened the initialization witness") - } - if err := fixture.save(dir); err != nil { - t.Fatal("retained creator could not publish original ownership") - } - if err := creator.Close(); err != nil { - t.Fatal("could not close initialized creator") - } - closeStore, restored, err := fixture.open(dir) - if err != nil { - t.Fatal("completed original initialization could not recover") - } - closeStore() - if restored != fixture.digest { - t.Fatal("recovery replaced the original ownership ledger") - } - }) - } -} - -func TestStoreInitializerExistingWriterRemainsNonblocking(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - closeWriter, _, err := fixture.open(dir) - if err != nil { - t.Fatal("could not open original writer") - } - defer closeWriter() - if fixture.save(dir) != nil { - t.Fatal("could not preserve original ownership") - } - finished := make(chan error, 1) - go func() { - other, created, err := openStoreLock(dir, fixture.lockName, syncStoreDirectory) - if other != nil { - _ = other.Close() - } - if other != nil || created { - finished <- errors.New("existing writer was not excluded") - return - } - finished <- err - }() - select { - case err := <-finished: - if !errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatal("existing-inode writer did not fail with nonblocking contention") - } - case <-time.After(2 * time.Second): - t.Fatal("existing-inode writer waited for the active owner") - } - closeWriter() - closeRecovered, digest, err := fixture.open(dir) - if err != nil { - t.Fatal("original ownership did not recover after active writer closed") - } - closeRecovered() - if digest != fixture.digest { - t.Fatal("excluded writer changed original ownership") - } - }) - } -} - -func TestStoreInitializerLegacyRecoveryRemainsNonblocking(t *testing.T) { - for name, fixture := range storeInitializationFixtures(t) { - t.Run(name, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ledger") - if os.Mkdir(dir, 0o700) != nil || fixture.save(dir) != nil { - t.Fatal("could not prepare valid legacy ownership without a lock") - } - statePath := filepath.Join(dir, "state.json") - before, err := os.ReadFile(statePath) - if err != nil { - t.Fatal("could not inspect original legacy ownership") - } - path := filepath.Join(dir, fixture.lockName) - // A creates the missing lock for a valid legacy ledger, then B - // recovers that ledger on the same inode before A calls flock. - creator, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) - if err != nil { - t.Fatal("could not create the legacy recovery lock") - } - defer creator.Close() - original, err := creator.Stat() - if err != nil { - t.Fatal("could not inspect the legacy recovery lock") - } - closeWriter, restored, err := fixture.open(dir) - if err != nil || restored != fixture.digest { - if closeWriter != nil { - closeWriter() - } - t.Fatal("contender could not recover the original legacy ledger") - } - defer closeWriter() - contenderInfo, err := os.Lstat(path) - if err != nil || !os.SameFile(original, contenderInfo) { - t.Fatal("contender replaced the creator's lock inode") - } - finished := make(chan error, 1) - go func() { finished <- flockStoreFile(creator, true) }() - select { - case err := <-finished: - if !errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatal("new legacy recovery lock did not fail with nonblocking contention") - } - case <-time.After(2 * time.Second): - // Release B only after proving A waited behind a valid owner. - // Join A so the regression never leaks a blocked syscall. - closeWriter() - select { - case <-finished: - case <-time.After(2 * time.Second): - t.Fatal("legacy recovery acquisition did not finish after owner release") - } - t.Fatal("newly created legacy recovery lock waited behind the retained ledger owner") - } - after, err := os.ReadFile(statePath) - if err != nil || !bytes.Equal(before, after) { - t.Fatal("excluded legacy recovery changed original ownership") - } - pathInfo, err := os.Lstat(path) - if err != nil || !os.SameFile(original, pathInfo) { - t.Fatal("excluded legacy recovery replaced the lock inode") - } - closeWriter() - closeRecovered, restored, err := fixture.open(dir) - if err != nil { - t.Fatal("legacy ownership could not recover after the active owner closed") - } - closeRecovered() - if restored != fixture.digest { - t.Fatal("legacy recovery changed the owned lifetime") - } - }) - } -}