diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 4a705e5..2981ef0 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:a826f0d40fa915e13c75cb7bfae1dc225d556a08316a86338dacb35421505c85" - digest: "sha256:a1c9ab806577705f57cc98ca78600d2d9b5085321f144ce39255e4994e22af02" + source_tree_digest: "sha256:50743872ba177cd8a1b293d497e4c2f03a21a46b9b6040594e4fb8c3fff755d5" + digest: "sha256:215167d571f0a4be0d1af3567bd49396b6069d604905dac742a6d2f633e51858" projection: - input_digest: "sha256:bd2c29dc422bb1153b6d87d7d3bba6051cfc8f5aa61d7dcf6d6abbc7981fd0ad" - output_digest: "sha256:5b9ae7bd9ea74902e1fc22dd35dadc7271ee350b3fe688051313260d3864bd54" + input_digest: "sha256:dc9842379e787b40c7e71830122e78ef4181c00d5d8aea46a6e5f861be631cef" + output_digest: "sha256:b05e1cdccfa2f91c474e16d065df790645d48f6d35854b2939b31a8b07864e72" files: - path: ".gds/compiled-policy.json" digest: "sha256:37ece9020fd9eeed4ee90582600c5e50cb3a7e32ee773719d1ccfad2d9cd1b6d" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:bba7c8fab8091083982cbb8cbe70c7802cb6b75ea5fb6038dfda137295324c0f" + digest: "sha256:6aac2541813495a8e06db7c5ba36ab666b1d4cf89c207abd9e7394e9031f3ec9" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index c972b06..cf0c78f 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:a826f0d40fa915e13c75cb7bfae1dc225d556a08316a86338dacb35421505c85 -# input-digest: sha256:bd2c29dc422bb1153b6d87d7d3bba6051cfc8f5aa61d7dcf6d6abbc7981fd0ad +# source-tree-digest: sha256:50743872ba177cd8a1b293d497e4c2f03a21a46b9b6040594e4fb8c3fff755d5 +# input-digest: sha256:dc9842379e787b40c7e71830122e78ef4181c00d5d8aea46a6e5f861be631cef # output-digest: sha256:b9bf3d0c64c0fb371596e7d090e82e62aebbfde91929115fc15fb28644e4fd38 # edit-source: # - .gds/repository.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbe5dc..1289072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Versioning. ## [Unreleased] +- Surface the registered device and its `class` (`profile`/`gui`/`docker_mode`/ + `execution_policy`) on `gds context` from the device-local locator bound to + exactly one `estate/devices/*.yaml`. `gds-orient` reads `context.device` + instead of inferring the host. - Add the `desktop-server` device class: Linux x86_64, GUI required, Docker none|rootful|rootless (default none). The bootstrap orchestrator forwards `docker_mode` and server-baseline hardening flags. Name it in `gds-orient` diff --git a/core/context/resolver.go b/core/context/resolver.go index 93a3619..1dacea8 100644 --- a/core/context/resolver.go +++ b/core/context/resolver.go @@ -21,6 +21,7 @@ import ( gitprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/git" "github.com/NDDev-OpenNetwork/github-device-sync/core/serialization" "github.com/NDDev-OpenNetwork/github-device-sync/core/validation" + "github.com/NDDev-OpenNetwork/github-device-sync/core/workspace" ) type Resolver struct { @@ -46,6 +47,7 @@ type Context struct { Repository RepositoryContext `json:"repository"` Mode ModeContext `json:"mode"` Estate EstateContext `json:"estate"` + Device *DeviceContext `json:"device,omitempty"` Policy PolicyContext `json:"policy"` Agent AgentContext `json:"agent"` Boundaries []BoundaryContext `json:"boundaries"` @@ -77,6 +79,19 @@ type EstateContext struct { Root string `json:"root,omitempty"` } +// DeviceContext is the device descriptor bound by the device-local estate +// locator. It is omitted when no locator exists or the locator does not +// name a descriptor under the proven estate root. Context never infers a +// device from hostname, cwd, or the first yaml in estate/devices. +type DeviceContext struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + OS string `json:"os,omitempty"` + Architecture string `json:"architecture,omitempty"` + Path string `json:"path"` + Class *workspace.DeviceClass `json:"class,omitempty"` +} + type PolicyContext struct { BundleLockPresent bool `json:"bundle_lock_present"` Provenance string `json:"provenance"` @@ -248,6 +263,7 @@ func (resolver *Resolver) Resolve(ctx context.Context, path string) Outcome { } resolveEstate(resolver, &result, &findings) + resolveDevice(resolver, &result, &findings) policyFindingCount := len(findings) document := resolveAppliedPolicy(resolver, &result, &findings, info.WorktreeRoot) if document != nil && repositoryAnchor != nil && result.Policy.Digest != "" && @@ -575,6 +591,134 @@ func resolveEstate(resolver *Resolver, result *Context, findings *[]domain.Findi result.Estate = EstateContext{Registered: true, Root: root} } +func resolveDevice(resolver *Resolver, result *Context, findings *[]domain.Finding) { + if !result.Estate.Registered || result.Estate.Root == "" { + return + } + deviceID := lookupRegisteredDeviceID(resolver, result, findings) + if deviceID == "" { + return + } + matches := []string{} + pattern := filepath.Join(result.Estate.Root, "estate", "devices", "*.yaml") + paths, err := filepath.Glob(pattern) + if err != nil { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_DESCRIPTOR_UNREADABLE", + Severity: domain.SeverityHigh, + Message: "Cannot enumerate device descriptors for the registered device.", + Evidence: map[string]any{"pattern": pattern, "error": err.Error()}, + }) + return + } + for _, path := range paths { + if peekDeviceID(path) != deviceID { + continue + } + matches = append(matches, path) + } + if len(matches) == 0 { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_DESCRIPTOR_MISSING", + Severity: domain.SeverityHigh, + Message: "Registered device_id has no matching estate/devices descriptor.", + Evidence: map[string]any{"device_id": deviceID, "estate_root": result.Estate.Root}, + }) + return + } + if len(matches) != 1 { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_DESCRIPTOR_AMBIGUOUS", + Severity: domain.SeverityHigh, + Message: "Registered device_id matches more than one estate/devices descriptor.", + Evidence: map[string]any{"device_id": deviceID, "paths": matches}, + }) + return + } + descriptor, deviceFindings := workspace.LoadDevice(matches[0], resolver.schemas) + if len(deviceFindings) != 0 { + *findings = append(*findings, deviceFindings...) + return + } + result.Device = &DeviceContext{ + ID: descriptor.Device.ID, + Name: descriptor.Device.Name, + OS: descriptor.Device.OS, + Architecture: descriptor.Device.Architecture, + Path: matches[0], + Class: descriptor.Device.Class, + } +} + +func lookupRegisteredDeviceID(resolver *Resolver, result *Context, findings *[]domain.Finding) string { + registrationPath, err := estateregistry.DefaultPath(resolver.getenv, resolver.userHome) + if err != nil { + return "" + } + if _, err := os.Lstat(registrationPath); errors.Is(err, os.ErrNotExist) { + return "" + } else if err != nil { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_NOT_PROVEN", + Severity: domain.SeverityMedium, + Message: fmt.Sprintf("Device-local estate registration cannot be inspected: %v", err), + Evidence: map[string]any{"registration_path": registrationPath}, + }) + return "" + } + registration, registrationFindings := estateregistry.Load(registrationPath, resolver.schemas) + if len(registrationFindings) != 0 { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_NOT_PROVEN", + Severity: domain.SeverityMedium, + Message: "Device-local estate registration cannot bind a device descriptor.", + Evidence: map[string]any{"registration_path": registrationPath}, + }) + return "" + } + if !samePhysicalPath(registration.Document.Estate.Root, result.Estate.Root) { + *findings = append(*findings, domain.Finding{ + Code: "GDS_CONTEXT_DEVICE_LOCATOR_ROOT_MISMATCH", + Severity: domain.SeverityMedium, + Message: "Device-local estate registration does not name the proven estate root.", + Evidence: map[string]any{ + "registration_path": registrationPath, + "registered_root": registration.Document.Estate.Root, + "estate_root": result.Estate.Root, + }, + }) + return "" + } + return registration.Document.DeviceID +} + +func peekDeviceID(path string) string { + value, err := serialization.DecodeFile(path) + if err != nil { + return "" + } + object, _ := value.(map[string]any) + device, _ := object["device"].(map[string]any) + id, _ := device["id"].(string) + return id +} + +func samePhysicalPath(left string, right string) bool { + resolve := func(path string) (string, bool) { + if path == "" { + return "", false + } + physical, err := filepath.EvalSymlinks(path) + if err != nil { + return filepath.Clean(path), true + } + return filepath.Clean(physical), true + } + a, aOK := resolve(left) + b, bOK := resolve(right) + return aOK && bOK && a == b +} + func resolveDirectory(path string) (string, error) { absolute, err := filepath.Abs(path) if err != nil { diff --git a/core/context/resolver_test.go b/core/context/resolver_test.go index 4d0add7..b89b6c1 100644 --- a/core/context/resolver_test.go +++ b/core/context/resolver_test.go @@ -293,6 +293,19 @@ func TestResolveEstateUsesDeviceLocalRegistration(t *testing.T) { if len(resolveFindings) != 0 || !result.Estate.Registered || result.Estate.Root != expectedRoot { t.Fatalf("estate = %#v, findings = %#v", result.Estate, resolveFindings) } + writeContextDeviceDescriptor(t, root, "device_01JEXAMPZ00000000000000000", "desktop-server", "rootful") + resolveDevice(resolver, &result, &resolveFindings) + if len(resolveFindings) != 0 || result.Device == nil { + t.Fatalf("device = %#v, findings = %#v", result.Device, resolveFindings) + } + if result.Device.ID != "device_01JEXAMPZ00000000000000000" || + result.Device.Class == nil || + result.Device.Class.Profile != "desktop-server" || + result.Device.Class.DockerMode != "rootful" || + result.Device.Class.GUI != "enabled" || + result.Device.Class.ExecutionPolicy != "interactive-desktop-server" { + t.Fatalf("device = %#v", result.Device) + } } func TestResolveEstateRejectsRegistrationAnchorDrift(t *testing.T) { @@ -337,6 +350,107 @@ func TestResolveEstateRejectsRegistrationAnchorDrift(t *testing.T) { } } +func TestResolveDeviceFromControlPlaneSelfAndLocator(t *testing.T) { + root := writeTestControlPlaneAnchor(t) + writeContextDeviceDescriptor(t, root, "device_01JEXAMPZ00000000000000000", "desktop-server", "rootful") + configHome := t.TempDir() + schemas, err := validation.NewSchemaSet() + if err != nil { + t.Fatal(err) + } + anchorEvidence, err := anchor.Observe(root) + if err != nil { + t.Fatal(err) + } + candidate, findings := estateregistry.NewCandidate( + "device_01JEXAMPZ00000000000000000", + "repo_01M0EZ7TB3KNXNSP78Z8M64WXG", + root, + anchorEvidence.File.ContentDigest, + schemas, + ) + if len(findings) != 0 { + t.Fatal(findings) + } + registrationPath := filepath.Join(configHome, "github-device-sync", estateregistry.FileName) + if err := os.MkdirAll(filepath.Dir(registrationPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(registrationPath, candidate.Raw, 0o644); err != nil { + t.Fatal(err) + } + resolver := &Resolver{ + manifests: manifest.NewLoader(schemas), schemas: schemas, + getenv: func(key string) string { + if key == "XDG_CONFIG_HOME" { + return configHome + } + return "" + }, + userHome: func() (string, error) { return t.TempDir(), nil }, + } + result := Context{ + Repository: RepositoryContext{Roles: []string{"control-plane"}}, + Workspace: WorkspaceContext{GitWorktreeRoot: root}, + } + resolveFindings := []domain.Finding{} + resolveEstate(resolver, &result, &resolveFindings) + resolveDevice(resolver, &result, &resolveFindings) + if len(resolveFindings) != 0 || !result.Estate.Registered || result.Device == nil { + t.Fatalf("estate = %#v, device = %#v, findings = %#v", result.Estate, result.Device, resolveFindings) + } + if result.Device.Class.Profile != "desktop-server" || result.Device.Class.DockerMode != "rootful" { + t.Fatalf("device = %#v", result.Device) + } +} + +func TestResolveDeviceMissingDescriptor(t *testing.T) { + root := writeTestControlPlaneAnchor(t) + configHome := t.TempDir() + schemas, err := validation.NewSchemaSet() + if err != nil { + t.Fatal(err) + } + anchorEvidence, err := anchor.Observe(root) + if err != nil { + t.Fatal(err) + } + candidate, findings := estateregistry.NewCandidate( + "device_01JEXAMPZ00000000000000000", + "repo_01M0EZ7TB3KNXNSP78Z8M64WXG", + root, + anchorEvidence.File.ContentDigest, + schemas, + ) + if len(findings) != 0 { + t.Fatal(findings) + } + registrationPath := filepath.Join(configHome, "github-device-sync", estateregistry.FileName) + if err := os.MkdirAll(filepath.Dir(registrationPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(registrationPath, candidate.Raw, 0o644); err != nil { + t.Fatal(err) + } + resolver := &Resolver{ + manifests: manifest.NewLoader(schemas), schemas: schemas, + getenv: func(key string) string { + if key == "XDG_CONFIG_HOME" { + return configHome + } + return "" + }, + userHome: func() (string, error) { return t.TempDir(), nil }, + } + result := Context{Repository: RepositoryContext{ID: "repo_01JEXAMPZ0000000000000000B"}} + resolveFindings := []domain.Finding{} + resolveEstate(resolver, &result, &resolveFindings) + resolveDevice(resolver, &result, &resolveFindings) + if result.Device != nil || !hasFinding(resolveFindings, "GDS_CONTEXT_DEVICE_DESCRIPTOR_MISSING") { + t.Fatalf("device = %#v, findings = %#v", result.Device, resolveFindings) + } +} + func newTestResolver(t *testing.T) *Resolver { t.Helper() schemas, err := validation.NewSchemaSet() @@ -400,6 +514,51 @@ func projectionGoldenRoot(t *testing.T) string { return filepath.Join(repositoryRoot(t), "tests", "golden", "projections", "control-plane") } +func writeContextDeviceDescriptor(t *testing.T, root string, deviceID string, profile string, dockerMode string) { + t.Helper() + execution := map[string]string{ + "desktop": "source-lsp-only", + "desktop-builds": "local-dev-with-builds", + "desktop-server": "interactive-desktop-server", + "server": "container-execution-only", + }[profile] + gui := "enabled" + if profile == "server" { + gui = "disabled" + } + body := fmt.Sprintf(`schema_version: 1 +device: + id: %q + name: "example-context-device" + os: "linux" + architecture: "x86_64" + class: + profile: %q + gui: %q + docker_mode: %q + execution_policy: %q +workspace_roots: + projects: "${HOME}/Developer/projects" +materialization: + default_mode: "absent" + include: + - selector: "portfolio:organization-projects" + workspace_root: "projects" + mode: "active" +harnesses: + - "codex" +state: + path: "${XDG_STATE_HOME}/github-device-sync" +`, deviceID, profile, gui, dockerMode, execution) + path := filepath.Join(root, "estate", "devices", "example-context-device.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + func writeTestControlPlaneAnchor(t *testing.T) string { t.Helper() root := t.TempDir() diff --git a/core/validation/device_class_test.go b/core/validation/device_class_test.go index 3128143..4b45af8 100644 --- a/core/validation/device_class_test.go +++ b/core/validation/device_class_test.go @@ -149,10 +149,11 @@ func TestDeviceClassAcceptsAbsent(t *testing.T) { func TestDeviceClassRules(t *testing.T) { t.Parallel() cases := []struct { - name string - osName string - class map[string]any - wantCode string + name string + osName string + architecture string + class map[string]any + wantCode string }{ { name: "server with gui enabled", osName: "linux", @@ -217,6 +218,14 @@ func TestDeviceClassRules(t *testing.T) { }, wantCode: "GDS_DEVICE_CLASS_HARDENING_PROFILE", }, + { + name: "desktop-server on linux arm64", osName: "linux", architecture: "arm64", + class: map[string]any{ + "profile": "desktop-server", "gui": "enabled", "docker_mode": "none", + "execution_policy": "interactive-desktop-server", + }, + wantCode: "GDS_DEVICE_CLASS_DESKTOP_SERVER_PLATFORM", + }, } set, err := NewSchemaSet() if err != nil { @@ -228,6 +237,9 @@ func TestDeviceClassRules(t *testing.T) { value := deviceClassFixture(t, tc.class) device := value["device"].(map[string]any) device["os"] = tc.osName + if tc.architecture != "" { + device["architecture"] = tc.architecture + } findings := set.Validate("device", value, "test") if !findCode(t, findings, tc.wantCode) { t.Fatalf("expected finding %s, got %#v", tc.wantCode, findings) @@ -277,3 +289,64 @@ state: t.Fatalf("expected no findings, got %#v", findings) } } + +// TestDeviceClassContractParity is the lock that schema.go claims exists: +// profile -> execution_policy must match macos-ubuntu-bootstrap +// config/rldyour-contract.json targets.ubuntu.profiles (and macos.desktop). +func TestDeviceClassContractParity(t *testing.T) { + t.Parallel() + want := map[string]string{ + "desktop": "source-lsp-only", + "desktop-builds": "local-dev-with-builds", + "desktop-server": "interactive-desktop-server", + "server": "container-execution-only", + } + if len(deviceClassExecutionPolicies) != len(want) { + t.Fatalf("deviceClassExecutionPolicies = %#v, want %#v", deviceClassExecutionPolicies, want) + } + for profile, policy := range want { + if deviceClassExecutionPolicies[profile] != policy { + t.Fatalf("profile %s: execution_policy = %q, want %q", + profile, deviceClassExecutionPolicies[profile], policy) + } + } +} + +func TestDeviceClassRoundTripsDesktopServerRootful(t *testing.T) { + t.Parallel() + yaml := []byte(` +schema_version: 1 +device: + id: device_0Q0MPJ4Z2ENZ97XWETRESKZGTH + name: example-user-test + os: linux + architecture: x86_64 + class: + profile: desktop-server + gui: enabled + docker_mode: rootful + execution_policy: interactive-desktop-server +workspace_roots: + control-plane: "${HOME}/Developer/control-plane" +materialization: + default_mode: absent + include: + - selector: "portfolio:estate-control-plane" + workspace_root: control-plane + mode: active +harnesses: [codex] +state: + path: "${XDG_STATE_HOME}/github-device-sync" +`) + value, err := serialization.Decode("device.yaml", yaml) + if err != nil { + t.Fatal(err) + } + set, err := NewSchemaSet() + if err != nil { + t.Fatal(err) + } + if findings := set.Validate("device", value, "test"); len(findings) != 0 { + t.Fatalf("expected no findings, got %#v", findings) + } +} diff --git a/docs/contracts/cli-v1.md b/docs/contracts/cli-v1.md index 81108d0..bf92493 100644 --- a/docs/contracts/cli-v1.md +++ b/docs/contracts/cli-v1.md @@ -87,6 +87,7 @@ Resolves: - repository anchor and stable identity; - standalone or embedded-submodule mode; - trusted estate root when proven; +- registered device identity and class when the locator binds a descriptor; - bundle-lock presence; - skill profile routing; - independent mutation boundaries. diff --git a/docs/contracts/context-resolution-v1.md b/docs/contracts/context-resolution-v1.md index d7e7060..4ac41cb 100644 --- a/docs/contracts/context-resolution-v1.md +++ b/docs/contracts/context-resolution-v1.md @@ -46,6 +46,14 @@ contains only: - canonical physical `root`; - exact repository-anchor digest. +After the estate root is proven, `gds context` binds that `device_id` to +exactly one `estate/devices/*.yaml` under the proven root and reports it as +`context.device` (including optional `class`). Missing locator omits the +field. A locator whose root is not the proven estate is +`GDS_CONTEXT_DEVICE_LOCATOR_ROOT_MISMATCH`. A `device_id` with no matching +descriptor is `GDS_CONTEXT_DEVICE_DESCRIPTOR_MISSING`. Context never infers +a device from hostname, cwd, or the first yaml in the directory. + Resolution rejects missing, symlinked, oversized, malformed, identity-drifted, role-drifted, moved, or anchor-drifted registrations. Missing registration is `NOT_PROVEN`, never an implicit fallback to directory ancestry. diff --git a/skills/canonical/gds-bootstrap-device/SKILL.md b/skills/canonical/gds-bootstrap-device/SKILL.md index 1dc69e8..70acb77 100644 --- a/skills/canonical/gds-bootstrap-device/SKILL.md +++ b/skills/canonical/gds-bootstrap-device/SKILL.md @@ -58,7 +58,9 @@ terminal, then wait for confirmation before continuing. The script prompts for sudo and handles the rest. Desktop customization (`scripts/ubuntu/desktop.sh`) is called automatically for GUI-enabled `desktop` and `desktop-server` profiles; each step is independent and - idempotent. + idempotent. `desktop-server` also forwards declared `docker_mode` + (default `none`; `rootful` and `rootless` are explicit installer + choices) and optional server-baseline hardening flags. 2. **Harness module installs (phase 2, inside OS bootstrap)** may install deb packages (e.g. ZCode). The OS bootstrap script prompts for sudo once and diff --git a/skills/canonical/gds-orient/SKILL.md b/skills/canonical/gds-orient/SKILL.md index aecd9c4..28c70e8 100644 --- a/skills/canonical/gds-orient/SKILL.md +++ b/skills/canonical/gds-orient/SKILL.md @@ -38,12 +38,14 @@ provider state. 1. Run `gds context --json` from the current directory. 2. If needed, run `gds status --json` for local Git evidence. 3. Explain the stable repository identity, roles, mode, mutation boundaries, - effective policy evidence, and selected skill profiles. When the resolved - device declares a `class:` block (profile/gui/docker_mode/execution_policy), - surface it too: the class tells whether this is a `desktop`, - `desktop-builds`, tunneled `desktop-server`, or headless `server` host - and which execution policy governs builds, and it selects the OS-installer - flags the phased bootstrap drives. + effective policy evidence, and selected skill profiles. When + `context.device` is present, surface it: `id`/`name`/`os`/`architecture` + and, when `context.device.class` is set, whether this is a `desktop`, + `desktop-builds`, tunneled `desktop-server`, or headless `server` host, + which `docker_mode` it declared, and which execution policy governs + builds. That class also selects the OS-installer flags the phased + bootstrap drives. Do not infer a device from hostname or cwd when the + field is omitted. 4. Route the user to the smallest applicable workflow. ## Stop conditions @@ -82,5 +84,7 @@ by the schema validator (`GDS_DEVICE_CLASS_*`): `desktop` permits only permits `none|rootful|rootless` (default `none`) with `gui: enabled`, and `server` is always `gui: disabled`. The phased bootstrap orchestrator that consumes the class is `scripts/bootstrap-device.sh`; see -`docs/runbooks/bootstrap-device.md` for the seam. Otherwise no additional -runtime reference is required; use current structured `gds` output. +`docs/runbooks/bootstrap-device.md` for the seam. `gds context --json` binds +`device` from the device-local estate locator (`device_id`) to exactly one +`estate/devices/*.yaml` under the proven estate root. Agents must not invent +a device when that object is absent. diff --git a/tests/test_bootstrap_device.py b/tests/test_bootstrap_device.py index 8cb7611..a2cf768 100644 --- a/tests/test_bootstrap_device.py +++ b/tests/test_bootstrap_device.py @@ -50,10 +50,12 @@ def test_source_build_never_accepts_a_merely_runnable_binary() -> None: def test_orient_skill_names_desktop_server_in_the_workflow() -> None: skill = (ROOT / "skills/canonical/gds-orient/SKILL.md").read_text(encoding="utf-8") assert "tunneled `desktop-server`, or headless `server`" in skill + assert "`context.device.class`" in skill bootstrap = (ROOT / "skills/canonical/gds-bootstrap-device/SKILL.md").read_text( encoding="utf-8" ) assert "`desktop` and `desktop-server` profiles" in bootstrap + assert "forwards declared `docker_mode`" in bootstrap def test_orchestrator_forwards_class_docker_mode() -> None: