diff --git a/CLAUDE.md b/CLAUDE.md index 6583184..a05b475 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,9 +67,11 @@ Publishable versions are `vMAJOR.MINOR.PATCH[-prerelease]`; build metadata is excluded so the GitHub Release and OCI aliases share one unambiguous identity. Both adapters persist effective settings. Rebuild functions locate adjacent -state and reuse paths/runtime/volume/UID/GID/build/channel choices. Uninstallers -parse that state, verify resource labels, and refuse unrelated fixed-name -resources. On Windows, `FORMAT=1` has an aligned closed field set but native +state and reuse paths/runtime/volume/UID/GID/build/channel/SSH-mount choices. +Uninstallers parse that state, verify resource labels, and refuse unrelated +fixed-name resources. Writers emit `FORMAT=2`; readers also accept `FORMAT=1`, +which lacks `MOUNT_SSH` (read as `0`). See ADR 0010 before adding a field. +On Windows, the format has an aligned closed field set but native PowerShell and Git Bash path/profile values are adapter-native; only the creating adapter may consume that state. Pre-v1.1 installs need explicit adoption; an adopted unlabeled volume also needs force before purge. @@ -154,8 +156,11 @@ artifact, signing, and reporting details. PowerShell 7 is the supported native Windows adapter. Use CurrentUserAllHosts-compatible integration and validate the final Box start. -It mounts the native user's `.ssh` directory read-only when present and does not -forward `SSH_AUTH_SOCK`. Git Bash uses the separate Bash adapter and owns its +It does not forward `SSH_AUTH_SOCK` and mounts the native user's `.ssh` +directory read-only only on explicit opt-in (`-MountSsh` or +`SQUAREBOX_MOUNT_SSH=1`, persisted as `MOUNT_SSH`). Both adapters default to no +`.ssh` directory mount; the Bash adapter's agent forwarding mounts only +`config`/`known_hosts`. Git Bash uses the separate Bash adapter and owns its MSYS shell integration and agent-socket translation. Keep the shared state field names and semantic intent aligned, but fail closed rather than cross-consuming adapter-native lifecycle state. diff --git a/CONTEXT.md b/CONTEXT.md index 15bb52e..e054467 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,8 +18,8 @@ _Avoid_: Raw tag, latest commit **Install identity**: The durable record of the runtime, paths, resource names, source revision, -host identity, and observed image identity managed by one Squarebox -installation. Release pulls record an immutable digest; source/edge builds +host identity, observed image identity, and lifecycle preferences (such as the +opt-in SSH-directory mount) managed by one Squarebox installation. Release pulls record an immutable digest; source/edge builds record their local image ID and reference. _Avoid_: Installer environment, defaults diff --git a/README.md b/README.md index 8193755..174069a 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Flags: `--build` (build from source), `--edge` (latest `main`), `--adopt` | `SQUAREBOX_RUNTIME` | auto | Force `docker` or `podman`. | | `SQUAREBOX_HOME_VOLUME` | `squarebox-home` | Name of the named volume backing `/home/dev`. | | `SQUAREBOX_EDGE` | `0` | `1` is equivalent to `--edge`. | +| `SQUAREBOX_MOUNT_SSH` | `0` | `1` mounts your host `~/.ssh` directory, **private keys included**, read-only into the Box when no SSH agent is forwarded. Recorded for rebuilds; set `0` to turn it back off. See **SSH access** below. | **Non-interactive provisioning** — set any of these to a comma-separated list to pre-select a toolset and install it without prompts (handy for servers and @@ -136,6 +137,23 @@ code. Release pulls record an immutable image digest; source/edge builds record their local image ID/reference. Existing pre-v1.1 checkouts require a one-time reviewed `--adopt`/`-Adopt`. +**SSH access** + +The Box does not receive your SSH private-key files by default, because anything +running inside it, including unattended `*-yolo` AI agents, could read them. + +- **SSH agent (recommended).** When `SSH_AUTH_SOCK` points at a running agent, + the Bash/POSIX and Git Bash adapters forward the agent socket and mount only + `~/.ssh/config` and `~/.ssh/known_hosts` read-only. Key files stay on the + host, though Box processes can ask the agent to sign while the socket is + available. +- **No agent.** `~/.ssh` is not mounted, and install prints a note. To mount + the directory read-only instead, opt in with `SQUAREBOX_MOUNT_SSH=1` (native + PowerShell: `.\install.ps1 -MountSsh` or `$env:SQUAREBOX_MOUNT_SSH = '1'`). + The choice is recorded in the Install identity and reused by + `sqrbx-rebuild`; set `SQUAREBOX_MOUNT_SSH=0` (or `-MountSsh:$false`) on a + rebuild to turn it back off. + **Windows (PowerShell 7+)** Windows users can install directly from PowerShell - no Git Bash required. @@ -150,18 +168,21 @@ Once installed, you can re-run or pass flags from the local copy: .\install.ps1 -Edge # latest main instead of latest release .\install.ps1 -Build # build the resolved source locally .\install.ps1 -Adopt # migrate a legacy pre-v1.1 installation + .\install.ps1 -MountSsh # opt in to mounting %USERPROFILE%\.ssh read-only > **Note:** `irm ... | iex` does not support flags - PowerShell interprets them > as arguments to `Invoke-Expression`, not the script. Use the local -> `.\install.ps1` form for `-Edge`, `-Build`, or `-Adopt`. PowerShell streams -> runtime and Git failures directly by default. +> `.\install.ps1` form for `-Edge`, `-Build`, `-Adopt`, or `-MountSsh`. +> PowerShell streams runtime and Git failures directly by default. > **Windows adapter boundary:** Keep install, rebuild, and uninstall on the > adapter that created the Install identity. Native PowerShell and Git Bash -> use the same `FORMAT=1` field names, but their native path and shell-profile -> values are not interchangeable; cross-adapter state consumption is rejected. -> Native PowerShell mounts `%USERPROFILE%\.ssh` read-only when it exists and -> does not forward `SSH_AUTH_SOCK`. The separate Git Bash adapter supports SSH +> use the same Install identity field names, but their native path and +> shell-profile values are not interchangeable; +> cross-adapter state consumption is rejected. +> Native PowerShell does not forward `SSH_AUTH_SOCK` and mounts +> `%USERPROFILE%\.ssh` read-only only when you opt in with `-MountSsh` or +> `SQUAREBOX_MOUNT_SSH=1`. The separate Git Bash adapter supports SSH > agent-socket forwarding with its Bash lifecycle. > Use `./scripts/migrate-windows-adapter.ps1 -Target PowerShell` or > `-Target GitBash` from PowerShell 7 for an explicit cross-adapter migration. @@ -500,7 +521,7 @@ Manually installed apt packages are still lost, since the image is rebuilt. | Workspace code on the host | Selected tmux/Zsh/Fish packages are reconciled into the new Box | | Managed home: history, auth, assistant data, mise toolchains | Manually installed, unselected APT packages are lost | | Selection state in `/workspace/.squarebox` | Image-tier binaries are replaced by the Candidate digest | -| Host SSH access exposed by the selected lifecycle adapter | Image-managed dotfiles are safely refreshed | +| Host SSH access (agent forwarding, or the recorded `SQUAREBOX_MOUNT_SSH` opt-in) | Image-managed dotfiles are safely refreshed | Use `sqrbx-uninstall --purge` to wipe recorded state. Do not remove a volume by name alone; lifecycle commands verify the Install identity and ownership diff --git a/SECURITY.md b/SECURITY.md index 19cfeb0..5e4a5c5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -272,14 +272,22 @@ Treat code and tools run inside it as having access to: - the Workspace, read-write; - the Managed home, including persisted tool credentials; - the installation-private Git name/email config; -- an SSH agent socket when the Bash/POSIX or Git Bash adapter forwards it, or - explicitly mounted read-only SSH files (the native PowerShell path); +- an SSH agent socket, plus read-only `~/.ssh/config` and `~/.ssh/known_hosts`, + when the Bash/POSIX or Git Bash adapter forwards an available agent; +- the read-only `~/.ssh` directory, including private keys, only when the + operator opts in with `SQUAREBOX_MOUNT_SSH=1` (or `-MountSsh` on native + PowerShell) and no agent is forwarded; - any additional mounts the operator supplies. Agent forwarding keeps private-key files on the host, but processes inside the -Box can ask the forwarded agent to sign while the socket is available. Mounting -SSH files as a fallback—or as native PowerShell's current SSH path—exposes their -contents read-only to Box processes. +Box can ask the forwarded agent to sign while the socket is available. Because +AI assistants may run unattended in the Box (for example the `*-yolo` aliases), +the `~/.ssh` directory is never mounted by default: without an agent the Box +gets no SSH material and install prints how to opt in. The opt-in exposes every +file in that directory, private keys included, read-only to all Box processes. +It is recorded as `MOUNT_SSH` in the Install identity and reused on rebuild +until a rebuild sets `SQUAREBOX_MOUNT_SSH=0`. Prefer an agent, ideally with +per-use confirmation, or a dedicated key with narrow scope. The entrypoint validates numeric UID/GID inputs and refuses unsafe Managed-home dotfile symlinks. On native Linux, an unprivileged lifecycle install also @@ -347,7 +355,7 @@ verify ownership before removal. Recorded directories require a Squarebox marker; an unrelated directory or resource with a familiar fixed name is not authority to delete it. -`FORMAT=1` versions each lifecycle adapter's native state contract; it does not +`FORMAT` versions each lifecycle adapter's native state contract; it does not make Bash/Git Bash and PowerShell Install-identity files interchangeable. Use the matching adapter family for rebuild and uninstall operations, or explicitly convert it with `scripts/migrate-windows-adapter.ps1`. The converter parses the diff --git a/docs/adr/0010-opt-in-ssh-directory-mount.md b/docs/adr/0010-opt-in-ssh-directory-mount.md new file mode 100644 index 0000000..33e6711 --- /dev/null +++ b/docs/adr/0010-opt-in-ssh-directory-mount.md @@ -0,0 +1,58 @@ +# ADR 0010: Make the SSH directory mount an opt-in Install identity setting + +Status: Accepted + +## Context + +When no SSH agent socket was available, the Bash adapter mounted the host's +entire `~/.ssh` directory read-only into the Box. The native PowerShell adapter +never forwards an agent and always mounted `%USERPROFILE%\.ssh` when present. +That directory normally contains private keys. AI assistants can run unattended +inside the Box (the `*-yolo` aliases), so any Box process could read those keys +without the operator having chosen that exposure. + +The choice must survive rebuilds like other lifecycle settings, so it belongs +in the Install identity. ADR 0007 requires a new format, a documented migration, +and cross-language fixtures before a field is added. + +## Decision + +Neither adapter mounts the `~/.ssh` directory by default. Agent forwarding in +the Bash adapter, with its read-only `~/.ssh/config` and `~/.ssh/known_hosts` +mounts, is unchanged because those files contain no private keys. When no agent +is forwarded and the opt-in is off, install prints how to enable it or use an +agent. + +`SQUAREBOX_MOUNT_SSH=1` (both adapters) or `-MountSsh` (native PowerShell) +opts in to the previous read-only directory mount when no agent is forwarded. +`SQUAREBOX_MOUNT_SSH=0` or `-MountSsh:$false` opts out. Any other value fails +before lifecycle mutation. + +The effective preference is recorded as `MOUNT_SSH=0|1` in a new `FORMAT=2` +Install identity, appended after `HOME_VOLUME_ADOPTED`. It records the operator's +preference, not whether a mount occurred on that run, because agent availability +can differ between rebuilds. + +- Writers emit only `FORMAT=2`. +- Readers accept `FORMAT=2`, where `MOUNT_SSH` is required, and `FORMAT=1`, + where `MOUNT_SSH` must be absent and reads as `0`. Every other format fails + closed. +- The next successful rebuild republishes a `FORMAT=1` record as `FORMAT=2`. + `scripts/migrate-windows-adapter.ps1` does the same when it converts state. +- Ownership rules from ADR 0004 and ADR 0009 are unchanged: path and profile + values remain adapter-native and creator-owned. + +`scripts/lib/install-state-schema.json` lists the `FORMAT=2` field order and +the defaults a `FORMAT=1` record omits. The verifier and shared fixtures cover +both formats in all four adapters. + +## Consequences + +Existing installs that relied on the implicit fallback lose `~/.ssh` inside the +Box on their next rebuild until they start an agent or opt in. The release notes +document this as a behavior change. + +An older adapter cannot read `FORMAT=2` state and fails closed, so going back to +an older release needs a fresh install identity or a reviewed manual edit of the +state file. This matches the ADR 0007 rule that readers reject formats they do +not recognize. diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md index 313f47d..57ed1fd 100644 --- a/docs/releases/v1.3.0.md +++ b/docs/releases/v1.3.0.md @@ -1,8 +1,43 @@ # Squarebox v1.3.0 migration guide +## SSH directory mount is now opt-in + +**Behavior change on your next rebuild.** Squarebox no longer mounts your host +`~/.ssh` directory, which usually holds private keys, into the Box by default. +AI assistants can run unattended in the Box, so they should not be able to read +those keys unless you choose that. + +- **Bash/POSIX and Git Bash with an SSH agent:** nothing changes. The agent + socket is still forwarded, with read-only `~/.ssh/config` and + `~/.ssh/known_hosts`. +- **Bash/POSIX and Git Bash without an agent:** previously `~/.ssh` was mounted + read-only as a fallback. Now nothing is mounted, and install prints a note. +- **Native PowerShell:** previously `%USERPROFILE%\.ssh` was always mounted + read-only when present. Now it is mounted only on opt-in. + +To keep the previous behavior, opt in once on your next rebuild. The choice is +recorded in the Install identity and reused by later rebuilds: + +```bash +SQUAREBOX_MOUNT_SSH=1 sqrbx-rebuild +``` + +```powershell +.\install.ps1 -MountSsh # or: $env:SQUAREBOX_MOUNT_SSH = '1'; sqrbx-rebuild +``` + +Set `SQUAREBOX_MOUNT_SSH=0` (or `-MountSsh:$false`) on a later rebuild to turn +it back off. Starting an SSH agent before rebuilding is the safer alternative, +because key files then stay on the host. + +The Install identity now uses `FORMAT=2`, which adds the `MOUNT_SSH` field. +v1.3.0 reads existing `FORMAT=1` state as opted out and rewrites it as +`FORMAT=2` on the next successful rebuild. Releases before v1.3.0 cannot read +`FORMAT=2` state; see ADR 0010. + ## Windows lifecycle adapter migration -Git Bash and native PowerShell retain strict adapter-native `FORMAT=1` state. +Git Bash and native PowerShell retain strict adapter-native Install identity state. Normal rebuild and uninstall commands do not reinterpret foreign profile paths. From PowerShell 7, convert an existing identity explicitly: @@ -17,9 +52,11 @@ validates data-only state, verifies live runtime ownership, moves shell integration transactionally, and retains the same Box, image, Workspace, and Managed home. After success, use only the target adapter for lifecycle work. -Native Windows OpenSSH-agent forwarding remains experimental. PowerShell keeps -the read-only SSH-directory fallback; Git Bash can forward an already available -Unix-compatible socket. Migration does not install a named-pipe relay. +Native Windows OpenSSH-agent forwarding remains experimental. PowerShell mounts +the SSH directory read-only only on opt-in (see above); Git Bash can forward an +already available Unix-compatible socket. Migration does not install a +named-pipe relay. It preserves a recorded `MOUNT_SSH` choice and converts +`FORMAT=1` state to `FORMAT=2` with the opt-in off. ## Box PID limit applies on recreation diff --git a/install.ps1 b/install.ps1 index 66e8db1..3005086 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,7 @@ param( [switch]$Edge, [switch]$Build, [switch]$Adopt, + [switch]$MountSsh, [string]$InstallDir, [string]$WorkspaceDir, [ValidateSet('docker', 'podman')][string]$Runtime, @@ -156,7 +157,7 @@ $StateFields = @( 'HOME_VOLUME', 'CONTAINER_NAME', 'IMAGE_ALIAS', 'IMAGE_REPOSITORY', 'IMAGE_REF', 'IMAGE_ID', 'IMAGE_DIGEST', 'SOURCE_REF', 'SOURCE_COMMIT', 'RELEASE_TAG', 'REQUESTED_TAG', 'PUID', 'PGID', 'BUILD', 'EDGE', 'SHELL_INIT', 'SHELL_RC', - 'ORIGIN', 'HOME_VOLUME_ADOPTED' + 'ORIGIN', 'HOME_VOLUME_ADOPTED', 'MOUNT_SSH' ) function Test-ReleaseTag([string]$Value) { return $Value.Length -le 128 -and $Value -cmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' @@ -181,7 +182,7 @@ function Test-StateId([string]$Value) { return $Value -cmatch '^[0-9]{1,10}$' -and [long]::TryParse($Value, [ref]$parsed) -and $parsed -ge 1 -and $parsed -le 2147483647 } function Assert-InstallState([hashtable]$State, [string]$Path, [string]$ExpectedInstallDir) { - if ($State.FORMAT -ne '1' -or $State.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Abort "Invalid Install identity: $Path" } + if ($State.FORMAT -cnotin @('1', '2') -or $State.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Abort "Invalid Install identity: $Path" } if ($State.RUNTIME -cnotin @('docker', 'podman')) { Abort "Invalid Install identity: $Path (invalid RUNTIME)" } foreach ($name in @('INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR', 'SHELL_INIT', 'SHELL_RC')) { if (-not (Test-StatePath $State[$name])) { Abort "Invalid Install identity: $Path (invalid $name path)" } @@ -220,6 +221,7 @@ function Assert-InstallState([hashtable]$State, [string]$Path, [string]$Expected ($State.EDGE -eq '1' -and $State.BUILD -ne '1')) { Abort "Invalid Install identity: $Path (BUILD, EDGE, and HOME_VOLUME_ADOPTED must be 0 or 1)" } + if ($State.MOUNT_SSH -cnotin @('0', '1')) { Abort "Invalid Install identity: $Path (MOUNT_SSH must be 0 or 1)" } if ($State.ORIGIN -cne $Repo) { Abort "Invalid Install identity: $Path (noncanonical ORIGIN)" } if ($State.EDGE -eq '1') { if ($State.RELEASE_TAG -or $State.REQUESTED_TAG -or $State.SOURCE_REF -cne 'refs/remotes/origin/main') { @@ -259,6 +261,11 @@ function Read-InstallState([string]$Path, [string]$ExpectedInstallDir) { if ($value -match "[`r`n]") { Abort "Malformed Install identity: $Path" } $state.Add($key, $value) } + if ($state.FORMAT -ceq '1') { + # FORMAT=1 predates MOUNT_SSH; its absence there is the default opt-out. + if ($state.ContainsKey('MOUNT_SSH')) { Abort "Install identity field 'MOUNT_SSH' requires FORMAT=2: $Path" } + $state.Add('MOUNT_SSH', '0') + } foreach ($key in $StateFields) { if (-not $state.ContainsKey($key)) { Abort "Missing Install identity field '$key': $Path" } } @@ -320,6 +327,14 @@ if (-not $PSBoundParameters.ContainsKey('Build') -and $State -and $State.BUILD - if ($env:SQUAREBOX_EDGE -eq '1') { $Edge = $true } if ($env:SQUAREBOX_BUILD -eq '1') { $Build = $true } if ($Edge) { $Build = $true } +# Mounting .ssh exposes private keys to every Box process, including unattended +# AI agents, so it is an explicit opt-in recorded for rebuilds. +if (-not $PSBoundParameters.ContainsKey('MountSsh')) { + if ($env:SQUAREBOX_MOUNT_SSH) { + if ($env:SQUAREBOX_MOUNT_SSH -cnotin @('0', '1')) { Abort "SQUAREBOX_MOUNT_SSH must be 0 or 1 (got '$($env:SQUAREBOX_MOUNT_SSH)')." } + $MountSsh = $env:SQUAREBOX_MOUNT_SSH -ceq '1' + } elseif ($State) { $MountSsh = $State.MOUNT_SSH -ceq '1' } +} $DefaultPuid = 1000; $DefaultPgid = 1000 if ($IsLinux) { $hostUid = [int](& id -u); $hostGid = [int](& id -g) @@ -795,8 +810,15 @@ $RuntimeVolumes = @( '-v', "${StarshipDest}:/home/dev/.config/starship.toml$BindSuffix", '-v', "${LazygitDir}:/home/dev/.config/lazygit$BindSuffix" ) +# Native PowerShell never forwards an SSH agent, so .ssh is mounted only on +# explicit opt-in (-MountSsh or SQUAREBOX_MOUNT_SSH=1). $SshDir = Join-Path $UserHome '.ssh' -if (Test-Path $SshDir) { $RuntimeVolumes += @('-v', "${SshDir}:/home/dev/.ssh$ReadOnlyBindSuffix") } +if ((Test-Path $SshDir) -and $MountSsh) { + Write-Host 'Note: mounting .ssh read-only (SSH directory opt-in is on).' + $RuntimeVolumes += @('-v', "${SshDir}:/home/dev/.ssh$ReadOnlyBindSuffix") +} elseif (Test-Path $SshDir) { + Write-Host 'Note: .ssh is not mounted into the Box. Rebuild with -MountSsh or SQUAREBOX_MOUNT_SSH=1 to mount it (including private keys) read-only, or use the Git Bash adapter for SSH agent forwarding.' +} Write-Host 'Creating Candidate Box...' & $Runtime create -it --name $script:CandidateName @RuntimeOptions @RuntimeVolumes $ImageAlias | Out-Null @@ -815,7 +837,7 @@ if (@($stateValues | Where-Object { $_ -match "[`r`n]" }).Count -gt 0) { Abort ' $StateDir = Split-Path $StateFile if (-not (Test-Path $StateDir)) { New-Item -ItemType Directory -Path $StateDir -Force | Out-Null } $stateLines = @( - 'FORMAT=1', "INSTALL_ID=$InstallId", "RUNTIME=$Runtime", "INSTALL_DIR=$InstallDir", + 'FORMAT=2', "INSTALL_ID=$InstallId", "RUNTIME=$Runtime", "INSTALL_DIR=$InstallDir", "WORKSPACE_DIR=$WorkspaceDir", "GIT_CONFIG_DIR=$GitConfigDir", "HOME_VOLUME=$HomeVolume", "CONTAINER_NAME=$ContainerName", "IMAGE_ALIAS=$ImageAlias", "IMAGE_REPOSITORY=$ImageRepository", "IMAGE_REF=$ImageRef", "IMAGE_ID=$ImageId", "IMAGE_DIGEST=$ImageDigest", @@ -823,7 +845,7 @@ $stateLines = @( "REQUESTED_TAG=$RequestedTag", "PUID=$Puid", "PGID=$Pgid", "BUILD=$([int][bool]$Build)", "EDGE=$([int][bool]$Edge)", "SHELL_INIT=$ShellInit", "SHELL_RC=$ProfilePath", "ORIGIN=$Repo", - "HOME_VOLUME_ADOPTED=$([int]$HomeVolumeAdopted)" + "HOME_VOLUME_ADOPTED=$([int]$HomeVolumeAdopted)", "MOUNT_SSH=$([int][bool]$MountSsh)" ) $StateTemp = Join-Path $StateDir ".install-state.$([guid]::NewGuid().ToString('N'))" try { diff --git a/install.sh b/install.sh index 4d63f4c..502c152 100755 --- a/install.sh +++ b/install.sh @@ -29,8 +29,12 @@ Usage: install.sh [--edge] [--build] [--adopt] [--verbose] --verbose Show runtime and Git output. Configuration: SQUAREBOX_DIR, SQUAREBOX_WORKSPACE, SQUAREBOX_RUNTIME, -SQUAREBOX_IMAGE, SQUAREBOX_TAG, SQUAREBOX_HOME_VOLUME, PUID, and PGID. -Omitted values on rebuild are read from the recorded Install identity. +SQUAREBOX_IMAGE, SQUAREBOX_TAG, SQUAREBOX_HOME_VOLUME, SQUAREBOX_MOUNT_SSH, +PUID, and PGID. Omitted values on rebuild are read from the recorded Install +identity. + +SQUAREBOX_MOUNT_SSH=1 opts in to mounting ~/.ssh (including private keys) +read-only when no SSH agent socket is forwarded; 0 turns it back off. EOF } @@ -81,9 +85,9 @@ STATE_CONTAINER_NAME=""; STATE_IMAGE_ALIAS=""; STATE_IMAGE_REPOSITORY="" STATE_IMAGE_REF=""; STATE_IMAGE_ID=""; STATE_IMAGE_DIGEST=""; STATE_SOURCE_REF="" STATE_SOURCE_COMMIT=""; STATE_RELEASE_TAG=""; STATE_REQUESTED_TAG="" STATE_PUID=""; STATE_PGID=""; STATE_BUILD=""; STATE_EDGE="" -STATE_SHELL_INIT=""; STATE_SHELL_RC=""; STATE_ORIGIN=""; STATE_HOME_VOLUME_ADOPTED=0 +STATE_SHELL_INIT=""; STATE_SHELL_RC=""; STATE_ORIGIN=""; STATE_HOME_VOLUME_ADOPTED=0; STATE_MOUNT_SSH=0 -STATE_KEYS="FORMAT INSTALL_ID RUNTIME INSTALL_DIR WORKSPACE_DIR GIT_CONFIG_DIR HOME_VOLUME CONTAINER_NAME IMAGE_ALIAS IMAGE_REPOSITORY IMAGE_REF IMAGE_ID IMAGE_DIGEST SOURCE_REF SOURCE_COMMIT RELEASE_TAG REQUESTED_TAG PUID PGID BUILD EDGE SHELL_INIT SHELL_RC ORIGIN HOME_VOLUME_ADOPTED" +STATE_KEYS="FORMAT INSTALL_ID RUNTIME INSTALL_DIR WORKSPACE_DIR GIT_CONFIG_DIR HOME_VOLUME CONTAINER_NAME IMAGE_ALIAS IMAGE_REPOSITORY IMAGE_REF IMAGE_ID IMAGE_DIGEST SOURCE_REF SOURCE_COMMIT RELEASE_TAG REQUESTED_TAG PUID PGID BUILD EDGE SHELL_INIT SHELL_RC ORIGIN HOME_VOLUME_ADOPTED MOUNT_SSH" STATE_SCHEMA_VALID=1 invalid_state() { @@ -122,7 +126,7 @@ valid_state_id() { validate_state_schema() { local file="$1" STATE_SCHEMA_VALID=1 - [ "$STATE_FORMAT" = 1 ] || invalid_state "$file" 'FORMAT must be 1' + case "$STATE_FORMAT" in 1|2) ;; *) invalid_state "$file" 'FORMAT must be 1 or 2' ;; esac [[ "$STATE_INSTALL_ID" =~ ^[A-Za-z0-9._-]{8,128}$ ]] || invalid_state "$file" 'invalid INSTALL_ID' case "$STATE_RUNTIME" in docker|podman) ;; *) invalid_state "$file" 'invalid RUNTIME' ;; esac same_state_path "$STATE_INSTALL_DIR" "$INSTALL_DIR" || invalid_state "$file" "path mismatch: $STATE_INSTALL_DIR != $INSTALL_DIR" @@ -156,6 +160,7 @@ validate_state_schema() { 0:0:0|0:0:1|1:0:0|1:0:1|1:1:0|1:1:1) ;; *) invalid_state "$file" 'invalid BUILD, EDGE, or HOME_VOLUME_ADOPTED flag' ;; esac + case "$STATE_MOUNT_SSH" in 0|1) ;; *) invalid_state "$file" 'invalid MOUNT_SSH flag' ;; esac [ "$STATE_ORIGIN" = "$REPO" ] || invalid_state "$file" 'noncanonical ORIGIN' if [ "$STATE_EDGE" = 1 ]; then [ -z "$STATE_RELEASE_TAG" ] && [ -z "$STATE_REQUESTED_TAG" ] \ @@ -195,7 +200,7 @@ load_state() { key="${line%%=*}"; value="${line#*=}" [ "$key" != "$line" ] || { echo "Error: malformed Install identity: $file" >&2; return 1; } case "$key" in - FORMAT|INSTALL_ID|RUNTIME|INSTALL_DIR|WORKSPACE_DIR|GIT_CONFIG_DIR|HOME_VOLUME|CONTAINER_NAME|IMAGE_ALIAS|IMAGE_REPOSITORY|IMAGE_REF|IMAGE_ID|IMAGE_DIGEST|SOURCE_REF|SOURCE_COMMIT|RELEASE_TAG|REQUESTED_TAG|PUID|PGID|BUILD|EDGE|SHELL_INIT|SHELL_RC|ORIGIN|HOME_VOLUME_ADOPTED) ;; + FORMAT|INSTALL_ID|RUNTIME|INSTALL_DIR|WORKSPACE_DIR|GIT_CONFIG_DIR|HOME_VOLUME|CONTAINER_NAME|IMAGE_ALIAS|IMAGE_REPOSITORY|IMAGE_REF|IMAGE_ID|IMAGE_DIGEST|SOURCE_REF|SOURCE_COMMIT|RELEASE_TAG|REQUESTED_TAG|PUID|PGID|BUILD|EDGE|SHELL_INIT|SHELL_RC|ORIGIN|HOME_VOLUME_ADOPTED|MOUNT_SSH) ;; *) echo "Error: malformed Install identity: $file (unknown field '$key')" >&2; return 1 ;; esac case "|$seen|" in *"|$key|"*) echo "Error: malformed Install identity: $file (duplicate field '$key')" >&2; return 1 ;; esac @@ -213,11 +218,18 @@ load_state() { PGID) STATE_PGID="$value" ;; BUILD) STATE_BUILD="$value" ;; EDGE) STATE_EDGE="$value" ;; SHELL_INIT) STATE_SHELL_INIT="$value" ;; SHELL_RC) STATE_SHELL_RC="$value" ;; ORIGIN) STATE_ORIGIN="$value" ;; HOME_VOLUME_ADOPTED) STATE_HOME_VOLUME_ADOPTED="$value" ;; + MOUNT_SSH) STATE_MOUNT_SSH="$value" ;; esac done <"$file" for expected in $STATE_KEYS; do + # FORMAT=1 predates MOUNT_SSH; its absence there is the default opt-out. + [ "$expected" = MOUNT_SSH ] && [ "$STATE_FORMAT" = 1 ] && continue case "|$seen|" in *"|$expected|"*) ;; *) echo "Error: malformed Install identity: $file (missing field '$expected')" >&2; return 1 ;; esac done + if [ "$STATE_FORMAT" = 1 ]; then + case "|$seen|" in *"|MOUNT_SSH|"*) echo "Error: malformed Install identity: $file (MOUNT_SSH requires FORMAT=2)" >&2; return 1 ;; esac + STATE_MOUNT_SSH=0 + fi validate_state_schema "$file" } @@ -238,6 +250,10 @@ REQUESTED_TAG="${SQUAREBOX_TAG:-${STATE_REQUESTED_TAG:-}}" EDGE="${CLI_EDGE:-${SQUAREBOX_EDGE:-${STATE_EDGE:-0}}}" BUILD="${CLI_BUILD:-${SQUAREBOX_BUILD:-${STATE_BUILD:-0}}}" [ "$EDGE" = 1 ] && BUILD=1 +# Mounting ~/.ssh exposes private keys to every Box process, including +# unattended AI agents, so it is an explicit opt-in recorded for rebuilds. +MOUNT_SSH="${SQUAREBOX_MOUNT_SSH:-${STATE_MOUNT_SSH:-0}}" +case "$MOUNT_SSH" in 0|1) ;; *) echo "Error: SQUAREBOX_MOUNT_SSH must be 0 or 1 (got '$MOUNT_SSH')." >&2; exit 64 ;; esac if [ "$HAD_STATE" = 1 ] && [ "$HOME_VOLUME" != "$STATE_HOME_VOLUME" ]; then echo "Error: cannot change the recorded Managed-home name during rebuild; uninstall this identity first." >&2; exit 1 fi @@ -880,9 +896,11 @@ if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ]; then RT_VOLUMES+=(-v "$SSH_AUTH_SOCK:/tmp/ssh-agent.sock"); RT_OPTS+=(-e SSH_AUTH_SOCK=/tmp/ssh-agent.sock) [ -f "$USER_HOME/.ssh/config" ] && RT_VOLUMES+=(-v "$(bind_spec "$USER_HOME/.ssh/config" /home/dev/.ssh/config "$ro_bind_mode")") [ -f "$USER_HOME/.ssh/known_hosts" ] && RT_VOLUMES+=(-v "$(bind_spec "$USER_HOME/.ssh/known_hosts" /home/dev/.ssh/known_hosts "$ro_bind_mode")") -elif [ -d "$USER_HOME/.ssh" ]; then - echo "Note: SSH agent unavailable; mounting ~/.ssh read-only." +elif [ -d "$USER_HOME/.ssh" ] && [ "$MOUNT_SSH" = 1 ]; then + echo "Note: SSH agent unavailable; mounting ~/.ssh read-only (SQUAREBOX_MOUNT_SSH=1)." RT_VOLUMES+=(-v "$(bind_spec "$USER_HOME/.ssh" /home/dev/.ssh "$ro_bind_mode")") +elif [ -d "$USER_HOME/.ssh" ]; then + echo "Note: SSH agent unavailable and ~/.ssh is not mounted. Start an SSH agent, or rebuild with SQUAREBOX_MOUNT_SSH=1 to mount ~/.ssh (including private keys) read-only." fi echo "Creating Candidate Box..." @@ -910,13 +928,14 @@ write_state() { case "$value" in *$'\n'*|*$'\r'*) echo "Error: newline in Install identity value." >&2; return 1 ;; esac done { - printf 'FORMAT=1\nINSTALL_ID=%s\nRUNTIME=%s\n' "$INSTALL_ID" "$RUNTIME" + printf 'FORMAT=2\nINSTALL_ID=%s\nRUNTIME=%s\n' "$INSTALL_ID" "$RUNTIME" printf 'INSTALL_DIR=%s\nWORKSPACE_DIR=%s\nGIT_CONFIG_DIR=%s\n' "$INSTALL_DIR" "$WORKSPACE_DIR" "$GIT_CONFIG_DIR" printf 'HOME_VOLUME=%s\nCONTAINER_NAME=%s\nIMAGE_ALIAS=%s\n' "$HOME_VOLUME" "$CONTAINER_NAME" "$IMAGE_ALIAS" printf 'IMAGE_REPOSITORY=%s\nIMAGE_REF=%s\nIMAGE_ID=%s\nIMAGE_DIGEST=%s\n' "$IMAGE_REPOSITORY" "$IMAGE_REF" "$IMAGE_ID" "$IMAGE_DIGEST" printf 'SOURCE_REF=%s\nSOURCE_COMMIT=%s\nRELEASE_TAG=%s\nREQUESTED_TAG=%s\n' "$SOURCE_REF" "$SOURCE_COMMIT" "$RELEASE_TAG" "$REQUESTED_TAG" printf 'PUID=%s\nPGID=%s\nBUILD=%s\nEDGE=%s\n' "$PUID" "$PGID" "$BUILD" "$EDGE" printf 'SHELL_INIT=%s\nSHELL_RC=%s\nORIGIN=%s\nHOME_VOLUME_ADOPTED=%s\n' "$SHELL_INIT" "$SHELL_RC" "$REPO" "$HOME_VOLUME_ADOPTED" + printf 'MOUNT_SSH=%s\n' "$MOUNT_SSH" } >"$tmp" chmod 600 "$tmp" 2>/dev/null || true if ! load_state "$tmp"; then rm -f -- "$tmp"; return 1; fi diff --git a/scripts/lib/install-state-schema.json b/scripts/lib/install-state-schema.json index 63ba7ff..d0d8927 100644 --- a/scripts/lib/install-state-schema.json +++ b/scripts/lib/install-state-schema.json @@ -1,5 +1,5 @@ { - "format": 1, + "format": 2, "fields": [ "FORMAT", "INSTALL_ID", @@ -25,20 +25,31 @@ "SHELL_INIT", "SHELL_RC", "ORIGIN", - "HOME_VOLUME_ADOPTED" + "HOME_VOLUME_ADOPTED", + "MOUNT_SSH" ], + "readable_formats": { + "1": { + "absent_fields": { + "MOUNT_SSH": "0" + } + }, + "2": { + "absent_fields": {} + } + }, "rules": [ { "id": "closed-field-set", - "contract": "Every field appears exactly once; unknown, duplicate, and missing fields fail closed." + "contract": "Every field of the record's format appears exactly once; unknown, duplicate, and missing fields fail closed. FORMAT=1 omits MOUNT_SSH and must not contain it." }, { "id": "data-only", "contract": "State is parsed as KEY=VALUE data and is never sourced or evaluated." }, { - "id": "format-1-only", - "contract": "FORMAT must equal 1; future formats fail closed." + "id": "known-formats-only", + "contract": "Writers emit FORMAT=2. Readers accept FORMAT=2 and FORMAT=1, reading each absent field at its listed default; future formats fail closed." }, { "id": "normalized-absolute-paths", @@ -50,7 +61,7 @@ }, { "id": "boolean-flags", - "contract": "BUILD, EDGE, and HOME_VOLUME_ADOPTED are 0 or 1, and EDGE requires BUILD." + "contract": "BUILD, EDGE, HOME_VOLUME_ADOPTED, and MOUNT_SSH are 0 or 1, and EDGE requires BUILD." }, { "id": "source-image-coherence", @@ -58,7 +69,7 @@ }, { "id": "adapter-ownership", - "contract": "FORMAT=1 field names and shared constraints match, while path/profile values remain adapter-native and creator-owned." + "contract": "Field names and shared constraints match across adapters, while path/profile values remain adapter-native and creator-owned." } ] } diff --git a/scripts/migrate-windows-adapter.ps1 b/scripts/migrate-windows-adapter.ps1 index 04f95a2..1932672 100755 --- a/scripts/migrate-windows-adapter.ps1 +++ b/scripts/migrate-windows-adapter.ps1 @@ -17,7 +17,7 @@ $Fields = @( 'HOME_VOLUME', 'CONTAINER_NAME', 'IMAGE_ALIAS', 'IMAGE_REPOSITORY', 'IMAGE_REF', 'IMAGE_ID', 'IMAGE_DIGEST', 'SOURCE_REF', 'SOURCE_COMMIT', 'RELEASE_TAG', 'REQUESTED_TAG', 'PUID', 'PGID', 'BUILD', 'EDGE', 'SHELL_INIT', 'SHELL_RC', - 'ORIGIN', 'HOME_VOLUME_ADOPTED' + 'ORIGIN', 'HOME_VOLUME_ADOPTED', 'MOUNT_SSH' ) $UserHome = [IO.Path]::GetFullPath($UserHomePath) if (-not $InstallDir) { $InstallDir = Join-Path $UserHome 'squarebox' } @@ -39,8 +39,15 @@ function Read-State([string]$Path) { if ($value -match '[\x00-\x1f\x7f]') { Fail "control character in '$key'" } $state[$key] = $value } + if ($state.FORMAT -ceq '1') { + # FORMAT=1 predates MOUNT_SSH. Its absence is the default opt-out, so + # the converted state is published as the equivalent FORMAT=2 record. + if ($state.Contains('MOUNT_SSH')) { Fail "field 'MOUNT_SSH' requires FORMAT=2" } + $state.FORMAT = '2'; $state['MOUNT_SSH'] = '0' + } foreach ($field in $Fields) { if (-not $state.Contains($field)) { Fail "missing field '$field'" } } - if ($state.FORMAT -cne '1' -or $state.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Fail 'invalid FORMAT or INSTALL_ID' } + if ($state.MOUNT_SSH -cnotin @('0', '1')) { Fail 'invalid MOUNT_SSH flag' } + if ($state.FORMAT -cne '2' -or $state.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Fail 'invalid FORMAT or INSTALL_ID' } if ($state.RUNTIME -cnotin @('docker', 'podman')) { Fail 'invalid runtime' } if ($state.ORIGIN -cne 'https://github.com/SquareWaveSystems/squarebox.git') { Fail 'noncanonical origin' } foreach ($name in @('HOME_VOLUME', 'CONTAINER_NAME')) { diff --git a/scripts/verify-install-state-schema.py b/scripts/verify-install-state-schema.py index b044d6c..715b92f 100755 --- a/scripts/verify-install-state-schema.py +++ b/scripts/verify-install-state-schema.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail when a FORMAT=1 lifecycle adapter drifts from its schema contract.""" +"""Fail when a lifecycle adapter drifts from its Install identity schema contract.""" import json import re @@ -9,6 +9,8 @@ ROOT = Path(__file__).resolve().parents[1] SCHEMA = json.loads((ROOT / "scripts/lib/install-state-schema.json").read_text()) EXPECTED = SCHEMA["fields"] +FORMAT = SCHEMA["format"] +LEGACY_ABSENT = SCHEMA["readable_formats"]["1"]["absent_fields"] def require(condition: bool, message: str) -> None: @@ -68,4 +70,23 @@ def bash_fields(text: str, name: str) -> None: require("$State.EDGE -eq '1' -and $State.BUILD -ne '1'" in text, f"{name} does not enforce EDGE requires BUILD") -print("ok - Install identity adapters match the authoritative FORMAT=1 schema") +require(LEGACY_ABSENT == {"MOUNT_SSH": "0"}, "FORMAT=1 compatibility defaults changed") +require(f"printf 'FORMAT={FORMAT}\\n" in bash_writer, f"install.sh does not write FORMAT={FORMAT}") +require(f"'FORMAT={FORMAT}'" in ps_writer, f"install.ps1 does not write FORMAT={FORMAT}") +for name in ("install.sh", "uninstall.sh"): + text = texts[name] + require('case "$STATE_FORMAT" in 1|2) ;;' in text, f"{name} does not accept exactly FORMAT 1 and 2") + require('[ "$expected" = MOUNT_SSH ] && [ "$STATE_FORMAT" = 1 ] && continue' in text + and "MOUNT_SSH requires FORMAT=2" in text, + f"{name} does not apply FORMAT=1 MOUNT_SSH compatibility") + require("in 0|1) ;; *) invalid_state" in text and "invalid MOUNT_SSH flag" in text, + f"{name} does not enforce the MOUNT_SSH flag") +for name in ("install.ps1", "uninstall.ps1"): + text = texts[name] + require("$State.FORMAT -cnotin @('1', '2')" in text, f"{name} does not accept exactly FORMAT 1 and 2") + require("$state.FORMAT -ceq '1'" in text and "requires FORMAT=2" in text + and "$state.Add('MOUNT_SSH', '0')" in text, + f"{name} does not apply FORMAT=1 MOUNT_SSH compatibility") + require("$State.MOUNT_SSH -cnotin @('0', '1')" in text, f"{name} does not enforce the MOUNT_SSH flag") + +print(f"ok - Install identity adapters match the authoritative FORMAT={FORMAT} schema") diff --git a/tests/fixtures/install-state-cases.json b/tests/fixtures/install-state-cases.json index 38206b3..54dd656 100644 --- a/tests/fixtures/install-state-cases.json +++ b/tests/fixtures/install-state-cases.json @@ -5,7 +5,16 @@ {"name": "source_build", "accept": true, "set": {"BUILD": "1", "IMAGE_REF": "squarebox"}}, {"name": "edge_build", "accept": true, "set": {"BUILD": "1", "EDGE": "1", "IMAGE_REF": "squarebox", "SOURCE_REF": "refs/remotes/origin/main", "RELEASE_TAG": "", "REQUESTED_TAG": ""}}, {"name": "adopted_home", "accept": true, "set": {"HOME_VOLUME_ADOPTED": "1"}}, - {"name": "future_format", "accept": false, "set": {"FORMAT": "2"}}, + {"name": "mount_ssh_opt_in", "accept": true, "set": {"MOUNT_SSH": "1"}}, + {"name": "format_1_without_mount_ssh", "accept": true, "set": {"FORMAT": "1"}, "remove": ["MOUNT_SSH"]}, + {"name": "format_1_crlf_without_mount_ssh", "accept": true, "encoding": "crlf", "set": {"FORMAT": "1"}, "remove": ["MOUNT_SSH"]}, + {"name": "format_1_with_mount_ssh", "accept": false, "set": {"FORMAT": "1"}}, + {"name": "format_1_with_mount_ssh_opt_in", "accept": false, "set": {"FORMAT": "1", "MOUNT_SSH": "1"}}, + {"name": "format_2_missing_mount_ssh", "accept": false, "remove": ["MOUNT_SSH"]}, + {"name": "invalid_mount_ssh", "accept": false, "set": {"MOUNT_SSH": "yes"}}, + {"name": "empty_mount_ssh", "accept": false, "set": {"MOUNT_SSH": ""}}, + {"name": "future_format", "accept": false, "set": {"FORMAT": "3"}}, + {"name": "zero_format", "accept": false, "set": {"FORMAT": "0"}}, {"name": "unknown_field", "accept": false, "append": ["DELETE_THIS=/"]}, {"name": "duplicate_field", "accept": false, "append": ["HOME_VOLUME=other"]}, {"name": "missing_field", "accept": false, "remove": ["WORKSPACE_DIR"]}, diff --git a/tests/test-install-state-fixtures.sh b/tests/test-install-state-fixtures.sh index 8c750cc..77e7d9e 100755 --- a/tests/test-install-state-fixtures.sh +++ b/tests/test-install-state-fixtures.sh @@ -22,7 +22,7 @@ with tempfile.TemporaryDirectory() as temporary: home.mkdir() state_dir.mkdir(parents=True) base = { - "FORMAT": "1", + "FORMAT": "2", "INSTALL_ID": "test-install-123", "RUNTIME": "docker", "INSTALL_DIR": str(install_dir), @@ -47,6 +47,7 @@ with tempfile.TemporaryDirectory() as temporary: "SHELL_RC": str(home / ".bashrc"), "ORIGIN": "https://github.com/SquareWaveSystems/squarebox.git", "HOME_VOLUME_ADOPTED": "0", + "MOUNT_SSH": "0", } env = os.environ.copy() env.update({ diff --git a/tests/test-lifecycle-install-state.sh b/tests/test-lifecycle-install-state.sh index b6d7dc6..2253be6 100755 --- a/tests/test-lifecycle-install-state.sh +++ b/tests/test-lifecycle-install-state.sh @@ -410,6 +410,91 @@ EOF "$ROOT/install.sh" --adopt "$HOME/.ssh/config" +printf 'example.test ssh-ed25519 AAAA\n' >"$HOME/.ssh/known_hosts" +printf 'PRIVATE KEY\n' >"$HOME/.ssh/id_ed25519" +export MOCK_RUNTIME="$TMP/runtime-ssh"; mkdir -p "$MOCK_RUNTIME" +export SQUAREBOX_DIR="$TMP/ssh-install" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 +SSH_STATE="$SQUAREBOX_DIR/.squarebox/install-state" +SSH_DIR_MOUNT="-v $HOME/.ssh:/home/dev/.ssh:ro" +SSH_AGENT_SOCKET="$TMP/ssh-agent.sock" +python3 -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' "$SSH_AGENT_SOCKET" +test -S "$SSH_AGENT_SOCKET" +ssh_create_call() { grep '^create ' "$MOCK_RUNTIME/calls" | tail -1; } +assert_no_ssh_dir_mount() { + ! ssh_create_call | grep -Eq ':/home/dev/\.ssh(:ro)?( |$)' +} + +env -u SSH_AUTH_SOCK -u SQUAREBOX_MOUNT_SSH "$ROOT/install.sh" "$TMP/ssh-default.out" 2>&1 +grep -qxF 'FORMAT=2' "$SSH_STATE" +grep -qxF 'MOUNT_SSH=0' "$SSH_STATE" +assert_no_ssh_dir_mount +! ssh_create_call | grep -qF '/home/dev/.ssh/' || exit 1 +grep -q 'SSH agent unavailable and ~/.ssh is not mounted.*SQUAREBOX_MOUNT_SSH=1' "$TMP/ssh-default.out" + +: >"$MOCK_RUNTIME/calls" +env -u SSH_AUTH_SOCK SQUAREBOX_MOUNT_SSH=1 "$SQUAREBOX_DIR/install.sh" "$TMP/ssh-opt-in.out" 2>&1 +ssh_create_call | grep -qF -- "$SSH_DIR_MOUNT" +grep -qxF 'MOUNT_SSH=1' "$SSH_STATE" +grep -q 'mounting ~/.ssh read-only (SQUAREBOX_MOUNT_SSH=1)' "$TMP/ssh-opt-in.out" + +# A rebuild with no SQUAREBOX_MOUNT_SSH reuses the recorded opt-in. +: >"$MOCK_RUNTIME/calls" +env -u SSH_AUTH_SOCK -u SQUAREBOX_MOUNT_SSH "$SQUAREBOX_DIR/install.sh" /dev/null 2>&1 +ssh_create_call | grep -qF -- "$SSH_DIR_MOUNT" +grep -qxF 'MOUNT_SSH=1' "$SSH_STATE" + +# An available agent wins even when opted in; only key-free files are mounted. +: >"$MOCK_RUNTIME/calls" +SSH_AUTH_SOCK="$SSH_AGENT_SOCKET" env -u SQUAREBOX_MOUNT_SSH "$SQUAREBOX_DIR/install.sh" /dev/null 2>&1 +ssh_create_call | grep -qF -- "-v $SSH_AGENT_SOCKET:/tmp/ssh-agent.sock" +ssh_create_call | grep -qF -- "-v $HOME/.ssh/config:/home/dev/.ssh/config:ro" +ssh_create_call | grep -qF -- "-v $HOME/.ssh/known_hosts:/home/dev/.ssh/known_hosts:ro" +assert_no_ssh_dir_mount +grep -qxF 'MOUNT_SSH=1' "$SSH_STATE" + +# SQUAREBOX_MOUNT_SSH=0 turns the recorded opt-in back off; agent forwarding +# and its config/known_hosts mounts remain the default path. +: >"$MOCK_RUNTIME/calls" +SSH_AUTH_SOCK="$SSH_AGENT_SOCKET" SQUAREBOX_MOUNT_SSH=0 "$SQUAREBOX_DIR/install.sh" /dev/null 2>&1 +ssh_create_call | grep -qF -- "-v $SSH_AGENT_SOCKET:/tmp/ssh-agent.sock" +ssh_create_call | grep -qF -- "-v $HOME/.ssh/known_hosts:/home/dev/.ssh/known_hosts:ro" +assert_no_ssh_dir_mount +grep -qxF 'MOUNT_SSH=0' "$SSH_STATE" +: >"$MOCK_RUNTIME/calls" +env -u SSH_AUTH_SOCK -u SQUAREBOX_MOUNT_SSH "$SQUAREBOX_DIR/install.sh" /dev/null 2>&1 +assert_no_ssh_dir_mount +grep -qxF 'MOUNT_SSH=0' "$SSH_STATE" + +cp "$SSH_STATE" "$TMP/ssh-state.before-invalid" +: >"$MOCK_RUNTIME/calls" +set +e +env -u SSH_AUTH_SOCK SQUAREBOX_MOUNT_SSH=yes "$SQUAREBOX_DIR/install.sh" "$TMP/ssh-invalid.out" 2>&1 +ssh_invalid_rc=$? +set -e +test "$ssh_invalid_rc" -eq 64 +grep -q 'SQUAREBOX_MOUNT_SSH must be 0 or 1' "$TMP/ssh-invalid.out" +cmp -s "$SSH_STATE" "$TMP/ssh-state.before-invalid" +! grep -q '^create ' "$MOCK_RUNTIME/calls" || exit 1 + +# A pre-MOUNT_SSH FORMAT=1 identity still loads as opted out, and the next +# rebuild republishes it as FORMAT=2 without mounting ~/.ssh. +sed -i '/^MOUNT_SSH=/d; s/^FORMAT=2$/FORMAT=1/' "$SSH_STATE" +: >"$MOCK_RUNTIME/calls" +env -u SSH_AUTH_SOCK -u SQUAREBOX_MOUNT_SSH "$SQUAREBOX_DIR/install.sh" /dev/null 2>&1 +assert_no_ssh_dir_mount +grep -qxF 'FORMAT=2' "$SSH_STATE" +grep -qxF 'MOUNT_SSH=0' "$SSH_STATE" +sed -i '/^MOUNT_SSH=/d; s/^FORMAT=2$/FORMAT=1/' "$SSH_STATE" +env -u SSH_AUTH_SOCK -u SQUAREBOX_MOUNT_SSH "$SQUAREBOX_DIR/uninstall.sh" --yes >/dev/null +test ! -e "$MOCK_RUNTIME/container" +unset SQUAREBOX_RUNTIME SQUAREBOX_TAG + export HOME="$PRIMARY_HOME" MOCK_RUNTIME="$TMP/runtime" export SQUAREBOX_DIR="$INSTALL" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 SQUAREBOX_AI=codex @@ -428,10 +513,10 @@ grep -qxF 'IMAGE_DIGEST=ghcr.io/squarewavesystems/squarebox@sha256:bbbbbbbbbbbbb grep -qxE 'INSTALL_ID=[A-Za-z0-9._-]{8,128}' "$STATE" grep -qxF "PUID=$EXPECTED_PUID" "$STATE" grep -qxF "PGID=$EXPECTED_PGID" "$STATE" -EXPECTED_KEYS='BUILD CONTAINER_NAME EDGE FORMAT GIT_CONFIG_DIR HOME_VOLUME HOME_VOLUME_ADOPTED IMAGE_ALIAS IMAGE_DIGEST IMAGE_ID IMAGE_REF IMAGE_REPOSITORY INSTALL_DIR INSTALL_ID ORIGIN PGID PUID RELEASE_TAG REQUESTED_TAG RUNTIME SHELL_INIT SHELL_RC SOURCE_COMMIT SOURCE_REF WORKSPACE_DIR' +EXPECTED_KEYS='BUILD CONTAINER_NAME EDGE FORMAT GIT_CONFIG_DIR HOME_VOLUME HOME_VOLUME_ADOPTED IMAGE_ALIAS IMAGE_DIGEST IMAGE_ID IMAGE_REF IMAGE_REPOSITORY INSTALL_DIR INSTALL_ID MOUNT_SSH ORIGIN PGID PUID RELEASE_TAG REQUESTED_TAG RUNTIME SHELL_INIT SHELL_RC SOURCE_COMMIT SOURCE_REF WORKSPACE_DIR' ACTUAL_KEYS=$(cut -d= -f1 "$STATE" | sort | tr '\n' ' ' | sed 's/ $//') [ "$ACTUAL_KEYS" = "$EXPECTED_KEYS" ] -[ "$(wc -l <"$STATE" | tr -d ' ')" = 25 ] +[ "$(wc -l <"$STATE" | tr -d ' ')" = 26 ] test -f "$INSTALL/.squarebox/identity/git/config" grep -q 'user.name=Lifecycle Test' "$INSTALL/.squarebox/identity/git/config" test ! -e "$HOME/.config/git" diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index b05d68a..5b387ff 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -57,6 +57,15 @@ $uninstall = [IO.File]::ReadAllText((Join-Path $Root 'uninstall.ps1')) Assert-True ($install.Contains('--userns=keep-id:uid=1000,gid=1000')) 'rootless Podman does not map host identity to dev' Assert-True ($install.Contains("'--security-opt', 'label=disable'")) 'Podman does not disable private SELinux relabeling' Assert-True ($install.Contains("'--pids-limit=4096'")) 'installer does not bound Box PID exhaustion' +# Host .ssh (private keys) is an explicit, persisted opt-in on the native adapter. +Assert-True ($install.Contains('[switch]$MountSsh')) 'installer has no -MountSsh opt-in switch' +Assert-True ($install.Contains("`$env:SQUAREBOX_MOUNT_SSH -cnotin @('0', '1')")) 'installer does not validate SQUAREBOX_MOUNT_SSH' +Assert-True ($install.Contains("elseif (`$State) { `$MountSsh = `$State.MOUNT_SSH -ceq '1' }")) 'rebuild does not reuse the recorded MOUNT_SSH choice' +Assert-True ($install.Contains('if ((Test-Path $SshDir) -and $MountSsh) {')) '.ssh mount is not gated on the opt-in' +Assert-True (-not ($install -match 'if \(Test-Path \$SshDir\) \{ \$RuntimeVolumes')) 'installer still mounts .ssh unconditionally' +Assert-True (@([regex]::Matches($install, '/home/dev/\.ssh')).Count -eq 1) 'installer has an ungated .ssh mount path' +Assert-True ($install.Contains('Rebuild with -MountSsh or SQUAREBOX_MOUNT_SSH=1')) 'installer does not explain how to opt in to the .ssh mount' +Assert-True ($install.Contains('"MOUNT_SSH=$([int][bool]$MountSsh)"')) 'installer does not persist the MOUNT_SSH choice' Assert-True (-not ($install -match ':ro,Z|BindSuffix.*:Z')) 'PowerShell adapter still emits private :Z binds' Assert-True ($install.Contains('$HomeVolume -cne $State.HOME_VOLUME')) 'Managed-home identity comparison is not case-sensitive' Assert-True ($install.Contains('$owner.Trim() -cne ''__INSTALL_ID__''')) 'generated adapter case-folds Install identity' @@ -179,7 +188,7 @@ try { } foreach ($case in $cases) { $values = [ordered]@{ - FORMAT = '1'; INSTALL_ID = 'test-install-123'; RUNTIME = 'docker' + FORMAT = '2'; INSTALL_ID = 'test-install-123'; RUNTIME = 'docker' INSTALL_DIR = $fixtureInstall; WORKSPACE_DIR = (Join-Path $fixtureRoot 'workspace') GIT_CONFIG_DIR = (Join-Path $fixtureInstall '.squarebox/identity/git') HOME_VOLUME = 'squarebox-home'; CONTAINER_NAME = 'squarebox'; IMAGE_ALIAS = 'squarebox' @@ -190,7 +199,7 @@ try { SOURCE_REF = 'v1.2.3'; SOURCE_COMMIT = 'a' * 40; RELEASE_TAG = 'v1.2.3' REQUESTED_TAG = 'latest'; PUID = '1000'; PGID = '1000'; BUILD = '0'; EDGE = '0' SHELL_INIT = $PROFILE.CurrentUserAllHosts; SHELL_RC = $PROFILE.CurrentUserAllHosts - ORIGIN = $Repo; HOME_VOLUME_ADOPTED = '0' + ORIGIN = $Repo; HOME_VOLUME_ADOPTED = '0'; MOUNT_SSH = '0' } foreach ($property in $case.set.PSObject.Properties) { $fixtureValue = [string]$property.Value @@ -259,7 +268,8 @@ $migrationValues = [ordered]@{ RELEASE_TAG='v1.2.3'; REQUESTED_TAG='latest'; PUID='1000'; PGID='1000'; BUILD='0'; EDGE='0' SHELL_INIT=$gitBashInit; SHELL_RC=$gitBashRc; ORIGIN='https://github.com/SquareWaveSystems/squarebox.git'; HOME_VOLUME_ADOPTED='0' } -[IO.File]::WriteAllLines($migrationState, @($StateFields | ForEach-Object { "$_=$($migrationValues[$_])" }), [Text.UTF8Encoding]::new($false)) +# Start from a pre-MOUNT_SSH FORMAT=1 record; migration publishes FORMAT=2. +[IO.File]::WriteAllLines($migrationState, @($StateFields | Where-Object { $_ -cne 'MOUNT_SSH' } | ForEach-Object { "$_=$($migrationValues[$_])" }), [Text.UTF8Encoding]::new($false)) $oldPath = $env:PATH try { $env:PATH = "$mockBin$([IO.Path]::PathSeparator)$oldPath" @@ -269,6 +279,7 @@ try { $powerState = @{}; Get-Content $migrationState | ForEach-Object { $key, $value = $_ -split '=', 2; $powerState[$key] = $value } Assert-True ($powerState.SHELL_INIT -ceq $profileAll) 'PowerShell migration published the wrong profile identity' Assert-True ($powerState.INSTALL_DIR -ceq [IO.Path]::GetFullPath($migrationInstall)) 'PowerShell migration did not normalize INSTALL_DIR' + Assert-True ($powerState.FORMAT -ceq '2' -and $powerState.MOUNT_SSH -ceq '0') 'FORMAT=1 migration did not publish the default-off FORMAT=2 record' Assert-True ((Get-Content $profileAll) -ccontains '# squarebox-install-id=test-install-123') 'PowerShell profile ownership was not installed' Assert-True (-not (Test-Path $gitBashInit)) 'source Git Bash adapter survived migration' diff --git a/tests/test-lifecycle-static.sh b/tests/test-lifecycle-static.sh index 4cddab4..ea22abf 100755 --- a/tests/test-lifecycle-static.sh +++ b/tests/test-lifecycle-static.sh @@ -94,7 +94,7 @@ grep -q 'DeleteWorkspace' uninstall.ps1 grep -q 'Workspace inside install directory' uninstall.sh grep -q 'Workspace inside install directory' uninstall.ps1 -# FORMAT=1 is deliberately adapter-native. Both readers accept CRLF, but a +# Install identity state is deliberately adapter-native. Both readers accept CRLF, but a # Git-Bash C:/... path is not promised to be interchangeable with a native # PowerShell C:\\... path; each lifecycle adapter must consume its own state. grep -q 'ReadAllLines' install.ps1 diff --git a/uat-checklist.md b/uat-checklist.md index bb9d49e..0a76074 100644 --- a/uat-checklist.md +++ b/uat-checklist.md @@ -26,6 +26,7 @@ optional follow-up run. - [ ] Fresh Bash installer: launch, interactive setup, exit, resume, rebuild, uninstall - [ ] Existing v1.1 Managed home upgrade: no repeated prompts; Selections reconcile +- [ ] No SSH agent: rebuild mounts no `~/.ssh` and prints the opt-in note; `SQUAREBOX_MOUNT_SSH=1` mounts it read-only and persists through a plain rebuild - [ ] Genuine host UID/GID other than 1000: default identity, Workspace, and managed files remain host-owned through rebuild and purge - [ ] Unprivileged Linux PUID/PGID mismatch fails before checkout, config, Install-state, or runtime mutation; a stale recorded identity can be adopted to the current account - [ ] Root-run rootful Docker/Podman PUID/PGID override starts with lifecycle-managed read-only files mounted beneath `/home/dev` diff --git a/uninstall.ps1 b/uninstall.ps1 index 9c7bd4d..2b79f5d 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -22,7 +22,7 @@ $StateFields = @( 'HOME_VOLUME', 'CONTAINER_NAME', 'IMAGE_ALIAS', 'IMAGE_REPOSITORY', 'IMAGE_REF', 'IMAGE_ID', 'IMAGE_DIGEST', 'SOURCE_REF', 'SOURCE_COMMIT', 'RELEASE_TAG', 'REQUESTED_TAG', 'PUID', 'PGID', 'BUILD', 'EDGE', 'SHELL_INIT', 'SHELL_RC', - 'ORIGIN', 'HOME_VOLUME_ADOPTED' + 'ORIGIN', 'HOME_VOLUME_ADOPTED', 'MOUNT_SSH' ) function Test-ReleaseTag([string]$Value) { return $Value.Length -le 128 -and $Value -cmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' @@ -47,7 +47,7 @@ function Test-StateId([string]$Value) { return $Value -cmatch '^[0-9]{1,10}$' -and [long]::TryParse($Value, [ref]$parsed) -and $parsed -ge 1 -and $parsed -le 2147483647 } function Assert-InstallState([hashtable]$State, [string]$Path, [string]$ExpectedInstallDir) { - if ($State.FORMAT -ne '1' -or $State.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Abort "Invalid Install identity: $Path" } + if ($State.FORMAT -cnotin @('1', '2') -or $State.INSTALL_ID -cnotmatch '^[A-Za-z0-9._-]{8,128}$') { Abort "Invalid Install identity: $Path" } if ($State.RUNTIME -cnotin @('docker', 'podman')) { Abort "Invalid Install identity: $Path (invalid RUNTIME)" } foreach ($name in @('INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR', 'SHELL_INIT', 'SHELL_RC')) { if (-not (Test-StatePath $State[$name])) { Abort "Invalid Install identity: $Path (invalid $name path)" } @@ -83,6 +83,7 @@ function Assert-InstallState([hashtable]$State, [string]$Path, [string]$Expected ($State.EDGE -eq '1' -and $State.BUILD -ne '1')) { Abort "Invalid Install identity: $Path (BUILD, EDGE, and HOME_VOLUME_ADOPTED must be 0 or 1)" } + if ($State.MOUNT_SSH -cnotin @('0', '1')) { Abort "Invalid Install identity: $Path (MOUNT_SSH must be 0 or 1)" } if ($State.ORIGIN -cne $Repo) { Abort "Invalid Install identity: $Path (noncanonical ORIGIN)" } if ($State.EDGE -eq '1') { if ($State.RELEASE_TAG -or $State.REQUESTED_TAG -or $State.SOURCE_REF -cne 'refs/remotes/origin/main') { @@ -121,6 +122,11 @@ function Read-InstallState([string]$Path, [string]$ExpectedInstallDir) { if ($value -match "[`r`n]") { Abort "Malformed Install identity: $Path" } $state.Add($key, $value) } + if ($state.FORMAT -ceq '1') { + # FORMAT=1 predates MOUNT_SSH; its absence there is the default opt-out. + if ($state.ContainsKey('MOUNT_SSH')) { Abort "Install identity field 'MOUNT_SSH' requires FORMAT=2: $Path" } + $state.Add('MOUNT_SSH', '0') + } foreach ($key in $StateFields) { if (-not $state.ContainsKey($key)) { Abort "Missing Install identity field '$key': $Path" } } diff --git a/uninstall.sh b/uninstall.sh index 177faa4..cf1cee4 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -64,8 +64,8 @@ STATE_FORMAT=""; INSTALL_ID=""; RUNTIME=""; STATE_INSTALL_DIR=""; WORKSPACE_DIR= GIT_CONFIG_DIR=""; HOME_VOLUME=""; CONTAINER_NAME=""; IMAGE_ALIAS=""; IMAGE_REF="" IMAGE_REPOSITORY=""; IMAGE_ID=""; IMAGE_DIGEST=""; SOURCE_REF=""; SOURCE_COMMIT="" RELEASE_TAG=""; REQUESTED_TAG=""; PUID=""; PGID=""; BUILD=""; EDGE="" -SHELL_INIT=""; SHELL_RC=""; ORIGIN=""; HOME_VOLUME_ADOPTED=0 -STATE_KEYS="FORMAT INSTALL_ID RUNTIME INSTALL_DIR WORKSPACE_DIR GIT_CONFIG_DIR HOME_VOLUME CONTAINER_NAME IMAGE_ALIAS IMAGE_REPOSITORY IMAGE_REF IMAGE_ID IMAGE_DIGEST SOURCE_REF SOURCE_COMMIT RELEASE_TAG REQUESTED_TAG PUID PGID BUILD EDGE SHELL_INIT SHELL_RC ORIGIN HOME_VOLUME_ADOPTED" +SHELL_INIT=""; SHELL_RC=""; ORIGIN=""; HOME_VOLUME_ADOPTED=0; MOUNT_SSH=0 +STATE_KEYS="FORMAT INSTALL_ID RUNTIME INSTALL_DIR WORKSPACE_DIR GIT_CONFIG_DIR HOME_VOLUME CONTAINER_NAME IMAGE_ALIAS IMAGE_REPOSITORY IMAGE_REF IMAGE_ID IMAGE_DIGEST SOURCE_REF SOURCE_COMMIT RELEASE_TAG REQUESTED_TAG PUID PGID BUILD EDGE SHELL_INIT SHELL_RC ORIGIN HOME_VOLUME_ADOPTED MOUNT_SSH" STATE_SCHEMA_VALID=1 invalid_state() { echo "Error: invalid Install identity: $STATE_FILE${1:+ ($1)}" >&2 @@ -102,7 +102,7 @@ valid_state_id() { } validate_state_schema() { STATE_SCHEMA_VALID=1 - [ "$STATE_FORMAT" = 1 ] || invalid_state 'FORMAT must be 1' + case "$STATE_FORMAT" in 1|2) ;; *) invalid_state 'FORMAT must be 1 or 2' ;; esac [[ "$INSTALL_ID" =~ ^[A-Za-z0-9._-]{8,128}$ ]] || invalid_state 'invalid INSTALL_ID' case "$RUNTIME" in docker|podman) ;; *) invalid_state 'invalid RUNTIME' ;; esac same_state_path "$STATE_INSTALL_DIR" "$INSTALL_DIR" || invalid_state 'INSTALL_DIR path mismatch' @@ -132,6 +132,7 @@ validate_state_schema() { 0:0:0|0:0:1|1:0:0|1:0:1|1:1:0|1:1:1) ;; *) invalid_state 'invalid BUILD, EDGE, or HOME_VOLUME_ADOPTED flag' ;; esac + case "$MOUNT_SSH" in 0|1) ;; *) invalid_state 'invalid MOUNT_SSH flag' ;; esac [ "$ORIGIN" = "$REPO" ] || invalid_state 'noncanonical ORIGIN' if [ "$EDGE" = 1 ]; then [ -z "$RELEASE_TAG" ] && [ -z "$REQUESTED_TAG" ] && [ "$SOURCE_REF" = refs/remotes/origin/main ] \ @@ -164,7 +165,7 @@ load_state() { case "$line" in *$'\r'*) echo "Error: malformed Install identity: $STATE_FILE" >&2; return 1 ;; esac key="${line%%=*}"; value="${line#*=}"; [ "$key" != "$line" ] || return 1 case "$key" in - FORMAT|INSTALL_ID|RUNTIME|INSTALL_DIR|WORKSPACE_DIR|GIT_CONFIG_DIR|HOME_VOLUME|CONTAINER_NAME|IMAGE_ALIAS|IMAGE_REPOSITORY|IMAGE_REF|IMAGE_ID|IMAGE_DIGEST|SOURCE_REF|SOURCE_COMMIT|RELEASE_TAG|REQUESTED_TAG|PUID|PGID|BUILD|EDGE|SHELL_INIT|SHELL_RC|ORIGIN|HOME_VOLUME_ADOPTED) ;; + FORMAT|INSTALL_ID|RUNTIME|INSTALL_DIR|WORKSPACE_DIR|GIT_CONFIG_DIR|HOME_VOLUME|CONTAINER_NAME|IMAGE_ALIAS|IMAGE_REPOSITORY|IMAGE_REF|IMAGE_ID|IMAGE_DIGEST|SOURCE_REF|SOURCE_COMMIT|RELEASE_TAG|REQUESTED_TAG|PUID|PGID|BUILD|EDGE|SHELL_INIT|SHELL_RC|ORIGIN|HOME_VOLUME_ADOPTED|MOUNT_SSH) ;; *) echo "Error: malformed Install identity: $STATE_FILE (unknown field '$key')" >&2; return 1 ;; esac case "|$seen|" in *"|$key|"*) echo "Error: malformed Install identity: $STATE_FILE (duplicate field '$key')" >&2; return 1 ;; esac @@ -181,11 +182,18 @@ load_state() { PUID) PUID="$value" ;; PGID) PGID="$value" ;; BUILD) BUILD="$value" ;; EDGE) EDGE="$value" ;; SHELL_INIT) SHELL_INIT="$value" ;; SHELL_RC) SHELL_RC="$value" ;; ORIGIN) ORIGIN="$value" ;; HOME_VOLUME_ADOPTED) HOME_VOLUME_ADOPTED="$value" ;; + MOUNT_SSH) MOUNT_SSH="$value" ;; esac done <"$STATE_FILE" for expected in $STATE_KEYS; do + # FORMAT=1 predates MOUNT_SSH; its absence there is the default opt-out. + [ "$expected" = MOUNT_SSH ] && [ "$STATE_FORMAT" = 1 ] && continue case "|$seen|" in *"|$expected|"*) ;; *) echo "Error: malformed Install identity: $STATE_FILE (missing field '$expected')" >&2; return 1 ;; esac done + if [ "$STATE_FORMAT" = 1 ]; then + case "|$seen|" in *"|MOUNT_SSH|"*) echo "Error: malformed Install identity: $STATE_FILE (MOUNT_SSH requires FORMAT=2)" >&2; return 1 ;; esac + MOUNT_SSH=0 + fi validate_state_schema }