diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index e22d713..e3ab645 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.9.4-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:6e0da387f1ed9a162e18f93470f9712783b4a7501a2ec9979d4ce2e15fc73f46" - digest: "sha256:030d8ccfe93fc4ded1b5fabe8e8df2eee4195c7b2eec33f6b1880dec851116bd" + source_tree_digest: "sha256:2b1a4353cef1f4e5ca83504a7f9cffb21ac04fcaf3e31d32068b108cba735d84" + digest: "sha256:4fced9b3b3ba51cbf1390489310ce01ce21d180a95d601771db066bfb9b0a8f5" projection: - input_digest: "sha256:6bae4a92f7cdb2890369fc557f3e82f64172dae27ce782d7f10455a6a0f47949" - output_digest: "sha256:92322690bf490037b4ec99b33584004ed63a1dc7f2b7368e6362510292b81d3c" + input_digest: "sha256:e0ebdc1dfa4345e685358212879ba7e2e39d8c666d60e2f1dba468c415626393" + output_digest: "sha256:e33b2ddf57c2990f9f9521eb011edcac98073683e5b476fdcc59e6e34329cabe" files: - path: ".gds/compiled-policy.json" digest: "sha256:37ece9020fd9eeed4ee90582600c5e50cb3a7e32ee773719d1ccfad2d9cd1b6d" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:21bbe5aad38befa4f10438841bcfd06f42dd9981047e057ec8142fa9326d0bd0" + digest: "sha256:d77abe7402b55f8e8315b0f2077126713836dd179b8cdb108c024bd39d5f3b16" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index c573a2a..2938651 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.9.4-dev -# source-tree-digest: sha256:6e0da387f1ed9a162e18f93470f9712783b4a7501a2ec9979d4ce2e15fc73f46 -# input-digest: sha256:6bae4a92f7cdb2890369fc557f3e82f64172dae27ce782d7f10455a6a0f47949 +# source-tree-digest: sha256:2b1a4353cef1f4e5ca83504a7f9cffb21ac04fcaf3e31d32068b108cba735d84 +# input-digest: sha256:e0ebdc1dfa4345e685358212879ba7e2e39d8c666d60e2f1dba468c415626393 # output-digest: sha256:15b87303cd0692fd8e5d88281771dded9fdc2fc027d600681155cb4e71f42386 # edit-source: # - .gds/repository.yaml diff --git a/core/validation/device_class_test.go b/core/validation/device_class_test.go index eb09b60..506dbfc 100644 --- a/core/validation/device_class_test.go +++ b/core/validation/device_class_test.go @@ -93,6 +93,24 @@ func TestDeviceClassAcceptsDesktopBuilds(t *testing.T) { } } +func TestDeviceClassAcceptsDesktopServer(t *testing.T) { + t.Parallel() + set, err := NewSchemaSet() + if err != nil { + t.Fatal(err) + } + value := deviceClassFixture(t, map[string]any{ + "profile": "desktop-server", + "gui": "enabled", + "docker_mode": "none", + "execution_policy": "interactive-desktop-server", + "hardening": map[string]any{"ssh": true, "ufw": true, "fail2ban": true}, + }) + if findings := set.Validate("device", value, "test"); len(findings) != 0 { + t.Fatalf("expected no findings, got %#v", findings) + } +} + func TestDeviceClassAcceptsServerHeadless(t *testing.T) { t.Parallel() set, err := NewSchemaSet() @@ -170,6 +188,30 @@ func TestDeviceClassRules(t *testing.T) { class: map[string]any{"profile": "desktop-builds", "docker_mode": "rootless"}, wantCode: "GDS_DEVICE_CLASS_DESKTOP_BUILDS_DOCKER", }, + { + name: "desktop-server without gui", osName: "linux", + class: map[string]any{ + "profile": "desktop-server", "gui": "disabled", "docker_mode": "none", + "execution_policy": "interactive-desktop-server", + }, + wantCode: "GDS_DEVICE_CLASS_DESKTOP_SERVER_GUI", + }, + { + name: "desktop-server with docker", osName: "linux", + class: map[string]any{ + "profile": "desktop-server", "gui": "enabled", "docker_mode": "rootful", + "execution_policy": "interactive-desktop-server", + }, + wantCode: "GDS_DEVICE_CLASS_DESKTOP_SERVER_DOCKER", + }, + { + name: "desktop-server on macos", osName: "macos", + class: map[string]any{ + "profile": "desktop-server", "gui": "enabled", "docker_mode": "none", + "execution_policy": "interactive-desktop-server", + }, + wantCode: "GDS_DEVICE_CLASS_DESKTOP_SERVER_PLATFORM", + }, { name: "desktop with hardening", osName: "linux", class: map[string]any{ diff --git a/core/validation/schema.go b/core/validation/schema.go index f0e3b62..f37d69b 100644 --- a/core/validation/schema.go +++ b/core/validation/schema.go @@ -477,6 +477,7 @@ func deviceFindings(source string, object map[string]any) []domain.Finding { var deviceClassExecutionPolicies = map[string]string{ "desktop": "source-lsp-only", "desktop-builds": "local-dev-with-builds", + "desktop-server": "interactive-desktop-server", "server": "container-execution-only", } @@ -531,6 +532,23 @@ func deviceClassFindings(source string, object map[string]any) []domain.Finding "The desktop-builds profile requires docker_mode rootful.", map[string]any{"profile": profile, "docker_mode": dockerMode}) } + if profile == "desktop-server" { + if osName != "linux" || (device["architecture"] != "x86_64" && device["architecture"] != "") { + rule("GDS_DEVICE_CLASS_DESKTOP_SERVER_PLATFORM", + "The desktop-server profile requires Linux x86_64.", + map[string]any{"profile": profile, "os": osName, "architecture": device["architecture"]}) + } + if gui != "" && gui != "enabled" { + rule("GDS_DEVICE_CLASS_DESKTOP_SERVER_GUI", + "The desktop-server profile requires gui enabled.", + map[string]any{"profile": profile, "gui": gui}) + } + if dockerMode != "" && dockerMode != "none" { + rule("GDS_DEVICE_CLASS_DESKTOP_SERVER_DOCKER", + "The desktop-server profile defaults to no local Docker; docker_mode must be none.", + map[string]any{"profile": profile, "docker_mode": dockerMode}) + } + } // execution_policy, when declared, must match the profile. The mapping is the // macos-ubuntu-bootstrap targets block, mirrored here so a device descriptor // and the OS installer it drives cannot disagree. @@ -542,11 +560,11 @@ func deviceClassFindings(source string, object map[string]any) []domain.Finding map[string]any{"profile": profile, "execution_policy": executionPolicy, "expected": expected}) } } - // Hardening toggles are server-only. - if hasHardening && profile != "" && profile != "server" { + // Hardening toggles belong to profiles that compose the server baseline. + if hasHardening && profile != "" && profile != "server" && profile != "desktop-server" { if hardeningMap, ok := hardening.(map[string]any); ok && len(hardeningMap) > 0 { rule("GDS_DEVICE_CLASS_HARDENING_PROFILE", - "Device class hardening is only permitted with the server profile.", + "Device class hardening is only permitted with a server-baseline profile.", map[string]any{"profile": profile, "hardening": hardening}) } } diff --git a/docs/runbooks/bootstrap-device.md b/docs/runbooks/bootstrap-device.md index ace1353..2a82c15 100644 --- a/docs/runbooks/bootstrap-device.md +++ b/docs/runbooks/bootstrap-device.md @@ -49,11 +49,11 @@ mirrored from `modules/macos-ubuntu-bootstrap/config/rldyour-contract.json`: | Field | Values | Notes | |---|---|---| -| `profile` | `desktop` \| `desktop-builds` \| `server` | LSP-only workstation vs build workstation with Docker vs headless container host | -| `gui` | `enabled` \| `disabled` | server is always `disabled` | -| `docker_mode` | `none` \| `rootful` \| `rootless` | desktop requires `none`; desktop-builds requires `rootful`; macOS never installs Docker | -| `execution_policy` | `source-lsp-only` \| `local-dev-with-builds` \| `container-execution-only` | must match profile | -| `hardening.{ssh,ufw,fail2ban}` | `true` \| `false` | server only | +| `profile` | `desktop` \| `desktop-builds` \| `desktop-server` \| `server` | LSP-only, local-build, tunneled remote-GUI, or headless-server host | +| `gui` | `enabled` \| `disabled` | desktop-server is always `enabled`; server is always `disabled` | +| `docker_mode` | `none` \| `rootful` \| `rootless` | desktop and desktop-server require `none`; desktop-builds requires `rootful`; macOS never installs Docker | +| `execution_policy` | `source-lsp-only` \| `local-dev-with-builds` \| `interactive-desktop-server` \| `container-execution-only` | must match profile | +| `hardening.{ssh,ufw,fail2ban}` | `true` \| `false` | server-baseline profiles only | Cross-field rules are enforced by the schema validator (`GDS_DEVICE_CLASS_*` findings), mirroring the rules in diff --git a/schemas/v1/device.schema.json b/schemas/v1/device.schema.json index 3d3fc0b..c121769 100644 --- a/schemas/v1/device.schema.json +++ b/schemas/v1/device.schema.json @@ -51,22 +51,23 @@ "additionalProperties": false, "properties": { "profile": { - "description": "desktop = a workstation (LSP/agents only); desktop-builds = a workstation with local Docker (local-dev-with-builds); server = a headless container-execution host.", + "description": "desktop = a workstation (LSP/agents only); desktop-builds = a workstation with local Docker; desktop-server = an amd64 Linux remote GUI workstation reached through a private tunnel; server = a headless container-execution host.", "enum": [ "desktop", "desktop-builds", + "desktop-server", "server" ] }, "gui": { - "description": "Whether the device runs a GUI session. The server profile is always disabled.", + "description": "Whether the device runs a GUI session. desktop-server is always enabled and server is always disabled.", "enum": [ "enabled", "disabled" ] }, "docker_mode": { - "description": "Local Docker posture. desktop permits only none; desktop-builds requires rootful; server permits none/rootful/rootless. macOS never installs local Docker.", + "description": "Local Docker posture. desktop and desktop-server permit only none; desktop-builds requires rootful; server permits none/rootful/rootless. macOS never installs local Docker.", "enum": [ "none", "rootful", @@ -74,15 +75,16 @@ ] }, "execution_policy": { - "description": "source-lsp-only for desktop; local-dev-with-builds for desktop-builds; container-execution-only for server.", + "description": "source-lsp-only for desktop; local-dev-with-builds for desktop-builds; interactive-desktop-server for desktop-server; container-execution-only for server.", "enum": [ "source-lsp-only", "local-dev-with-builds", + "interactive-desktop-server", "container-execution-only" ] }, "hardening": { - "description": "Server-only hardening toggles. Permitted only when profile is server.", + "description": "Server-baseline hardening toggles. Permitted when profile is server or desktop-server.", "type": "object", "additionalProperties": false, "properties": { diff --git a/scripts/gds-exact-apply.sh b/scripts/gds-exact-apply.sh index 5396b39..aba6243 100755 --- a/scripts/gds-exact-apply.sh +++ b/scripts/gds-exact-apply.sh @@ -36,6 +36,7 @@ command -v jq >/dev/null 2>&1 || die "jq is required" # the install/upgrade/rollback inputs is explicitly rejected by the native CLI. # Other lifecycle commands still need their selectors (for example --harness). verify_command=("${command[@]}") +apply_command=("${command[@]}") for ((command_index = 1; command_index + 1 < ${#command[@]}; command_index++)); do if [ "${command[command_index]}" = release ]; then case "${command[command_index + 1]}" in @@ -53,6 +54,21 @@ for ((command_index = 1; command_index + 1 < ${#command[@]}; command_index++)); break ;; esac fi + if [ "${command[command_index]}" = workspace ] && + [ "${command[command_index + 1]}" = register-estate ]; then + apply_command=() + for ((index = 0; index < ${#command[@]}; index++)); do + case "${command[index]}" in + --estate-root|--registration-path) + ((index + 1 < ${#command[@]})) || die "missing value for ${command[index]}" + index=$((index + 1)) ;; + --estate-root=*|--registration-path=*) ;; + *) apply_command+=("${command[index]}") ;; + esac + done + verify_command=("${apply_command[@]}") + break + fi done "$gds_bin" --json operation enable "$plan_id" \ @@ -60,7 +76,7 @@ done --device-id "$device_id" --session-id "$session_id" | jq -e '.result == "succeeded" and .data.status == "active"' >/dev/null -apply_output=$("${command[@]}" --apply "$plan_id" --approval-ref "$approval_file" \ +apply_output=$("${apply_command[@]}" --apply "$plan_id" --approval-ref "$approval_file" \ --state-path "$state_path" --device-id "$device_id" --session-id "$session_id") operation_id=$(printf '%s' "$apply_output" | jq -er 'select(.result == "succeeded") | .operation_id') diff --git a/tests/test_exact_apply.py b/tests/test_exact_apply.py index f4830b7..da4d74b 100644 --- a/tests/test_exact_apply.py +++ b/tests/test_exact_apply.py @@ -69,6 +69,24 @@ def test_harness_selectors_and_literal_arguments_survive_verification(tmp_path: assert "--approval-ref" not in calls[2] +@pytest.mark.parametrize("equals", [False, True]) +def test_workspace_registration_drops_planning_only_inputs_for_apply_and_verify( + tmp_path: Path, equals: bool, +) -> None: + estate = str(tmp_path / "estate with spaces") + registration = str(tmp_path / "config with spaces" / "registration.json") + inputs = {"estate-root": estate, "registration-path": registration} + flags = [arg for name, value in inputs.items() + for arg in ([f"--{name}={value}"] if equals else [f"--{name}", value])] + base = ["workspace", "register-estate", *flags] + result, calls = run_helper(tmp_path, base) + assert result.returncode == 0, result.stderr + assert calls[1][1:3] == calls[2][1:3] == ["workspace", "register-estate"] + for call in calls[1:]: + assert all(arg.split("=", 1)[0] not in {"--estate-root", "--registration-path"} + for arg in call) + + @pytest.mark.parametrize("operation", ["install", "upgrade", "rollback", "remove"]) @pytest.mark.parametrize("equals", [False, True]) def test_release_verification_uses_stored_identity_with_native_cli(