Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A Windows-only `setup-owner` protocol gives Blender Box a fixed-purpose, identity-fenced process owner before setup code can run. Its runtime capability probe fails closed when atomic Job Object assignment is unavailable.
- `capabilities --require blender-box-v1` reports the exact identity-fencing and bounded-call contract for remote wrappers without touching Session state; wrappers can require named capabilities before use.
- Machine-readable `call` failures include a typed wire-error reason so orchestrators can distinguish a bounded read timeout from other failures.

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ factory-empty file.
- `call`: Send one raw command and optional JSON parameters to a healthy Session's addon.
- `stop`: Terminate an owned Session process tree without saving.
- `mcp-serve`: Connect the validated `blender-mcp` stdio server to one healthy Session.
- `setup-owner`: Own one fenced Blender Box setup process tree on Windows. This integration-only command is not a general remote command runner.

Run `blendersessiond COMMAND --help` for each command's flags. `doctor`,
`start`, `status`, and `stop` support versioned JSON output with `--json`;
Expand All @@ -129,6 +130,18 @@ Wrappers that persist timeout state should also pass
`--require-capability typed-call-error-reason`. With `call --json`, wire
failures include a stable `reason`; a read-timeout expiration uses `timeout`.

Blender Box setup also requires the runtime-gated Windows owner contract:

```console
blendersessiond capabilities --require blender-box-v1 --require-capability windows-setup-owner-v1
```

That probe creates an inert suspended Windows process inside a kill-on-close
Job Object, verifies membership, and terminates it without resuming it. The
capability fails closed when the current host context cannot provide atomic
Job assignment. Blender Box alone drives the private `setup-owner` launch,
status, and stop protocol with immutable Attempt and Launch identities.

Raw calls wait up to 180 seconds by default. Use `call --read-timeout SECONDS`
for a known long render or script, up to the hard 3,600-second maximum. Keep an
outer workflow deadline and choose a read timeout no later than its remaining
Expand Down Expand Up @@ -245,6 +258,12 @@ Set `BLENDERSESSIOND_STATE_DIR` to an absolute path to override this location.
`blendersessiond status` prints each Session's log paths; use `--json` when a
script needs the complete state record.

On Windows, Blender Box setup attempts use the reserved `setup-attempts`
subdirectory below this state directory. Their request, launch receipt, and
terminal records are create-once. The staged script name is derived from the
Attempt ID, and the owner validates its size, SHA-256, and strict UTF-8 bytes
again before it can resume PowerShell.

Session MCP ports are allocated upward from `9876` on loopback. Set
`BLENDERSESSIOND_BASE_MCP_PORT` to start allocation from a different port —
the test suite uses this to keep fake-Blender Sessions off the ports a real
Expand Down
66 changes: 66 additions & 0 deletions docs/adr/0005-fenced-windows-setup-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
summary: Blender Box setup uses an atomic, identity-fenced Windows Job owner.
read_when:
- Changing setup-owner launch, status, stop, state, or Windows process code.
- Changing the Blender Box capability contract.
---

# Fenced Windows setup owner

## Context

Blender Box setup crosses Windows OpenSSH before its PowerShell program can
record a process identity. Cancelling the SSH client in that interval can
leave setup work running. A later cleanup cannot safely find that work by
name, command text, or PID because each can identify an unrelated replacement.

The existing Blender Session owner already contains Windows Job Object code,
but setup must remain a narrower operation. It must not become a remote shell.

## Decision

`blendersessiond setup-owner` accepts only one versioned Blender Box setup
operation. The client creates a random Attempt ID and Launch ID before launch.
It sends a bounded raw JSON request on standard input. The request names only a
script artifact derived from the Attempt ID, plus its size, SHA-256, revision,
and a deadline no more than five minutes away. Executable, argument,
environment, working-directory, and output-path choices are not part of the
contract.

Each attempt has three create-once JSON records under `setup-attempts`:

- `request.json` records both identities and the SHA-256 of the exact raw request.
- `launch-receipt.json` records the detached keeper and suspended root process identities before resume.
- `terminal.json` records process outcome, bounded output, and whether cleanup is proven.

The detached keeper creates a kill-on-close Job Object, then creates system
PowerShell suspended with `PROC_THREAD_ATTRIBUTE_JOB_LIST`. The child inherits
only its three standard-stream pipe handles. The keeper durably publishes the
launch receipt before it resumes the root process. It sends the already
validated script bytes through standard input to a fixed bootstrap. That
bootstrap checks the exact byte count and SHA-256, decodes strict UTF-8 in
memory, and only then invokes the script block.

Status and stop require the complete Attempt, request-hash, and Launch fence.
Normal stop asks the exact keeper to terminate its Job. Fallback termination
opens the recorded keeper once and verifies its creation FILETIME through that
handle. PID lookup alone never authorizes termination. Owner-loss recovery
claims `tree_gone` only after the exact keeper and root are absent and the
named Job no longer exists. Otherwise the immutable terminal is
`cleanup_unverified`.

`windows-setup-owner-v1` is a runtime capability. Its probe creates an inert
suspended child with the same atomic Job-list mechanism, verifies membership,
terminates the Job before resume, and proves that it has no members. Missing
API support or an incompatible outer Job policy fails closed.

## Consequences

Blender Box can reconcile lost launch responses and repeat status or stop
without broad process discovery. A stale caller cannot act on a replacement
attempt. The keeper is the only process that retains the Job handle, so its
loss terminates the setup tree.

The protocol is Windows-only and private to Blender Box setup. Staging and its
ACL handoff remain Blender Box responsibilities. The owner rejects a staged
artifact that does not match the immutable request.
86 changes: 84 additions & 2 deletions src/blendersessiond/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import json
import platform
import sys
from collections.abc import Sequence
from pathlib import Path
Expand All @@ -24,6 +25,16 @@
validate_session_id,
validate_session_name,
)
from blendersessiond.setup_owner import (
MAX_REQUEST_BYTES,
SetupOwnerError,
launch_setup,
status_setup,
stop_setup,
)
from blendersessiond.windows_setup_process import (
runtime_self_test as windows_setup_owner_self_test,
)
from blendersessiond.wire import (
DEFAULT_READ_TIMEOUT_SECONDS,
MAX_READ_TIMEOUT_SECONDS,
Expand All @@ -38,6 +49,8 @@
"bounded-call-read-timeout",
"typed-call-error-reason",
]
WINDOWS_SETUP_OWNER_CAPABILITY = "windows-setup-owner-v1"
_platform_system = platform.system


def build_parser() -> argparse.ArgumentParser:
Expand All @@ -61,7 +74,7 @@ def build_parser() -> argparse.ArgumentParser:
capabilities.add_argument(
"--require-capability",
action="append",
choices=BLENDER_BOX_CAPABILITIES,
choices=[*BLENDER_BOX_CAPABILITIES, WINDOWS_SETUP_OWNER_CAPABILITY],
metavar="CAPABILITY",
help="Require a named capability within the integration contract.",
)
Expand Down Expand Up @@ -145,20 +158,61 @@ def build_parser() -> argparse.ArgumentParser:
help="Serve MCP stdio for one healthy Session.",
)
_add_name_argument(mcp_serve)

setup_owner = commands.add_parser(
"setup-owner",
help="Own one fixed-purpose Blender Box Windows setup attempt.",
)
setup_commands = setup_owner.add_subparsers(
dest="setup_owner_command", required=True
)
setup_launch = setup_commands.add_parser(
"launch", help="Launch the exact setup request read from standard input."
)
_add_json_argument(setup_launch)
for command_name in ("status", "stop"):
setup_command = setup_commands.add_parser(command_name)
setup_command.add_argument("--attempt-id", required=True)
setup_command.add_argument("--expect-request-sha256", required=True)
setup_command.add_argument("--expect-launch-id", required=True)
_add_json_argument(setup_command)
return parser


def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)

if args.command == "capabilities":
capabilities = list(BLENDER_BOX_CAPABILITIES)
if WINDOWS_SETUP_OWNER_CAPABILITY in (args.require_capability or []):
try:
if _platform_system() != "Windows":
raise RuntimeError(
"Windows setup ownership is unavailable on this platform."
)
windows_setup_owner_self_test()
except (OSError, RuntimeError, ValueError) as error:
print(
json.dumps(
{
"schema_version": 1,
"status": "incompatible",
"contract": args.require,
"capability": WINDOWS_SETUP_OWNER_CAPABILITY,
"message": str(error),
},
indent=2,
)
)
return 1
capabilities.append(WINDOWS_SETUP_OWNER_CAPABILITY)
print(
json.dumps(
{
"schema_version": 1,
"status": "compatible",
"contract": args.require,
"capabilities": BLENDER_BOX_CAPABILITIES,
"capabilities": capabilities,
},
indent=2,
)
Expand Down Expand Up @@ -299,6 +353,34 @@ def main(argv: Sequence[str] | None = None) -> int:
print(f"ERROR: {error}", file=sys.stderr)
return 1

if args.command == "setup-owner":
try:
if args.setup_owner_command == "launch":
result = launch_setup(sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1))
elif args.setup_owner_command == "status":
result = status_setup(
args.attempt_id,
expected_request_sha256=args.expect_request_sha256,
expected_launch_id=args.expect_launch_id,
)
elif args.setup_owner_command == "stop":
result = stop_setup(
args.attempt_id,
expected_request_sha256=args.expect_request_sha256,
expected_launch_id=args.expect_launch_id,
)
else:
raise AssertionError(args.setup_owner_command)
except (SetupOwnerError, OSError, RuntimeError, ValueError) as error:
_print_failure(
command=f"setup-owner {args.setup_owner_command}",
message=str(error),
as_json=args.json,
)
return 1
_print_result(result.to_dict(), as_json=args.json)
return 0

raise AssertionError(f"unhandled command: {args.command}")


Expand Down
Loading
Loading