diff --git a/bootstrap/host/DevcontainerCli.psm1 b/bootstrap/host/DevcontainerCli.psm1 index e022963..fab2166 100644 --- a/bootstrap/host/DevcontainerCli.psm1 +++ b/bootstrap/host/DevcontainerCli.psm1 @@ -2305,20 +2305,57 @@ function Get-OpenBaoAgentJobVariable { return '' } +function Resolve-DevcontainerLocalEnv { + <# + .SYNOPSIS + A devcontainer.json value with each ${localEnv:NAME} and ${localEnv:NAME:default} (and the + older ${env:...}) replaced as the devcontainers CLI does at up: NAME from this process's + environment, else the default (the part after NAME's colon, up to the next one), else empty. + Any other ${...} is left as it is. `devcontainer up` writes the devcontainer.metadata label + unresolved, so a value read from it needs this before it reaches the engine. + #> + [CmdletBinding()] + param([string]$Text) + if (-not $Text) { return '' } + return [regex]::Replace($Text, '\$\{(?:localEnv|env):([^}]*)\}', { + param($m) + $parts = $m.Groups[1].Value -split ':' + $value = if ($parts[0]) { [Environment]::GetEnvironmentVariable($parts[0]) } else { $null } + if ($null -ne $value) { return $value } + if ($parts.Count -gt 1) { return $parts[1] } + return '' + }) +} + function Get-DevcontainerRemoteUser { - <# .SYNOPSIS The container's remoteUser (else containerUser) from the devcontainer.metadata label `devcontainer up` sets; empty when the engine does not say. #> + <# + .SYNOPSIS + The user to run ` exec -u` as: the container's remoteUser (else containerUser) from the + devcontainer.metadata label `devcontainer up` sets, its ${localEnv:...} resolved here + (Resolve-DevcontainerLocalEnv), since the label keeps devcontainer.json's text as written. A + value that still holds a ${...} (one only the CLI knows) or resolves empty gives way to the + container's own user (Config.User, the user `exec` runs as without -u); empty when the engine + does not say, so -u is left out. Never returns a value containing "${". + #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Engine, [Parameter(Mandatory)][string]$ContainerId) try { $text = (& $Engine inspect $ContainerId 2>$null) -join "`n" if ($LASTEXITCODE -ne 0 -or -not $text) { return '' } - $label = @($text | ConvertFrom-Json)[0].Config.Labels.'devcontainer.metadata' - if (-not $label) { return '' } - $meta = @($label | ConvertFrom-Json | ForEach-Object { $_ }) - $user = '' - foreach ($m in $meta) { if ($m.remoteUser) { $user = [string]$m.remoteUser } } - if (-not $user) { foreach ($m in $meta) { if ($m.containerUser) { $user = [string]$m.containerUser } } } - return $user + $config = @($text | ConvertFrom-Json)[0].Config + $label = $config.Labels.'devcontainer.metadata' + $meta = if ($label) { @($label | ConvertFrom-Json | ForEach-Object { $_ }) } else { @() } + $remote = ''; $container = '' + foreach ($m in $meta) { + if ($m.remoteUser) { $remote = [string]$m.remoteUser } + if ($m.containerUser) { $container = [string]$m.containerUser } + } + foreach ($candidate in @($remote, $container, [string]$config.User)) { + $user = (Resolve-DevcontainerLocalEnv -Text $candidate).Trim() + if ($user -and -not $user.Contains('${')) { return $user } + if ($candidate) { Write-Verbose "the container user '$candidate' does not resolve here; trying the next" } + } + return '' } catch { return '' } } @@ -2398,12 +2435,47 @@ function Get-OpenBaoContainer { return [pscustomobject]@{ Engine = $engine; Id = $ids[0]; User = (Get-DevcontainerRemoteUser -Engine $engine -ContainerId $ids[0]) } } +function Get-OpenBaoExecFailure { + <# + .SYNOPSIS + Why `openbao-setup unlock --status` could not be asked, in one line, or empty when its answer + only means "no openbao-setup that knows the key file here": exit 0, 4 (not applicable), 2 (an + older openbao-setup's usage), or no /usr/local/bin/openbao-setup (the engine names that path + with "not found" or "no such file or directory", or exits 126 or 127 saying nothing). Anything + else is the engine or the command failing, for example `exec -u` with a user the container does + not have: the exit code and stderr's first line (never stdout, which may hold secrets). + #> + [CmdletBinding()] + param([Parameter(Mandatory)]$Result, [Parameter(Mandatory)]$Container) + $err = "$($Result.Err)" + if ($Result.Code -in 0, 2, 4) { return '' } + if (($Result.Code -in @(126, 127)) -and -not $err.Trim()) { return '' } + if ($err.Contains($script:OpenBaoSetupPath) -and $err -match 'not found|no such file or directory') { return '' } + $first = @($err -split "`r?`n" | Where-Object { $_.Trim() }) | Select-Object -First 1 + $first = "$first".Trim() -replace '^Error response from daemon:\s*', '' + $engine = [IO.Path]::GetFileNameWithoutExtension([string]$Container.Engine) + if (-not $engine) { $engine = 'docker' } + $who = if ($Container.User) { "as user '$($Container.User)'" } else { 'as the container''s default user' } + $line = "$engine exec $who failed (exit $($Result.Code))" + if ($first) { $line += ": $first" } + return $line +} + function Get-OpenBaoUnlockStatus { - <# .SYNOPSIS openbao-setup unlock --status in the container: the JSON object, or $null when there is no openbao-setup that knows it (silently). #> + <# + .SYNOPSIS + openbao-setup unlock --status in the container: the JSON object, or $null when there is no + answer. $null is silent when there is no openbao-setup that knows it; when the call itself + failed (Get-OpenBaoExecFailure), -Failure, if given, receives the one-line reason. + #> [CmdletBinding()] - param([Parameter(Mandatory)]$Container) + param([Parameter(Mandatory)]$Container, [ref]$Failure) + if ($Failure) { $Failure.Value = '' } $r = Invoke-OpenBaoSetup -Engine $Container.Engine -ContainerId $Container.Id -User $Container.User -Arguments @('unlock', '--status') - if ($r.Code -ne 0) { return $null } + if ($r.Code -ne 0) { + if ($Failure) { $Failure.Value = Get-OpenBaoExecFailure -Result $r -Container $Container } + return $null + } $s = ConvertFrom-OpenBaoSetupOutput -Text $r.Out if (-not $s -or -not $s.mode) { return $null } return $s @@ -2428,7 +2500,8 @@ function Invoke-OpenBaoUnlock { `openbao-setup unlock --status`; when mode is keyfile and the server is not ready, opens this server's OpenBao key file and streams it into `openbao-setup unlock --stdin`; prints one line. Silent when mode is off or operator, when the status says ready, and when the container has no - openbao-setup that answers (a repository without the Feature, or an older one). Never throws. + openbao-setup that answers (a repository without the Feature, or an older one); one line, also + after `up`, when asking failed for another reason (Get-OpenBaoExecFailure). Never throws. Returns 0 (done, or nothing to do) or 1 (could not unlock); after `up` the code is ignored. #> [CmdletBinding()] @@ -2440,12 +2513,18 @@ function Invoke-OpenBaoUnlock { if ($Explicit) { Write-OpenBaoLine -Problem "No running devcontainer for $folder. Start it with: task dc:start (which unlocks OpenBao itself)." } return [int]$Explicit.IsPresent } - $s = Get-OpenBaoUnlockStatus -Container $c + $why = '' + $s = Get-OpenBaoUnlockStatus -Container $c -Failure ([ref]$why) } catch { if ($Explicit) { Write-OpenBaoLine -Problem "could not ask the container: $($_.Exception.Message)" } return [int]$Explicit.IsPresent } + if (-not $s -and $why) { + # Not "no openbao-setup here" but the call failing: one line, after up too, so it is not silent. + Write-OpenBaoLine -Problem "could not ask the container for OpenBao's unlock status: $why." + return 1 + } if (-not $s) { if ($Explicit) { Write-OpenBaoLine -Problem 'this devcontainer has no openbao-setup that knows the OpenBao key file (the openbao Feature 1.5.0 or later, in server mode).' } return [int]$Explicit.IsPresent @@ -2539,7 +2618,9 @@ function Invoke-OpenBaoKeyFileMake { $folder = (Resolve-Path -LiteralPath $WorkspaceFolder).Path $c = Get-OpenBaoContainer -WorkspaceFolder $folder -DockerPath $DockerPath if (-not $c) { Write-OpenBaoLine -Problem "No running devcontainer for $folder. Start it with: task dc:start"; return 1 } - $s = Get-OpenBaoUnlockStatus -Container $c + $why = '' + $s = Get-OpenBaoUnlockStatus -Container $c -Failure ([ref]$why) + if (-not $s -and $why) { Write-OpenBaoLine -Problem "could not ask the container for OpenBao's unlock status: $why."; return 1 } if (-not $s) { Write-OpenBaoLine -Problem 'this devcontainer has no openbao-setup that knows the OpenBao key file (the openbao Feature 1.5.0 or later, in server mode).'; return 4 } if ($s.mode -ne 'keyfile') { Write-OpenBaoLine -Problem "the openbao Feature here is set to unlock: $($s.mode); set its option unlock: keyfile (or OPENBAO_SETUP_UNLOCK=keyfile) and rebuild first."; return 4 } $repo = Get-OpenBaoRepoName -WorkspaceFolder $folder @@ -2959,4 +3040,4 @@ function Invoke-DevcontainerCli { exit $rc } -Export-ModuleMember -Function Get-HostTypeId, Get-DefaultHostTypePriority, Resolve-HostType, Resolve-SshAgent, Resolve-HostProfile, Initialize-HostEnvironment, Test-FuseRequested, Get-FuseRefusal, Get-FuseRunArgument, Get-FuseSlotMissing, Write-FuseSlotWarning, Write-FuseRefusedWarning, Get-AudioRequest, Get-AudioRefusal, Invoke-WslSh, Invoke-WslgBind, Initialize-HostAudio, ConvertTo-AudioSecond, Format-AudioDuration, Invoke-HostAudio, ConvertTo-MachineName, Get-HostMachineName, Test-MachineSlotMissing, Write-MachineSlotWarning, Get-DevcontainerConfigFile, Get-HostMount, Test-CloudFolderMount, ConvertTo-MountArgument, ConvertTo-JsonWithoutComment, Get-ConfiguredMountTarget, Test-HostMountsEnabled, Resolve-HostMount, Initialize-HostMountSource, Get-HostMountArgument, Show-HostProfile, Import-SshKey, Import-OpenBaoSshKey, Clear-HostProfileCache, Get-SettingOrEnv, ConvertFrom-TaskCliArgString, Get-PortableNodeDir, Invoke-DcfDownload, Get-DevcontainersCliEntrypoint, Remove-DevcontainersCli, Resolve-DockerPath, Get-WorkspaceFolderVariant, Get-RunningDevcontainerId, Stop-Devcontainer, Test-BuildSubcommand, Get-BuildLockStateDir, Get-BuildLockLabel, Get-BuildLockWaitMessage, Get-EngineBuildxVersion, Get-BuildLockSkipReason, Enter-DevcontainerBuildLock, Exit-DevcontainerBuildLock, Test-EngineSubcommand, Get-ContainerEngineCheck, Test-ContainerEngineResponding, Get-ContainerEngineError, Get-ContainerEngineStartHint, Wait-ContainerEngine, Write-ContainerEngineDiagnosis, Get-DevcontainerCliInvocation, Invoke-DevcontainerCliProcess, Get-LauncherCacheRoot, Get-CloudFolderRcloneVersion, Get-WorkstationRclone, Open-Browser, Invoke-CloudFolderRelayRequest, Invoke-CloudFolderLoginRelay, Invoke-CloudFolderLogin, Get-OpenBaoKeyFileDir, Get-OpenBaoRepoName, Get-OpenBaoKeyFilePath, Find-OpenBaoKeyFile, Protect-OpenBaoKeyFileBlob, Unprotect-OpenBaoKeyFileBlob, Save-OpenBaoKeyFile, Read-OpenBaoKeyFile, ConvertTo-OpenBaoKeyFileJson, Get-OpenBaoAgentJobVariable, Get-DevcontainerRemoteUser, Invoke-OpenBaoSetup, ConvertFrom-OpenBaoSetupOutput, Get-OpenBaoUnlockStatus, Invoke-OpenBaoUnlock, Invoke-OpenBaoKeyFileMake, Export-OpenBaoKeyFile, Import-OpenBaoKeyFile, Remove-OpenBaoKeyFile, Invoke-OpenBaoKeyFileTask, Invoke-DevcontainerCli +Export-ModuleMember -Function Get-HostTypeId, Get-DefaultHostTypePriority, Resolve-HostType, Resolve-SshAgent, Resolve-HostProfile, Initialize-HostEnvironment, Test-FuseRequested, Get-FuseRefusal, Get-FuseRunArgument, Get-FuseSlotMissing, Write-FuseSlotWarning, Write-FuseRefusedWarning, Get-AudioRequest, Get-AudioRefusal, Invoke-WslSh, Invoke-WslgBind, Initialize-HostAudio, ConvertTo-AudioSecond, Format-AudioDuration, Invoke-HostAudio, ConvertTo-MachineName, Get-HostMachineName, Test-MachineSlotMissing, Write-MachineSlotWarning, Get-DevcontainerConfigFile, Get-HostMount, Test-CloudFolderMount, ConvertTo-MountArgument, ConvertTo-JsonWithoutComment, Get-ConfiguredMountTarget, Test-HostMountsEnabled, Resolve-HostMount, Initialize-HostMountSource, Get-HostMountArgument, Show-HostProfile, Import-SshKey, Import-OpenBaoSshKey, Clear-HostProfileCache, Get-SettingOrEnv, ConvertFrom-TaskCliArgString, Get-PortableNodeDir, Invoke-DcfDownload, Get-DevcontainersCliEntrypoint, Remove-DevcontainersCli, Resolve-DockerPath, Get-WorkspaceFolderVariant, Get-RunningDevcontainerId, Stop-Devcontainer, Test-BuildSubcommand, Get-BuildLockStateDir, Get-BuildLockLabel, Get-BuildLockWaitMessage, Get-EngineBuildxVersion, Get-BuildLockSkipReason, Enter-DevcontainerBuildLock, Exit-DevcontainerBuildLock, Test-EngineSubcommand, Get-ContainerEngineCheck, Test-ContainerEngineResponding, Get-ContainerEngineError, Get-ContainerEngineStartHint, Wait-ContainerEngine, Write-ContainerEngineDiagnosis, Get-DevcontainerCliInvocation, Invoke-DevcontainerCliProcess, Get-LauncherCacheRoot, Get-CloudFolderRcloneVersion, Get-WorkstationRclone, Open-Browser, Invoke-CloudFolderRelayRequest, Invoke-CloudFolderLoginRelay, Invoke-CloudFolderLogin, Get-OpenBaoKeyFileDir, Get-OpenBaoRepoName, Get-OpenBaoKeyFilePath, Find-OpenBaoKeyFile, Protect-OpenBaoKeyFileBlob, Unprotect-OpenBaoKeyFileBlob, Save-OpenBaoKeyFile, Read-OpenBaoKeyFile, ConvertTo-OpenBaoKeyFileJson, Get-OpenBaoAgentJobVariable, Resolve-DevcontainerLocalEnv, Get-DevcontainerRemoteUser, Invoke-OpenBaoSetup, ConvertFrom-OpenBaoSetupOutput, Get-OpenBaoUnlockStatus, Invoke-OpenBaoUnlock, Invoke-OpenBaoKeyFileMake, Export-OpenBaoKeyFile, Import-OpenBaoKeyFile, Remove-OpenBaoKeyFile, Invoke-OpenBaoKeyFileTask, Invoke-DevcontainerCli diff --git a/src/devcontainer-cli/CHANGELOG.md b/src/devcontainer-cli/CHANGELOG.md index 4ef8d0b..56cabfe 100644 --- a/src/devcontainer-cli/CHANGELOG.md +++ b/src/devcontainer-cli/CHANGELOG.md @@ -8,6 +8,17 @@ here; `task lint` fails when the entry is missing. Entries from 1.0.0 to 1.18.1 were generated from the commit history (`.github/scripts/changelog.py backfill`) and the "Changes in x.y.z" sections that NOTES.md used to hold; most list commit subjects as written then; the newest of them is in wording for users. +## [1.22.1] - 2026-11-09 + +### Fixed + +- **The OpenBao key file works with a variable remote user (Windows launcher)**: with `"remoteUser": "${localEnv:NAME:default}"` in devcontainer.json, `task dc:openbao:unlock` and `task dc:openbao:keyfile:create` said "this devcontainer has no openbao-setup that knows the OpenBao key file" (create exited 4) and the unlock after `up` did nothing, because the launcher ran `docker exec -u '${localEnv:NAME:default}'` with the text as written. The launcher now resolves `${localEnv:NAME}` and `${localEnv:NAME:default}` as the devcontainers CLI does at `up` (the launcher's environment, which under `task` includes the `.env` files, else the default); any other `${...}` falls back to the container's own user, never reaching `-u`. +- When asking the container for OpenBao's unlock status fails for another reason than a missing or older `openbao-setup`, the launcher now says so in one line, with the exit code and the error's first line (for example `docker exec as user 'X' failed (exit 1): unable to find user X: no matching entries in passwd file`), on `task dc:openbao:unlock`, on `keyfile:create`/`rotate` (exit 1) and after `up`, instead of the misleading "no openbao-setup" or silence. + +### Changed + +- **Act:** none beyond the next bootstrap refresh (re-run the bootstrap to vendor the new launcher); no rebuild. + ## [1.22.0] - 2026-11-06 ### Added diff --git a/src/devcontainer-cli/NOTES.md b/src/devcontainer-cli/NOTES.md index ab2c2d1..a8edf0a 100644 --- a/src/devcontainer-cli/NOTES.md +++ b/src/devcontainer-cli/NOTES.md @@ -156,6 +156,24 @@ from a host, use the `devcontainer:*` tasks (next section). path on the host than inside the container cannot be resolved. - The CLI's own dependencies are resolved by npm at build time and are not locked here, so supply-chain trust in them is npm's, not this Feature's. +- The launcher's `exec` into the container (the OpenBao unlock and key-file + tasks) runs as the remote user from the container's `devcontainer.metadata` + label, and `devcontainer up` writes that label with devcontainer.json's text + unresolved: `"remoteUser": "${localEnv:DEVCONTAINER_USER:host}"` stays + literal there. Since 1.22.1 the launcher resolves `${localEnv:...}` itself + (from its environment, else the default after the colon) and falls back to + the container's own user for any other `${...}`. Before 1.22.1 the `exec` + failed with "unable to find user ${localEnv:...}", reported as "no + openbao-setup that knows the OpenBao key file", and the unlock after `up` + failed silently. On 1.22.0 or earlier, refresh the bootstrap, or make + `remoteUser` a literal. +- `openbao: could not ask the container for OpenBao's unlock status: docker + exec as user 'X' failed (exit N): ...`: the engine refused the `exec`, so + OpenBao itself never answered. Check that user X exists in the container + (`docker exec -u X id`). Run the launcher through `task`: its `dotenv:` + loads `.devcontainer/.env`, while run directly it does not, and a + `${localEnv:NAME:default}` falls back to its default even when `.env` sets + NAME. ## Host-side counterpart @@ -342,14 +360,20 @@ When the mode is `keyfile` and the server is sealed or the token is missing or d server's key file and streams it into `openbao-setup unlock --stdin`, then prints one line (`openbao: unsealed, logged in (the OpenBao key file).`, or why not). Silent when the mode is `off` or `operator`, when the status says `ready`, and when the container has no `openbao-setup` that -answers. With no key file for that server it says so in one line and names +answers. When asking itself fails (the engine refuses the `exec`, for example a user the container +does not have), it says so in one line, with the exit code and the first line of the error, also +after `up`. With no key file for that server it says so in one line and names `task dc:openbao:keyfile:create` (or two custody shares at `task openbao:unseal` in the container, then `task dc:openbao:keyfile:rotate`); the start goes on and OpenBao stays sealed. A failing step never fails the `up`. **How it travels.** Through the engine's own `exec -i -u /usr/local/bin/openbao-setup ...` (the remote user from the container's `devcontainer.metadata` -label), not `devcontainer exec`, which runs the user's profile first; the bytes are written to its +label: `remoteUser`, else `containerUser`; the label keeps devcontainer.json's text as written, so +a `${localEnv:NAME}` or `${localEnv:NAME:default}` in it is resolved here as the devcontainers CLI +does at `up`, from the launcher's environment (under `task`, with the `.env` files loaded), else the +default; any other `${...}` gives way to the container's own user, and `-u` is never sent with a +`${` in it), not `devcontainer exec`, which runs the user's profile first; the bytes are written to its standard input from memory. Never an argument, an environment variable, a temporary file or a log line. Not verified on a real host: `wslc exec -i -u`. diff --git a/src/devcontainer-cli/README.md b/src/devcontainer-cli/README.md index 5c86a9f..594cf7f 100644 --- a/src/devcontainer-cli/README.md +++ b/src/devcontainer-cli/README.md @@ -177,6 +177,24 @@ from a host, use the `devcontainer:*` tasks (next section). path on the host than inside the container cannot be resolved. - The CLI's own dependencies are resolved by npm at build time and are not locked here, so supply-chain trust in them is npm's, not this Feature's. +- The launcher's `exec` into the container (the OpenBao unlock and key-file + tasks) runs as the remote user from the container's `devcontainer.metadata` + label, and `devcontainer up` writes that label with devcontainer.json's text + unresolved: `"remoteUser": "${localEnv:DEVCONTAINER_USER:host}"` stays + literal there. Since 1.22.1 the launcher resolves `${localEnv:...}` itself + (from its environment, else the default after the colon) and falls back to + the container's own user for any other `${...}`. Before 1.22.1 the `exec` + failed with "unable to find user ${localEnv:...}", reported as "no + openbao-setup that knows the OpenBao key file", and the unlock after `up` + failed silently. On 1.22.0 or earlier, refresh the bootstrap, or make + `remoteUser` a literal. +- `openbao: could not ask the container for OpenBao's unlock status: docker + exec as user 'X' failed (exit N): ...`: the engine refused the `exec`, so + OpenBao itself never answered. Check that user X exists in the container + (`docker exec -u X id`). Run the launcher through `task`: its `dotenv:` + loads `.devcontainer/.env`, while run directly it does not, and a + `${localEnv:NAME:default}` falls back to its default even when `.env` sets + NAME. ## Host-side counterpart @@ -363,14 +381,20 @@ When the mode is `keyfile` and the server is sealed or the token is missing or d server's key file and streams it into `openbao-setup unlock --stdin`, then prints one line (`openbao: unsealed, logged in (the OpenBao key file).`, or why not). Silent when the mode is `off` or `operator`, when the status says `ready`, and when the container has no `openbao-setup` that -answers. With no key file for that server it says so in one line and names +answers. When asking itself fails (the engine refuses the `exec`, for example a user the container +does not have), it says so in one line, with the exit code and the first line of the error, also +after `up`. With no key file for that server it says so in one line and names `task dc:openbao:keyfile:create` (or two custody shares at `task openbao:unseal` in the container, then `task dc:openbao:keyfile:rotate`); the start goes on and OpenBao stays sealed. A failing step never fails the `up`. **How it travels.** Through the engine's own `exec -i -u /usr/local/bin/openbao-setup ...` (the remote user from the container's `devcontainer.metadata` -label), not `devcontainer exec`, which runs the user's profile first; the bytes are written to its +label: `remoteUser`, else `containerUser`; the label keeps devcontainer.json's text as written, so +a `${localEnv:NAME}` or `${localEnv:NAME:default}` in it is resolved here as the devcontainers CLI +does at `up`, from the launcher's environment (under `task`, with the `.env` files loaded), else the +default; any other `${...}` gives way to the container's own user, and `-u` is never sent with a +`${` in it), not `devcontainer exec`, which runs the user's profile first; the bytes are written to its standard input from memory. Never an argument, an environment variable, a temporary file or a log line. Not verified on a real host: `wslc exec -i -u`. @@ -697,6 +721,17 @@ so the same script serves the container and the host-side tasks. ## Changes +### [1.22.1] - 2026-11-09 + +#### Fixed + +- **The OpenBao key file works with a variable remote user (Windows launcher)**: with `"remoteUser": "${localEnv:NAME:default}"` in devcontainer.json, `task dc:openbao:unlock` and `task dc:openbao:keyfile:create` said "this devcontainer has no openbao-setup that knows the OpenBao key file" (create exited 4) and the unlock after `up` did nothing, because the launcher ran `docker exec -u '${localEnv:NAME:default}'` with the text as written. The launcher now resolves `${localEnv:NAME}` and `${localEnv:NAME:default}` as the devcontainers CLI does at `up` (the launcher's environment, which under `task` includes the `.env` files, else the default); any other `${...}` falls back to the container's own user, never reaching `-u`. +- When asking the container for OpenBao's unlock status fails for another reason than a missing or older `openbao-setup`, the launcher now says so in one line, with the exit code and the error's first line (for example `docker exec as user 'X' failed (exit 1): unable to find user X: no matching entries in passwd file`), on `task dc:openbao:unlock`, on `keyfile:create`/`rotate` (exit 1) and after `up`, instead of the misleading "no openbao-setup" or silence. + +#### Changed + +- **Act:** none beyond the next bootstrap refresh (re-run the bootstrap to vendor the new launcher); no rebuild. + ### [1.22.0] - 2026-11-06 #### Added @@ -715,13 +750,6 @@ so the same script serves the container and the host-side tasks. - **Audio on request (both launchers)**: `DCF_HOST_AUDIO=wslg` in a Windows WSL2 workstation's `.devcontainer/.env` gives the container its microphone through WSLg's PulseAudio socket, for the new `audio` Feature (Claude Code's `/voice`). Before `up`, on the `windows-docker` and `windows-wsl-docker` host types, the launcher binds the default WSL distro's `/mnt/wslg` to `/mnt/wsl/wslg` as root (skipped when already there; a WSL restart drops it and the next `up` redoes it), mounts it at `/mnt/wslg`, and sets `DCF_HOST_AUDIO=wslg` for `containerEnv`; anywhere else, or when that distro has no WSLg socket, it warns once and starts the container without audio (`DCF_HOST_AUDIO=none`). `task devcontainer:host` says what it would do. Unset or `none` (the default) runs nothing and adds nothing: nothing changes unless you opt in. It gives every process in the container the microphone; whether dictated audio may go to a model provider is your repository's ruling. To use it: re-vendor the bootstrap, add `"DCF_HOST_AUDIO": "${localEnv:DCF_HOST_AUDIO}"` to `containerEnv` (the bootstrap's template has it), add the `audio` Feature once it is released, set the variable and run `task devcontainer:rebuild`. -### [1.20.0] - 2026-11-05 - -#### Added - -- **The OpenBao key file (Windows launcher)**: with the openbao Feature 1.5.0 or later set to `unlock: keyfile`, every `task dc:up` (so `start`, `shell`, `exec`, `agent`, `claude`, `rebuild`) unseals OpenBao and logs you in with nothing typed, from a DPAPI-protected key file in `%LOCALAPPDATA%\devcontainer-features-keys\openbao\` (it also opens with a domain's DPAPI backup key). It is streamed into the container on stdin through the engine's `exec`, never as an argument, an environment variable or a file. New tasks: `dc:openbao:unlock`, `dc:openbao:keyfile:create`, `:rotate`, `:export`, `:import`, `:forget`; `create` and `rotate` take two steps (OpenBao's rekey verification): the key file is saved first, then confirmed with its own shares 4 and 5, and only then are the three custody shares printed; a failed save or confirm cancels the rotation (with its own nonce; another rotation is never cancelled) and keeps the old shares and key file; a lost confirm answer is settled by `keyfile status`'s pending verification nonce; the nonce carries a confirm token, so it is never printed or logged; the container's warnings are shown. The four that handle shares or the file refuse inside an agent's job (`CLAUDECODE`, `DEVHOST_JOB_CHILD`, `OPENBAO_AGENT_JOB_VARS`). Nothing changes for a repository whose openbao Feature is not in keyfile mode (one silent status call after `up`). To use it: re-vendor the bootstrap, set `unlock: keyfile`, rebuild, then run `task dc:openbao:keyfile:create` once in a plain terminal and store the three custody shares it prints. -- The bash launcher (Linux, macOS, a WSL distro) refuses those tasks in one line: the OpenBao key file is Windows-only for now. - The full history is in [CHANGELOG.md](https://github.com/nabil-becker/devcontainer-features/blob/main/src/devcontainer-cli/CHANGELOG.md). --- diff --git a/src/devcontainer-cli/devcontainer-feature.json b/src/devcontainer-cli/devcontainer-feature.json index da4e926..8ba3a5b 100644 --- a/src/devcontainer-cli/devcontainer-feature.json +++ b/src/devcontainer-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devcontainer-cli", - "version": "1.22.0", + "version": "1.22.1", "name": "Dev Container CLI", "description": "Installs @devcontainers/cli as /usr/local/bin/devcontainer, backed by a private, sha256-verified Node.js under /opt/devcontainer-cli that is NOT added to PATH - so `devcontainer up/build/exec/features test` work inside a container without a system-wide Node.js. Pair with docker-in-docker (needed for features test and nested `up` on Windows/macOS hosts) or docker-outside-of-docker (Linux hosts, same paths).", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/devcontainer-cli", diff --git a/test/bootstrap/OpenBaoKeyFile.Tests.ps1 b/test/bootstrap/OpenBaoKeyFile.Tests.ps1 index 4676e4a..42c879b 100644 --- a/test/bootstrap/OpenBaoKeyFile.Tests.ps1 +++ b/test/bootstrap/OpenBaoKeyFile.Tests.ps1 @@ -17,7 +17,11 @@ # settled by keyfile status's pending verification nonce, the part of ours before its first colon # (ours: cancel; empty with five shares: applied, also for # rotate where five shares hold both ways; another: never cancelled), the warnings shown, error then code on failure; export/import round trip, a wrong passphrase, -# the checkout refused; forget; the step wired after `up` only; the bash launcher's one refusal line. +# the checkout refused; forget; the step wired after `up` only; the bash launcher's one refusal line; +# the remote user (a ${localEnv:NAME:default} in the label resolved here, a literal kept, an unknown +# ${...} giving way to containerUser or the container's own user, never "-u ${"); a failing status +# call (exec -u with an unknown user) named in one line with its exit and stderr's first line, while +# exit 4 and a missing openbao-setup keep the old "not there". # The stub engine is a shell script, so the Describes that use it run where bash runs (Linux, macOS, # CI); the DPAPI round trip runs only on Windows: # pwsh -NoProfile -Command "Invoke-Pester -Path test/bootstrap/OpenBaoKeyFile.Tests.ps1 -Output Detailed" @@ -44,15 +48,21 @@ BeforeAll { #!/usr/bin/env bash case "$1" in ps) [ "${FAKE_RUNNING:-1}" = 1 ] && echo cid1; exit 0 ;; - inspect) printf '%s\n' '[{"Config":{"Labels":{"devcontainer.metadata":"[{\"remoteUser\":\"vscode\"}]"}}}]'; exit 0 ;; + inspect) + if [ -n "${FAKE_INSPECT:-}" ]; then printf '%s\n' "$FAKE_INSPECT"; exit 0; fi + printf '%s\n' '[{"Config":{"Labels":{"devcontainer.metadata":"[{\"remoteUser\":\"vscode\"}]"}}}]'; exit 0 ;; exec) ;; *) exit 0 ;; esac printf '%s\n' "$*" >> "$FAKE_ARGV" env >> "$FAKE_ENVLOG" shift -while [ $# -gt 0 ]; do case "$1" in -i) shift ;; -u) shift 2 ;; *) break ;; esac; done +user= +while [ $# -gt 0 ]; do case "$1" in -i) shift ;; -u) user=$2; shift 2 ;; *) break ;; esac; done [ "$1" = cid1 ] || exit 125 +case "$user" in + *'${'* | "${FAKE_BAD_USER:-/none/}") echo "Error response from daemon: unable to find user $user: no matching entries in passwd file" >&2; exit 1 ;; +esac shift if [ "${FAKE_NO_SETUP:-}" = 1 ] || [ "$1" != /usr/local/bin/openbao-setup ]; then echo 'OCI runtime exec failed: exec: "/usr/local/bin/openbao-setup": no such file or directory' >&2; exit 127 @@ -82,7 +92,7 @@ echo "usage: openbao-setup ..." >&2; exit 2 $w.Engine = $engine $w.Saved = @{} foreach ($k in 'DEVCONTAINER_DOCKER_PATH', 'XDG_DATA_HOME', 'LOCALAPPDATA', 'TMPDIR', 'TEMP', 'TMP', 'FAKE_ARGV', 'FAKE_ENVLOG', 'FAKE_STDIN', - 'FAKE_STATUS', 'FAKE_STATUS_RC', 'FAKE_RUNNING', 'FAKE_NO_SETUP', 'FAKE_UNLOCK_OUT', 'FAKE_UNLOCK_RC', 'FAKE_MAKE_OUT', 'FAKE_MAKE_RC', 'FAKE_CONFIRM', 'FAKE_KF_STATUS', 'FAKE_KEYFILE', + 'FAKE_STATUS', 'FAKE_STATUS_RC', 'FAKE_RUNNING', 'FAKE_NO_SETUP', 'FAKE_UNLOCK_OUT', 'FAKE_UNLOCK_RC', 'FAKE_MAKE_OUT', 'FAKE_MAKE_RC', 'FAKE_CONFIRM', 'FAKE_KF_STATUS', 'FAKE_KEYFILE', 'FAKE_INSPECT', 'FAKE_BAD_USER', 'DCF_TEST_REMOTE_USER', 'CLAUDECODE', 'DEVHOST_JOB_CHILD', 'OPENBAO_AGENT_JOB_VARS', 'MY_AGENT_JOB') { $w.Saved[$k] = [Environment]::GetEnvironmentVariable($k) } @@ -90,20 +100,29 @@ echo "usage: openbao-setup ..." >&2; exit 2 $env:XDG_DATA_HOME = (Join-Path $t 'data'); $env:LOCALAPPDATA = (Join-Path $t 'localappdata') $env:TMPDIR = $w.Tmp; $env:TEMP = $w.Tmp; $env:TMP = $w.Tmp $env:FAKE_ARGV = $w.Argv; $env:FAKE_ENVLOG = $w.EnvLog; $env:FAKE_STDIN = $w.Stdin - foreach ($k in 'FAKE_STATUS_RC', 'FAKE_RUNNING', 'FAKE_NO_SETUP', 'FAKE_UNLOCK_OUT', 'FAKE_UNLOCK_RC', 'FAKE_MAKE_OUT', 'FAKE_MAKE_RC', 'FAKE_CONFIRM', 'FAKE_KF_STATUS', 'FAKE_KEYFILE', 'CLAUDECODE', 'DEVHOST_JOB_CHILD', 'OPENBAO_AGENT_JOB_VARS', 'MY_AGENT_JOB') { - [Environment]::SetEnvironmentVariable($k, $null) + foreach ($k in 'FAKE_STATUS_RC', 'FAKE_RUNNING', 'FAKE_NO_SETUP', 'FAKE_UNLOCK_OUT', 'FAKE_UNLOCK_RC', 'FAKE_MAKE_OUT', 'FAKE_MAKE_RC', 'FAKE_CONFIRM', 'FAKE_KF_STATUS', 'FAKE_KEYFILE', 'FAKE_INSPECT', 'FAKE_BAD_USER', 'DCF_TEST_REMOTE_USER', 'CLAUDECODE', 'DEVHOST_JOB_CHILD', 'OPENBAO_AGENT_JOB_VARS', 'MY_AGENT_JOB') { + # Removed, not set to $null: PowerShell 7.6 passes $null as "" and .NET 9 keeps an empty variable. + Remove-Item -LiteralPath "Env:$k" -ErrorAction SilentlyContinue } $env:FAKE_STATUS = (Get-Status) return $w } function Clear-World($w) { - foreach ($k in $w.Saved.Keys) { [Environment]::SetEnvironmentVariable($k, $w.Saved[$k]) } + foreach ($k in $w.Saved.Keys) { + if ($null -eq $w.Saved[$k]) { Remove-Item -LiteralPath "Env:$k" -ErrorAction SilentlyContinue } + else { [Environment]::SetEnvironmentVariable($k, $w.Saved[$k]) } + } Remove-Item -LiteralPath $w.T -Recurse -Force -ErrorAction SilentlyContinue } function Get-Status([string]$Mode = 'keyfile', [bool]$Sealed = $true, [string]$Token = 'missing', [bool]$Initialized = $true, [string]$Server = 'clu-1') { $ready = $Mode -eq 'keyfile' -and -not $Sealed -and $Token -eq 'live' ConvertTo-Json -Compress -InputObject @{ v = 1; mode = $Mode; initialized = $Initialized; sealed = $Sealed; token = $Token; renewer = $false; ready = $ready; server = $Server } } + # The engine's inspect answer: the devcontainer.metadata label's entries and the image's Config.User. + function Get-Inspect([object[]]$Meta, [string]$ConfigUser = '') { + $label = ConvertTo-Json -Compress -AsArray -InputObject @($Meta) + ConvertTo-Json -Compress -Depth 5 -AsArray -InputObject @([ordered]@{ Config = [ordered]@{ User = $ConfigUser; Labels = @{ 'devcontainer.metadata' = $label } } }) + } function Get-Log([string]$Path) { if (Test-Path -LiteralPath $Path) { [IO.File]::ReadAllText($Path) } else { '' } } # Runs $Body; returns Rc (its output), Host (Write-Host lines) and Err (the console's stderr). function Invoke-Captured([scriptblock]$Body) { @@ -259,6 +278,127 @@ Describe 'The unlock step after up' -Skip:(-not $script:HasBash -or $IsWindows) } } +Describe 'The remote user the unlock runs as' -Skip:(-not $script:HasBash -or $IsWindows) { + BeforeEach { $script:W = Initialize-World; Use-ProtectorStandIn } + AfterEach { Clear-World $script:W } + + It 'resolves ${localEnv:NAME:default} to the default when NAME is unset' { + $env:FAKE_INSPECT = Get-Inspect @(@{ remoteUser = '${localEnv:DCF_TEST_REMOTE_USER:host}' }) + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -Be 'host' + } + + It 'resolves ${localEnv:NAME:default} to NAME''s value when it is set' { + $env:FAKE_INSPECT = Get-Inspect @(@{ remoteUser = '${localEnv:DCF_TEST_REMOTE_USER:host}' }) + $env:DCF_TEST_REMOTE_USER = 'alice' + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -Be 'alice' + } + + It 'keeps a literal remoteUser as it is, the last entry winning over containerUser' { + $env:FAKE_INSPECT = Get-Inspect @(@{ containerUser = 'root' }, @{ remoteUser = 'node' }, @{ remoteUser = 'vscode' }) -ConfigUser 'root' + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -Be 'vscode' + } + + It 'falls back to containerUser when ${localEnv:NAME} is unset with no default' { + $env:FAKE_INSPECT = Get-Inspect @(@{ containerUser = 'dev'; remoteUser = '${localEnv:DCF_TEST_REMOTE_USER}' }) + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -Be 'dev' + } + + It 'falls back to the container''s own user for a ${...} only the devcontainers CLI knows' { + $env:FAKE_INSPECT = Get-Inspect @(@{ remoteUser = '${containerEnv:USER}' }) -ConfigUser 'node' + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -Be 'node' + } + + It 'leaves -u out, never sending ${, when nothing resolves' { + $env:FAKE_INSPECT = Get-Inspect @(@{ remoteUser = '${containerEnv:USER}' }) + Get-DevcontainerRemoteUser -Engine $script:W.Engine -ContainerId cid1 | Should -BeExactly '' + $env:FAKE_STATUS = Get-Status -Sealed $false -Token 'live' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws } + $r.Rc | Should -Be 0 + "$($r.Host)$($r.Err)" | Should -BeNullOrEmpty + (Get-Log $script:W.Argv).Trim() | Should -Be 'exec -i cid1 /usr/local/bin/openbao-setup unlock --status' + } + + It 'unlocks as the resolved user when remoteUser is ${localEnv:NAME:default}' { + $env:FAKE_INSPECT = Get-Inspect @(@{ remoteUser = '${localEnv:DCF_TEST_REMOTE_USER:host}' }) + Save-TestKeyFile + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws } + $r.Rc | Should -Be 0 + $r.Host | Should -Be 'openbao: unsealed, logged in (the OpenBao key file).' + $r.Err | Should -BeNullOrEmpty + Get-Log $script:W.Argv | Should -Match 'exec -i -u host cid1 /usr/local/bin/openbao-setup unlock --status' + Get-Log $script:W.Argv | Should -Match 'exec -i -u host cid1 /usr/local/bin/openbao-setup unlock --stdin' + Get-Log $script:W.Argv | Should -Not -Match '\$\{' + } +} + +Describe 'When asking for the unlock status fails' -Skip:(-not $script:HasBash -or $IsWindows) { + BeforeEach { $script:W = Initialize-World; Use-ProtectorStandIn; Save-TestKeyFile } + AfterEach { Clear-World $script:W } + + It 'after up, says why in one line (the exit code and stderr''s first line) and sends nothing' { + $env:FAKE_BAD_USER = 'vscode' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws } + $r.Rc | Should -Be 1 + $r.Host | Should -BeNullOrEmpty + $r.Err.Trim() | Should -Be "openbao: could not ask the container for OpenBao's unlock status: docker exec as user 'vscode' failed (exit 1): unable to find user vscode: no matching entries in passwd file." + Get-Log $script:W.Argv | Should -Not -Match 'unlock --stdin' + } + + It 'with -Explicit, says the same instead of "no openbao-setup"' { + $env:FAKE_BAD_USER = 'vscode' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws -Explicit } + $r.Rc | Should -Be 1 + $r.Err | Should -Match "docker exec as user 'vscode' failed \(exit 1\): unable to find user vscode" + $r.Err | Should -Not -Match 'no openbao-setup that knows' + } + + It 'names another failing exit and never prints stdout' { + $env:FAKE_STATUS = "$($script:Marker) on stdout" + $env:FAKE_STATUS_RC = '1' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws -Explicit } + $r.Rc | Should -Be 1 + $r.Err.Trim() | Should -Be "openbao: could not ask the container for OpenBao's unlock status: docker exec as user 'vscode' failed (exit 1)." + "$($r.Host)$($r.Err)" | Should -Not -Match $script:Marker + } + + It 'keyfile:create says why and exits 1, not 4' { + $env:FAKE_BAD_USER = 'vscode' + $r = Invoke-Captured { Invoke-OpenBaoKeyFileTask -WorkspaceFolder $script:W.Ws -Arguments @('create') } + $r.Rc | Should -Be 1 + $r.Err.Trim() | Should -Be "openbao: could not ask the container for OpenBao's unlock status: docker exec as user 'vscode' failed (exit 1): unable to find user vscode: no matching entries in passwd file." + Get-Log $script:W.Argv | Should -Not -Match 'keyfile create' + } + + It 'exit 4 (not applicable) keeps the old "not there": silent after up, the old line with -Explicit and on keyfile:create (exit 4)' { + $env:FAKE_STATUS = '{"v":1,"ok":false,"code":"not-applicable"}' + $env:FAKE_STATUS_RC = '4' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws } + $r.Rc | Should -Be 0 + "$($r.Host)$($r.Err)" | Should -BeNullOrEmpty + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws -Explicit } + $r.Rc | Should -Be 1 + $r.Err.Trim() | Should -Match '^openbao: this devcontainer has no openbao-setup that knows the OpenBao key file' + $r = Invoke-Captured { Invoke-OpenBaoKeyFileTask -WorkspaceFolder $script:W.Ws -Arguments @('create') } + $r.Rc | Should -Be 4 + $r.Err.Trim() | Should -Match '^openbao: this devcontainer has no openbao-setup that knows the OpenBao key file' + } + + It 'a missing openbao-setup keeps the old "not there", with -Explicit too' { + $env:FAKE_NO_SETUP = '1' + $r = Invoke-Captured { Invoke-OpenBaoUnlock -WorkspaceFolder $script:W.Ws -Explicit } + $r.Rc | Should -Be 1 + $r.Err.Trim() | Should -Match '^openbao: this devcontainer has no openbao-setup that knows the OpenBao key file' + } + + It 'an engine that exits 127 or 126 saying nothing counts as no openbao-setup' -ForEach @(@{ rc = 126 }, @{ rc = 127 }) { + $why = InModuleScope DevcontainerCli -Parameters @{ Rc = $rc } { + param($Rc) + Get-OpenBaoExecFailure -Result ([pscustomobject]@{ Code = $Rc; Out = ''; Err = '' }) -Container ([pscustomobject]@{ Engine = 'podman'; User = 'host' }) + } + $why | Should -BeExactly '' + } +} + Describe 'Invoke-DevcontainerCli runs the unlock step after up only' { BeforeEach { InModuleScope DevcontainerCli { $script:CliExitCode = 0 }