Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+workload-endpoint-identifiers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Changed
body: Require workload endpoint names to use one shared Docker-style component grammar across blueprint resolution and controlled-session authorization.
9 changes: 8 additions & 1 deletion docs/BLUEPRINT_ENVIRONMENT_MODEL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
status: Active
updated: 2026-08-02
updated: 2026-08-08
summary: Normative blueprint environment, workload, application, provider contribution, lifecycle, and Docker rendering model.
supersedes: docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md
---
Expand Down Expand Up @@ -2094,6 +2094,13 @@ could use a structured configuration system.
cycles are not possible with the initial environment-to-backend-only rule,
implementations should still reject them rather than recurse.

Workload endpoint names use one Docker Distribution image-name path component:
lowercase alphanumeric segments separated by `.`, `_`, `__`, or one or more
`-`, with a maximum length of 128 bytes. Names such as `api_v1`, `api.v1`,
`api--v1`, and `2fa` are valid. Full image-reference syntax such as `/`, `:`,
and `@` is not accepted. Reploy applies this same grammar when endpoint names
become controlled-session capability identifiers.

`environment.workload.endpoints.<name>.port` is the authoritative port on which
the workload listens inside the container. `extends` copies that port into the
Docker endpoint. Docker adds the internal bind address and scope-specific host
Expand Down
27 changes: 21 additions & 6 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
status: Active
updated: 2026-08-02
updated: 2026-08-08
summary: Capability-scoped execution sessions that inherit Reploy's global container sandbox.
---

Expand All @@ -9,10 +9,10 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta
## Status

- Decision state: Focused review complete; high-level decisions approved
- Implementation state: Initial global sandbox prerequisites and trusted
application-startup verification implemented in the current slice;
controlled-session authorization, protocol, lifecycle, and Docker
orchestration remain later slices
- Implementation state: Initial global sandbox prerequisites, trusted
application-startup verification, and controlled-session authorization are
implemented; protocol, lifecycle, and Docker orchestration remain later
slices
- Initial runtime: Linux containers under Docker
- Motivating clients: OmegaFlow recording, sandboxed AI agents, security
inspection, and untrusted-code execution
Expand Down Expand Up @@ -383,7 +383,15 @@ record containing:
- the network and endpoint grants;
- the mount and source grants;
- the permitted session and endpoint operations;
- the lease lifetime and owner connection.
- the admitted lease identity and its owner-connection policy.

The authorization record is portable immutable data; it does not serialize a
live connection or make ownership transferable. Host Reploy binds the record
to its admitted live-run lease, permits exactly one controller connection to
claim that lease, and treats that connection as the owner until it closes or
Host Reploy cancels the session. Connection loss ends the lease. The initial
protocol has no reconnect, ownership transfer, or operation that can extend the
lease by presenting an authorization digest again.

The controller does not receive a generic session-creation capability. After
creation, protocol operations do not accept a deployment name, mount, identity,
Expand All @@ -392,6 +400,13 @@ They act only on the session and logical endpoint identities established by the
host-created plan. A generation change invalidates admission of a pending
session; it does not retarget a live session.

Logical endpoint identities are the exact names declared by the resolved
blueprint. Blueprint resolution and authorization validation share one
Docker-style single path-component grammar: lowercase alphanumeric segments
separated by `.`, `_`, `__`, or one or more `-`, with a 128-byte maximum. Full
image-reference syntax is not accepted. This keeps the immutable capability
record aligned with every blueprint that can reach runtime planning.

A unique private endpoint and opaque handle prevent accidental cross-session
use, but secrecy is not the sole security boundary. Isolation relies on:

Expand Down
28 changes: 26 additions & 2 deletions internal/blueprint/extends.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"sort"
"strings"

"github.com/omry/reploy/internal/endpointname"
)

const environmentMountReferencePrefix = "environment.mounts."
Expand Down Expand Up @@ -53,7 +55,7 @@ func resolveExtends(source Syntax) (extendedSyntax, error) {
endpointNames := sortedKeys(source.Docker.Workload.Endpoints)
for _, name := range endpointNames {
endpoint := source.Docker.Workload.Endpoints[name]
reference, err := referencedName("docker.workload.endpoints."+name+".extends", endpoint.Extends, environmentEndpointReferencePrefix)
reference, err := referencedEndpointName("docker.workload.endpoints."+name+".extends", endpoint.Extends)
if err != nil {
return extendedSyntax{}, err
}
Expand All @@ -67,6 +69,28 @@ func resolveExtends(source Syntax) (extendedSyntax, error) {
}

func referencedName(field string, reference string, prefix string) (string, error) {
name, err := referencedSuffix(field, reference, prefix)
if err != nil {
return "", err
}
if strings.Contains(name, ".") {
return "", fmt.Errorf("%s must reference one named object", field)
}
return name, nil
}

func referencedEndpointName(field string, reference string) (string, error) {
name, err := referencedSuffix(field, reference, environmentEndpointReferencePrefix)
if err != nil {
return "", err
}
if err := endpointname.Validate(name); err != nil {
return "", fmt.Errorf("%s endpoint name %q: %w", field, name, err)
}
return name, nil
}

func referencedSuffix(field string, reference string, prefix string) (string, error) {
reference = strings.TrimSpace(reference)
if reference == "" {
return "", fmt.Errorf("%s is required", field)
Expand All @@ -75,7 +99,7 @@ func referencedName(field string, reference string, prefix string) (string, erro
return "", fmt.Errorf("%s must reference %s<name>", field, prefix)
}
name := strings.TrimPrefix(reference, prefix)
if name == "" || strings.Contains(name, ".") {
if name == "" {
return "", fmt.Errorf("%s must reference one named object", field)
}
return name, nil
Expand Down
20 changes: 8 additions & 12 deletions internal/blueprint/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"sort"
"strings"
"time"

"github.com/omry/reploy/internal/endpointname"
"github.com/omry/reploy/internal/runtimeidentity"
)

var builtInControlOperations = map[string]bool{
Expand Down Expand Up @@ -148,17 +151,7 @@ func resolveRuntimeUser(value string) (string, error) {
}

func ValidateRuntimeUserName(value string) error {
if value == "" || len(value) > 32 {
return fmt.Errorf("must be a nonempty portable Unix user name no longer than 32 bytes")
}
for index, character := range value {
if character >= 'a' && character <= 'z' || character == '_' && index == 0 ||
index > 0 && (character >= '0' && character <= '9' || character == '_' || character == '-') {
continue
}
return fmt.Errorf("must be a portable lowercase Unix user name")
}
return nil
return runtimeidentity.ValidateUserName(value)
}

func resolveConcurrentRunPolicy(value string) (ConcurrentRunPolicy, error) {
Expand Down Expand Up @@ -615,6 +608,9 @@ func resolveWorkloads(source Syntax, extended extendedSyntax, document *Document
_ = command
workload := Workload{Command: source.Environment.Workload.Command, Endpoints: map[string]Endpoint{}}
for _, name := range sortedKeys(source.Environment.Workload.Endpoints) {
if err := endpointname.Validate(name); err != nil {
return fmt.Errorf("environment.workload.endpoints key %q: %w", name, err)
}
endpoint, err := resolveEndpoint("environment.workload.endpoints."+name, source.Environment.Workload.Endpoints[name])
if err != nil {
return err
Expand All @@ -631,7 +627,7 @@ func resolveWorkloads(source Syntax, extended extendedSyntax, document *Document
endpointReferences := map[string]int{}
for _, name := range sortedKeys(extended.Endpoints) {
item := extended.Endpoints[name]
endpointName, _ := referencedName("extends", item.Docker.Extends, environmentEndpointReferencePrefix)
endpointName, _ := referencedEndpointName("extends", item.Docker.Extends)
endpointReferences[endpointName]++
resolvedEndpoint := workload.Endpoints[endpointName]
stagingPort, err := resolveSyntaxInt(item.Docker.Publish.Staging, "docker.workload.endpoints."+name+".publish.staging")
Expand Down
44 changes: 44 additions & 0 deletions internal/blueprint/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,50 @@ func TestResolveProducesTypedEnvironment(t *testing.T) {
}
}

func TestResolveValidatesWorkloadEndpointNames(t *testing.T) {
for _, test := range []struct {
name string
ok bool
}{
{name: "api", ok: true},
{name: "api_v1", ok: true},
{name: "api.v1", ok: true},
{name: "api--v1", ok: true},
{name: "api__internal", ok: true},
{name: "2fa", ok: true},
{name: "API"},
{name: "-api"},
{name: "api-"},
{name: "api/v1"},
{name: "api:v1"},
{name: "api@sha256"},
} {
t.Run(test.name, func(t *testing.T) {
source, err := Decode([]byte(minimalBlueprint))
if err != nil {
t.Fatal(err)
}
endpoint := source.Environment.Workload.Endpoints["http"]
delete(source.Environment.Workload.Endpoints, "http")
source.Environment.Workload.Endpoints[test.name] = endpoint
dockerEndpoint := source.Docker.Workload.Endpoints["http"]
dockerEndpoint.Extends = "environment.workload.endpoints." + test.name
source.Docker.Workload.Endpoints["http"] = dockerEndpoint

_, err = Resolve(source)
if test.ok {
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "endpoint name") {
t.Fatalf("Resolve() error = %v, want endpoint-name diagnostic", err)
}
})
}
}

func TestResolveRuntimeNetwork(t *testing.T) {
for _, test := range []struct {
name string
Expand Down
160 changes: 160 additions & 0 deletions internal/controlledsession/authorization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Package controlledsession defines the host-owned authorization, wire
// protocol, and lifecycle state machine for one controlled session.
//
// The package is intentionally independent of Docker orchestration. Callers
// must construct and validate a complete immutable authorization before they
// create runtime resources or expose a session channel.
package controlledsession

import (
"crypto/rand"
"fmt"
"io"
"regexp"
"slices"
"strings"
"unicode"
"unicode/utf8"

"github.com/omry/reploy/internal/canonical"
"github.com/omry/reploy/internal/deploy"
"github.com/omry/reploy/internal/endpointname"
"github.com/omry/reploy/internal/runtimeidentity"
)

const AuthorizationSchemaV1 = "controlled-session-authorization-v1"

type OperationV1 string

const (
OperationInputV1 OperationV1 = "input"
OperationResizeV1 OperationV1 = "resize"
OperationTerminateV1 OperationV1 = "terminate"
OperationCompleteV1 OperationV1 = "complete"
OperationOpenEndpointV1 OperationV1 = "open-endpoint"
)

// RuntimeIdentityV1 records the exact container-local identity selected before
// the session starts.
type RuntimeIdentityV1 = runtimeidentity.IdentityV1

// AuthorizationV1 binds one opaque session handle to one already admitted,
// immutable runtime plan. The plan digests cover all details that are not
// repeated here, including mounts, environment, network, and commands.
//
// Ownership and lifetime are deliberately host runtime state rather than
// transferable fields in this record. The host binds the validated value to
// its LiveRunID, permits exactly one controller connection to claim that lease,
// and ends the lease when that connection is lost or the host cancels it.
type AuthorizationV1 struct {
Schema string `json:"schema"`
Handle string `json:"handle"`
DeploymentID string `json:"deployment_id"`
GenerationReference string `json:"generation_reference"`
BuildIdentity canonical.Digest `json:"build_identity"`
LiveRunID string `json:"live_run_id"`
WorkloadPlan canonical.Digest `json:"workload_plan"`
ControllerPlan canonical.Digest `json:"controller_plan"`
RuntimeIdentity RuntimeIdentityV1 `json:"runtime_identity"`
Operations []OperationV1 `json:"operations"`
EndpointIDs []string `json:"endpoint_ids"`
}

var sessionHandlePatternV1 = regexp.MustCompile(`^session-[0-9a-f]{64}$`)

func NewHandleV1() (string, error) {
return newHandleV1(rand.Reader)
}

func newHandleV1(random io.Reader) (string, error) {
if random == nil {
return "", fmt.Errorf("create controlled-session handle requires randomness")
}
var value [32]byte
if _, err := io.ReadFull(random, value[:]); err != nil {
return "", fmt.Errorf("create controlled-session handle: %w", err)
}
return fmt.Sprintf("session-%x", value), nil
}

func AuthorizationDigestV1(authorization AuthorizationV1) (canonical.Digest, error) {
if err := ValidateAuthorizationV1(authorization); err != nil {
return "", err
}
return canonical.Sum("controlled-session-authorization", AuthorizationSchemaV1, authorization)
}

func ValidateAuthorizationV1(authorization AuthorizationV1) error {
if authorization.Schema != AuthorizationSchemaV1 {
return fmt.Errorf("controlled-session authorization schema must be %q", AuthorizationSchemaV1)
}
if !sessionHandlePatternV1.MatchString(authorization.Handle) {
return fmt.Errorf("controlled-session handle must use session- followed by 64 lowercase hexadecimal characters")
}
if err := validateSafeTextV1("deployment ID", authorization.DeploymentID); err != nil {
return err
}
if err := validateSafeTextV1("generation reference", authorization.GenerationReference); err != nil {
return err
}
if err := authorization.BuildIdentity.Validate(); err != nil {
return fmt.Errorf("controlled-session build identity: %w", err)
}
if err := deploy.ValidateLiveRunIDV1(authorization.LiveRunID); err != nil {
return fmt.Errorf("controlled-session live-run ID: %w", err)
}
if err := authorization.WorkloadPlan.Validate(); err != nil {
return fmt.Errorf("controlled-session workload plan: %w", err)
}
if err := authorization.ControllerPlan.Validate(); err != nil {
return fmt.Errorf("controlled-session controller plan: %w", err)
}
if err := runtimeidentity.ValidateIdentityV1(authorization.RuntimeIdentity); err != nil {
return fmt.Errorf("controlled-session runtime identity: %w", err)
}
if authorization.Operations == nil || authorization.EndpointIDs == nil {
return fmt.Errorf("controlled-session authorization collections must use arrays")
}
for index, operation := range authorization.Operations {
switch operation {
case OperationInputV1, OperationResizeV1, OperationTerminateV1, OperationCompleteV1, OperationOpenEndpointV1:
default:
return fmt.Errorf("controlled-session operation %q is unsupported", operation)
}
if index > 0 && authorization.Operations[index-1] >= operation {
return fmt.Errorf("controlled-session operations must be unique and sorted")
}
}
for index, endpointID := range authorization.EndpointIDs {
if err := endpointname.Validate(endpointID); err != nil {
return fmt.Errorf("controlled-session endpoint ID %q: %w", endpointID, err)
}
if index > 0 && authorization.EndpointIDs[index-1] >= endpointID {
return fmt.Errorf("controlled-session endpoint IDs must be unique and sorted")
}
}
if len(authorization.EndpointIDs) != 0 && !slices.Contains(authorization.Operations, OperationOpenEndpointV1) {
return fmt.Errorf("controlled-session endpoint grants require the open-endpoint operation")
}
return nil
}

func cloneAuthorizationV1(authorization AuthorizationV1) AuthorizationV1 {
result := authorization
result.RuntimeIdentity.SupplementaryGIDs = append([]string{}, authorization.RuntimeIdentity.SupplementaryGIDs...)
result.Operations = append([]OperationV1{}, authorization.Operations...)
result.EndpointIDs = append([]string{}, authorization.EndpointIDs...)
return result
}

func validateSafeTextV1(field string, value string) error {
if value == "" || len(value) > 512 || !utf8.ValidString(value) || strings.TrimSpace(value) != value {
return fmt.Errorf("controlled-session %s must be nonempty safe text", field)
}
for _, character := range value {
if unicode.IsControl(character) || unicode.In(character, unicode.Cf) {
return fmt.Errorf("controlled-session %s must be nonempty safe text", field)
}
}
return nil
}
Loading
Loading