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/+armv7-full-range-runtime-ids.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Fixed
body: Preserve valid unsigned 32-bit runtime UID and GID values through host identity capture, runtime planning, and Linux startup-probe execution on 32-bit targets.
2 changes: 2 additions & 0 deletions .changes/unreleased/+controlled-session-crash-receipts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Added
body: Persist bounded allowlisted controlled-session crash receipts, clear incomplete receipt targets after write failures, and expose lock-protected retrieval and explicit acknowledgement APIs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Pin administrative controlled-session container stops to the session's durably recorded local Docker endpoint.
2 changes: 2 additions & 0 deletions .changes/unreleased/+root-nonzero-primary-gid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Fixed
body: Preserve a root caller's nonzero primary GID when building the container-local runtime account.
2 changes: 1 addition & 1 deletion .changes/unreleased/+workload-endpoint-identifiers.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
kind: Changed
body: Require workload endpoint names to use one shared Docker-style component grammar across blueprint resolution and controlled-session authorization.
body: Require workload endpoint names to use one shared Docker-style component grammar across blueprint resolution, interpolation, and controlled-session authorization, including names with dots, while rejecting combinations that collide in generated endpoint environment metadata.
4 changes: 3 additions & 1 deletion docs/BLUEPRINT_ENVIRONMENT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1385,7 +1385,9 @@ effective numeric authority above. The account and its numeric identity are
locked build inputs, so changing either makes reuse stale. The blueprint name
is deliberately independent of a Windows domain account or Unix host account.
An effective UID of zero uses the existing local name `root`; a blueprint
cannot request root merely by naming it.
cannot request root merely by naming it. Reploy preserves the effective primary
GID even when UID zero was invoked with a nonzero GID; it does not normalize
that runtime identity to `0:0`.

An application runtime with effective UID zero cannot receive a host bind,
whether read-only input or writable shared state. It also cannot use
Expand Down
44 changes: 36 additions & 8 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,21 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta
full ID was not recorded by its frozen name, verifies every exact ownership
label, removes and verifies both containers and the private channel under one
bounded cleanup attempt, and retains incomplete ownership for a later retry.
A watchdog-owned retry loop while Docker remains unavailable and a bounded
durable post-crash diagnostic receipt are still later ownership phases, and
controlled-session networking remains a later phase.
The watchdog now receives a pre-created, exact private incident-receipt file
descriptor with its immutable manifest. On parent loss it durably records a
bounded canonical receipt containing only the parent-loss class, exact
controller and workload ownership, per-resource verified-absent-or-failed
cleanup outcomes, and the required recovery action. Successful disarm or an
observed premature watchdog exit removes the unused target. Lock-protected
host APIs list completed receipts without contacting Docker and acknowledge
one validated receipt explicitly. The exact target's inherited descriptor
holds an advisory lock until both the parent and watchdog close it, so
restart reconciliation cannot unlink an empty target while a surviving
watchdog can still write. Empty unlocked targets are not incidents, receipts
are never silently evicted, and new session startup fails closed at the
fixed retention bound. A watchdog-owned retry loop while Docker remains
unavailable is still a later ownership phase, and controlled-session
networking remains a later phase.
- Initial runtime: Linux containers under Docker
- Motivating clients: OmegaFlow recording, sandboxed AI agents, security
inspection, and untrusted-code execution
Expand Down Expand Up @@ -716,7 +728,8 @@ generation rather than changing the staged generation.
Root applies when the effective runtime UID is `0`: because staged or
user-scope Reploy was invoked as root, or because a system-scope installation
explicitly selected root. It is never inherited merely from the base image's
configured `USER`.
configured `USER`. Reploy preserves that root identity's effective primary GID
rather than assuming or normalizing it to GID `0`.

A root runtime identity does not emit a generic warning. With the global
sandbox enforced, its additional authority is limited to container-scoped
Expand Down Expand Up @@ -1207,6 +1220,14 @@ termination cause still wins if termination had already begun. Verified host
cleanup may complete the durable ownership record even though containment loss
makes the session result unsuccessful.

The attached operation creates the exact private incident-receipt target while
it still holds the workload deployment lock and before it prepares the channel
or either container. The watchdog receives only that inherited file descriptor;
it cannot select a state path. Parent-liveness EOF or a malformed control byte
writes one canonical receipt after the bounded cleanup attempt. The receipt
records only fixed ownership and cleanup facts. Normal verified disarm and a
host-observed watchdog failure remove the unused target.

If Docker is unavailable, the watchdog retries until Docker returns or the host
reboots. If both the attached operation and watchdog are killed, durable labels
and deployment-scoped live-run state let the next locked Reploy operation
Expand Down Expand Up @@ -1275,15 +1296,22 @@ Diagnostics identify which operation failed, what Reploy attempted, whether
the session channel or Docker lifecycle was observed, what cleanup ran, and the
safe next action.

The target crash-containment work adds a bounded durable incident receipt for
failures that outlive the attached Host Reploy process. It records only
Crash containment includes a bounded durable incident receipt for failures
that outlive the attached Host Reploy process. It records only
allowlisted lifecycle, observation, exit-status, cleanup, and recovery facts.
It does not duplicate PTY output, environment names or values, secrets,
arbitrary container logs, or unrestricted Docker output. Host Reploy creates
the exact private receipt target before startup and gives the watchdog only the
narrow write authority needed for that target; the child does not select an
arbitrary state path. A parent-liveness EOF proves parent loss but cannot by
itself distinguish `SIGKILL`, an OOM kill, or another abrupt process death.
arbitrary state path. Completed receipts are available through lock-protected
list and explicit acknowledgement APIs that do not contact Docker. The parent
and watchdog retain an advisory lock on the inherited target until both have
closed it; an empty locked target is preserved because the surviving watchdog
may still write, while an empty unlocked target is ignored and removed.
Malformed or oversized receipts fail closed, completed receipts are not
silently evicted, and the fixed retention bound blocks new session startup
until receipts are handled. A parent-liveness EOF proves parent loss but cannot
by itself distinguish `SIGKILL`, an OOM kill, or another abrupt process death.

## Resource and Timeout Policy

Expand Down
20 changes: 17 additions & 3 deletions internal/blueprint/interpolation.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,29 @@ func (context InterpolationContext) lookup(reference string) (any, error) {
}
return nil, fmt.Errorf("unknown interpolation root %q", parts[0])
}
for _, part := range parts[1:] {
for index := 1; index < len(parts); {
object, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("interpolation reference %q cannot select %q from %T", reference, part, value)
return nil, fmt.Errorf("interpolation reference %q cannot select %q from %T", reference, parts[index], value)
}
value, ok = object[part]
value, index, ok = selectInterpolationMapKeyV1(object, parts, index)
if !ok {
return nil, fmt.Errorf("interpolation reference %q is unavailable", reference)
}
}
return value, nil
}

// selectInterpolationMapKeyV1 chooses the longest remaining dotted key in an
// object. Ordinary single-component traversal remains the fallback, while a
// logical endpoint such as "api.v1" stays addressable without introducing a
// second interpolation syntax.
func selectInterpolationMapKeyV1(object map[string]any, parts []string, start int) (any, int, bool) {
for end := len(parts); end > start; end-- {
key := strings.Join(parts[start:end], ".")
if value, ok := object[key]; ok {
return value, end, true
}
}
return nil, start, false
}
34 changes: 34 additions & 0 deletions internal/blueprint/interpolation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,37 @@ func TestResolveOperationStringsStringifiesWholeScalarReferences(t *testing.T) {
t.Fatalf("values = %#v", values)
}
}

func TestResolveOperationStringsResolvesDottedEndpointNames(t *testing.T) {
values, err := ResolveOperationStrings([]string{
"{{ reploy.workload.endpoints.api.v1.publish.port }}",
"{{ reploy.workload.endpoints.api.publish.port }}",
}, nil, PhaseStaged, nil, map[string]any{
"reploy.workload": map[string]any{"endpoints": map[string]any{
"api.v1": map[string]any{"publish": map[string]any{"port": 8443}},
"api": map[string]any{"publish": map[string]any{"port": 8080}},
}},
})
if err != nil {
t.Fatal(err)
}
if want := []string{"8443", "8080"}; !reflect.DeepEqual(values, want) {
t.Fatalf("values = %#v, want %#v", values, want)
}
}

func TestInterpolationContextPrefersLongestAvailableDottedMapKey(t *testing.T) {
context := InterpolationContext{Roots: map[string]any{
"root": map[string]any{
"api": map[string]any{"v1": "nested"},
"api.v1": "dotted",
},
}}
value, err := interpolate("{{ root.api.v1 }}", context)
if err != nil {
t.Fatal(err)
}
if value != "dotted" {
t.Fatalf("value = %#v, want dotted key", value)
}
}
13 changes: 11 additions & 2 deletions internal/deploy/controlled_session_cleanup_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ControlledSessionCleanupManifest struct {
BootSession string `json:"boot_session"`
DockerEndpoint string `json:"docker_endpoint"`
ChannelDirectory string `json:"channel_directory"`
IncidentReceipt string `json:"incident_receipt"`
Controller ControlledSessionContainerOwnershipV1 `json:"controller"`
Workload ControlledSessionContainerOwnershipV1 `json:"workload"`
Networks []string `json:"networks"`
Expand All @@ -35,11 +36,15 @@ func ControlledSessionCleanupManifestFromOwnership(ownership ControlledSessionOw
if err := validateCurrentControlledSessionOwnershipV1(ownership); err != nil {
return ControlledSessionCleanupManifest{}, fmt.Errorf("controlled-session cleanup manifest ownership: %w", err)
}
receiptPath, err := ControlledSessionIncidentReceiptPathV1(ownership.ChannelDirectory, ownership.LiveRunID)
if err != nil {
return ControlledSessionCleanupManifest{}, err
}
manifest := ControlledSessionCleanupManifest{
LiveRunID: ownership.LiveRunID, BootSession: ownership.BootSession,
DockerEndpoint: ownership.DockerEndpoint,
ChannelDirectory: ownership.ChannelDirectory,
Controller: ownership.Controller, Workload: ownership.Workload,
ChannelDirectory: ownership.ChannelDirectory, IncidentReceipt: receiptPath,
Controller: ownership.Controller, Workload: ownership.Workload,
Networks: []string{}, Volumes: []string{},
}
if err := ValidateControlledSessionCleanupManifest(manifest); err != nil {
Expand All @@ -66,6 +71,10 @@ func ValidateControlledSessionCleanupManifest(manifest ControlledSessionCleanupM
filepath.Base(filepath.Dir(sessionsDirectory)) != ".reploy" {
return fmt.Errorf("controlled-session cleanup manifest channel directory must identify the live-run private session directory")
}
receiptPath, err := ControlledSessionIncidentReceiptPathV1(manifest.ChannelDirectory, manifest.LiveRunID)
if err != nil || manifest.IncidentReceipt != receiptPath {
return fmt.Errorf("controlled-session cleanup manifest incident receipt must identify the exact live-run receipt target")
}
if err := validateControlledSessionContainerOwnershipV1(manifest.Controller, "controller"); err != nil {
return fmt.Errorf("controlled-session cleanup manifest controller: %w", err)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/deploy/controlled_session_cleanup_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func TestControlledSessionCleanupManifestDerivesExactDurableOwnership(t *testing
manifest.Workload != ownership.Workload || len(manifest.Networks) != 0 || len(manifest.Volumes) != 0 {
t.Fatalf("cleanup manifest = %#v", manifest)
}
wantReceipt := filepath.Join(filepath.Dir(filepath.Dir(ownership.ChannelDirectory)), "incidents", ownership.LiveRunID+".json")
if manifest.IncidentReceipt != wantReceipt {
t.Fatalf("incident receipt = %q, want %q", manifest.IncidentReceipt, wantReceipt)
}
content, err := EncodeControlledSessionCleanupManifest(manifest)
if err != nil {
t.Fatal(err)
Expand Down
19 changes: 19 additions & 0 deletions internal/deploy/controlled_session_incident_directory_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !windows

package deploy

import (
"fmt"
"os"
)

func createControlledSessionIncidentDirectoryV1(path string) error {
return os.Mkdir(path, 0o700)
}

func validateControlledSessionIncidentDirectorySecurityV1(_ string, info os.FileInfo) error {
if info.Mode().Perm()&0o077 != 0 {
return fmt.Errorf("controlled-session incident receipt directory must not be accessible to group or other users")
}
return nil
}
127 changes: 127 additions & 0 deletions internal/deploy/controlled_session_incident_directory_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//go:build windows

package deploy

import (
"fmt"
"os"
"path/filepath"
"unsafe"

"golang.org/x/sys/windows"
)

func createControlledSessionIncidentDirectoryV1(path string) error {
parentDescriptor, err := windows.GetNamedSecurityInfo(
filepath.Dir(path),
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION,
)
if err != nil {
return fmt.Errorf("inspect incident receipt parent owner: %w", err)
}
owner, _, err := parentDescriptor.Owner()
if err != nil {
return fmt.Errorf("read incident receipt parent owner: %w", err)
}
securityDescriptor, err := windows.SecurityDescriptorFromString(
"O:" + owner.String() + "D:P" +
"(A;;GA;;;" + owner.String() + ")" +
"(A;;GA;;;SY)" +
"(A;;GA;;;BA)",
)
if err != nil {
return fmt.Errorf("create private incident receipt directory ACL: %w", err)
}
attributes := windows.SecurityAttributes{
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
SecurityDescriptor: securityDescriptor,
}
pointer, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
return windows.CreateDirectory(pointer, &attributes)
}

func validateControlledSessionIncidentDirectorySecurityV1(path string, _ os.FileInfo) error {
pointer, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
handle, err := windows.CreateFile(
pointer,
windows.READ_CONTROL,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return fmt.Errorf("open controlled-session incident receipt directory: %w", err)
}
defer windows.CloseHandle(handle)
var information windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &information); err != nil {
return fmt.Errorf("inspect controlled-session incident receipt directory: %w", err)
}
if information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 ||
information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("controlled-session incident receipt path must be a real directory: %s", path)
}
descriptor, err := windows.GetSecurityInfo(
handle,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return fmt.Errorf("inspect controlled-session incident receipt directory ACL: %w", err)
}
owner, _, err := descriptor.Owner()
if err != nil {
return fmt.Errorf("read controlled-session incident receipt directory owner: %w", err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("read controlled-session incident receipt directory ACL: %w", err)
}
if dacl == nil {
return fmt.Errorf("controlled-session incident receipt directory must have a restrictive ACL")
}
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
return fmt.Errorf("resolve Windows SYSTEM identity for incident receipts: %w", err)
}
administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return fmt.Errorf("resolve Windows Administrators identity for incident receipts: %w", err)
}
ownerCanRead := false
for index := uint16(0); index < dacl.AceCount; index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
return fmt.Errorf("read controlled-session incident receipt directory ACL entry %d: %w", index, err)
}
if ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE {
continue
}
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
return fmt.Errorf("controlled-session incident receipt directory ACL contains an unsupported access rule")
}
if ace.Mask == 0 {
continue
}
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if !sid.Equals(owner) && !sid.Equals(system) && !sid.Equals(administrators) {
return fmt.Errorf("controlled-session incident receipt directory ACL grants access beyond its owner, SYSTEM, or Administrators")
}
if sid.Equals(owner) && ace.Mask&(windows.GENERIC_READ|windows.GENERIC_ALL|windows.FILE_LIST_DIRECTORY) != 0 {
ownerCanRead = true
}
}
if !ownerCanRead {
return fmt.Errorf("controlled-session incident receipt directory ACL must grant its owner read access")
}
return nil
}
Loading
Loading