@kothar-hq/host-container-capabilities 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ An [agent skill](https://agentskills.io) that finds out which container engines
5
5
  Coding agents run on Windows hosts (some with WSL), on Linux and macOS, inside WSL, and in cloud sandboxes. An agent that assumes `docker` reaches a Linux engine gets it wrong on a Windows host in Windows-container mode, and one that "fixes" that by switching Docker Desktop stops every container on the host. This skill gives the agent a probed report instead:
6
6
 
7
7
  - **Windows engine:** whether one answers, and the isolation (`process` or `hyperv`) that ltsc2019, ltsc2022 and ltsc2025 images need on this host build.
8
- - **Linux engine:** the default WSL distro's own Docker, then other WSL 2 distros, then the default docker context, then an existing Docker Desktop Linux pipe. On Linux and macOS, the docker CLI's current context.
8
+ - **Linux engine:** the default WSL distro's own Docker, then other WSL 2 distros, then the default docker context, then an existing Docker Desktop Linux pipe, then Podman. On Linux and macOS, the docker CLI's current context, then Podman. The report says whether the engine is rootless and whether SELinux is enforced.
9
9
  - **Routing:** run locally when the engine exists, otherwise offer the project's CI workflow.
10
10
  - **Shared-host safety:** everything the agent creates is named and labelled, and only that is cleaned up. Docker Desktop is never switched, and no daemon is started or installed without asking.
11
11
 
@@ -54,7 +54,7 @@ sh .claude/skills/host-container-capabilities/scripts/host-capabilities.sh
54
54
 
55
55
  - Windows: PowerShell 7. Linux, macOS and WSL: a POSIX `sh`.
56
56
  - The installer needs Node.js 20 or later. The skill itself doesn't need Node.
57
- - Docker is the only container runtime probed today. Podman and others are tracked in the [issues](https://github.com/kothar-hq/host-container-capabilities/issues).
57
+ - Docker and Podman are probed today. Other runtimes are tracked in the [issues](https://github.com/kothar-hq/host-container-capabilities/issues).
58
58
 
59
59
  ## Versions
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kothar-hq/host-container-capabilities",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "An agent skill that finds out which container engines a host has (Windows, Linux, WSL) and routes container work to a local engine or to CI.",
5
5
  "keywords": [
6
6
  "agent-skill",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: host-container-capabilities
3
- description: Find out which container engines this host has (a Windows engine for Windows containers, a Linux engine such as a WSL distro's own Docker, both, or neither) and route container work to a local engine or to CI. Use before any docker command, before building or testing an image, before running Linux tools such as shellcheck or actionlint in a container, or when asked whether this machine can run Linux or Windows containers.
3
+ description: Find out which container engines this host has (a Windows engine for Windows containers, a Linux engine such as Docker, Podman or a WSL distro's own Docker, both, or neither) and route container work to a local engine or to CI. Use before any docker or podman command, before building or testing an image, before running Linux tools such as shellcheck or actionlint in a container, or when asked whether this machine can run Linux or Windows containers.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.9.1"
6
+ version: "0.10.0"
7
7
  repository: https://github.com/kothar-hq/host-container-capabilities
8
8
  ---
9
9
 
@@ -31,10 +31,12 @@ The fields that decide things:
31
31
  | `platform` | `windows`, `wsl` (the agent is running inside WSL), `linux` or `macos` |
32
32
  | `windowsEngine.available`, `.invoke` | Whether a Windows engine answers, and the command that reaches it (`docker`, or `docker.exe` from inside WSL) |
33
33
  | `windowsEngine.ltsc.<2019\|2022\|2025>.isolation` | `process`, `hyperv`, or `null` when that Windows version's images can't run here. Only filled in on Windows. |
34
- | `linuxEngine.available`, `.invoke` | Whether a Linux engine answers, and the command that reaches it, for example `["wsl.exe","--distribution","Ubuntu","--exec","docker"]` |
34
+ | `linuxEngine.available`, `.invoke` | Whether a Linux engine answers, and the command that reaches it, for example `["wsl.exe","--distribution","Ubuntu","--exec","docker"]` or `["podman"]` |
35
+ | `linuxEngine.kind` | `docker`, `wsl` (Docker inside a WSL distro), `docker-desktop-linux` or `podman`. It decides the flags in section 3. |
36
+ | `linuxEngine.rootless`, `.selinux` | Whether the engine runs rootless, and whether SELinux is enforced on its host. They decide the user and mount flags in section 3. |
35
37
  | `linuxEngine.repoPath` | The project's path as the Linux engine's host sees it, for bind mounts |
36
38
  | `linuxEngine.containers` minus `.claudeContainers` | Containers that belong to someone else (the user's devcontainers, CI runners). Leave them alone. |
37
- | `linuxEngine.reason`, `.tried` | Why there's no Linux engine, and what was tried |
39
+ | `linuxEngine.reason`, `.tried` | Why there's no Linux engine (for example `socket permission denied`), and what was tried |
38
40
  | `policy.sharedHost` | `true` unless the user overrode it. Shared means strict cleanup (section 4). |
39
41
  | `policy.createdByLabel`, `.sessionLabelKey` | The labels to put on everything you create (section 4) |
40
42
  | `github.authenticated` | Whether `gh` can dispatch workflows |
@@ -65,6 +67,17 @@ $linux = $report.linuxEngine
65
67
  --volume "$($linux.repoPath):/src:ro" --workdir /src rhysd/actionlint:1.7.12 -color
66
68
  ```
67
69
 
70
+ - **Podman takes the same arguments** as docker for everything this skill does (`run`, `build`, `ps`, `images`, `rm`, `rmi`, `--label`, `--filter label=`). Give images their full name (`docker.io/rhysd/actionlint:1.7.12`): Podman may not search Docker Hub for a short name.
71
+ - **Files written to a bind mount should belong to the user.** Which flag does that depends on the engine:
72
+
73
+ | `kind` | `rootless` | Flag |
74
+ | --- | --- | --- |
75
+ | `podman` | `true` | `--userns=keep-id` |
76
+ | `podman` | `false` | `--user "$(id -u):$(id -g)"` |
77
+ | any Docker kind | `false` | `--user "$(id -u):$(id -g)"` |
78
+ | any Docker kind | `true` | none: root in the container is already the user on the host. `--user` would map to a different host user. |
79
+
80
+ - **SELinux:** when `linuxEngine.selinux` is `true`, add `:Z` to a bind mount (`--volume "$PWD:/src:Z"`, or `:ro,Z`), or the container can't read it. `:Z` relabels the directory, so only use it on the project, never on a home directory or a system path.
68
81
  - **Prefer PowerShell on Windows.** Git Bash rewrites arguments that look like paths (`/src` becomes `C:/Program Files/Git/src`), so in Bash prefix the command with `MSYS_NO_PATHCONV=1`.
69
82
  - **`--exec` runs docker without a shell** inside the distro. Don't pass `bash -lc '...'` through `wsl.exe`: the quoting breaks and `$variables` are expanded in the wrong place.
70
83
  - **`/mnt/c` is slow.** That's fine for linters and for reading the project. For a real build, copy the sources into the container or a Linux directory instead of building on the bind mount.
@@ -80,7 +93,7 @@ On a shared host (`policy.sharedHost`, the default), everything you create carri
80
93
 
81
94
  Prefer images that are already present (`docker images`). If you pull one, note that it wasn't there before, and remove exactly that reference when you're done.
82
95
 
83
- Clean up only what carries your session's label, on the engine you created it on:
96
+ Clean up only what carries your session's label, on the engine you created it on. Put the engine's `invoke` in place of `docker`. The same commands work with Podman:
84
97
 
85
98
  ```sh
86
99
  docker ps -aq --filter label=<policy.sessionLabelKey>=<tag> | xargs -r docker rm -f
@@ -91,9 +104,12 @@ Never `docker system prune`, `docker image prune` or `docker builder prune` on a
91
104
 
92
105
  ## 5. When an engine is missing
93
106
 
107
+ - **`linuxEngine.reason` is `socket permission denied`:** Docker is installed and running, but this user can't use its socket. Offer, after asking, to add the user to the `docker` group (`sudo usermod -aG docker "$USER"`, then a new login; this makes the user root-equivalent on the host, so say so), or to set up rootless Docker or Podman instead. Don't work around it with `sudo docker`.
108
+ - **`linuxEngine.reason` is `no docker or podman CLI is installed`** (a cloud sandbox, a fresh machine): offer the CI workflow, or report what's missing.
94
109
  - **No Linux engine, but WSL is installed** (`linuxEngine.tried` names the distros): offer to install Docker Engine in the distro, or to start its daemon if `tried` says it isn't reachable. Both are host changes, so ask first. Otherwise, offer the CI workflow.
95
110
  - **No Linux engine and no WSL** (Windows without WSL, or a Linux or macOS host without Docker): offer the CI workflow, or report what's missing.
96
- - **No Windows engine** (Linux, macOS, or a cloud sandbox): Windows container images can only be built in CI, on a Windows runner. Don't try workarounds.
111
+ - **No Windows engine** (Linux, macOS, or a cloud sandbox): Windows container images can only be built in CI, on a Windows runner. Don't try workarounds. Podman can't run Windows containers, so it never fills in `windowsEngine`.
112
+ - **A remote Windows engine:** when the docker CLI's current context (or `DOCKER_HOST`) reaches a Windows engine from Linux or macOS, `windowsEngine.invoke` is `["docker"]`. Bind mounts of this host don't work there, and `ltsc` is `null`.
97
113
 
98
114
  ## 6. Never
99
115
 
@@ -117,7 +133,7 @@ Per host, in `.claude/host-container-capabilities.overrides.local.json` in the p
117
133
  }
118
134
  ```
119
135
 
120
- - `linuxEngine`: `auto` (the default), `none`, `wsl:<distro>` (Windows only), `docker` (the default docker context, if it's a Linux engine), or `docker-desktop-linux` (Windows only: Docker Desktop's Linux engine pipe, if it already exists; it's never switched to)
136
+ - `linuxEngine`: `auto` (the default: Docker first, then Podman), `none`, `wsl:<distro>` (Windows only: Docker, then Podman, in that distro), `docker` (the default docker context, if it's a Linux engine), `podman` (the podman CLI; where `docker` is the podman-docker shim, the report says `podman`), or `docker-desktop-linux` (Windows only: Docker Desktop's Linux engine pipe, if it already exists; it's never switched to)
121
137
  - `sharedHost`: set `false` only on a host that nothing else uses
122
138
  - `maxAgeHours`: how long a report is reused
123
139
  - `createdByLabel`, `sessionLabelKey`: the labels from section 4, for a project that already has a labelling convention
@@ -11,8 +11,10 @@ On Windows it checks:
11
11
  - the Windows engine behind the default docker context: its version, default isolation, and the isolation each of
12
12
  ltsc2019, ltsc2022 and ltsc2025 needs on this host build
13
13
  - a Linux engine, in this order: the default WSL distro's own Docker, any other WSL 2 distro's Docker (the
14
- docker-desktop distros are skipped), the default docker context if it is a Linux engine, and Docker Desktop's
15
- Linux engine pipe if it already exists. It stops at the first that answers. Probing a stopped WSL distro boots it.
14
+ docker-desktop distros are skipped), the default docker context if it is a Linux engine, Docker Desktop's
15
+ Linux engine pipe if it already exists, then Podman in each WSL 2 distro (which includes Podman Desktop's
16
+ podman-machine distros) and the native podman CLI. It stops at the first that answers. Probing a stopped WSL
17
+ distro boots it.
16
18
  - gh, and whether it is logged in (the exit code only; nothing it prints is kept)
17
19
 
18
20
  It never switches Docker Desktop between Windows and Linux containers, never starts a daemon, and installs nothing.
@@ -23,8 +25,8 @@ current directory, otherwise the current directory.
23
25
  The report is cached in <root>/.claude/host-container-capabilities.windows.local.json, which should be gitignored.
24
26
  A cached report is reused until it is older than maxAgeHours, the overrides file is newer than it, or -Refresh is
25
27
  given. Overrides are read from <root>/.claude/host-container-capabilities.overrides.local.json, a flat JSON object:
26
- linuxEngine 'auto' (default), 'none', 'wsl:<distro>', 'docker' (the default docker context) or
27
- 'docker-desktop-linux'
28
+ linuxEngine 'auto' (default), 'none', 'wsl:<distro>' (Docker, then Podman, in that distro), 'docker'
29
+ (the default docker context), 'docker-desktop-linux' or 'podman' (the native podman CLI)
28
30
  sharedHost true (default). Set false only on a host that nothing else uses.
29
31
  maxAgeHours 24 (default)
30
32
  createdByLabel the key=value label the agent puts on everything it creates
@@ -97,8 +99,8 @@ if (Test-Path $overridesPath) {
97
99
  foreach ($key in @($userOverrides.Keys)) {
98
100
  if ($overrides.Contains($key)) { $overrides[$key] = $userOverrides[$key] } else { Write-Warning "Ignoring unknown override '$key'" }
99
101
  }
100
- if ($overrides.linuxEngine -notmatch '^(auto|none|docker|docker-desktop-linux|wsl:.+)$') {
101
- throw "Override linuxEngine '$($overrides.linuxEngine)' must be auto, none, docker, docker-desktop-linux or wsl:<distro>"
102
+ if ($overrides.linuxEngine -notmatch '^(auto|none|docker|docker-desktop-linux|podman|wsl:.+)$') {
103
+ throw "Override linuxEngine '$($overrides.linuxEngine)' must be auto, none, docker, docker-desktop-linux, podman or wsl:<distro>"
102
104
  }
103
105
  if ($overrides.createdByLabel -notmatch '^[^=\s]+=\S+$') {
104
106
  throw "Override createdByLabel '$($overrides.createdByLabel)' must be key=value, without spaces"
@@ -136,6 +138,11 @@ function Invoke-Native {
136
138
  # and sets $script:probeError when the engine doesn't answer.
137
139
  function Get-EngineFact {
138
140
  param([Parameter(Mandatory)][string[]]$Docker)
141
+ $cli = Invoke-Native ($Docker + @('--version'))
142
+ if ($cli.ExitCode -eq 0 -and $cli.Output -match 'podman') {
143
+ $script:probeError = 'docker is the podman-docker shim'
144
+ return $null
145
+ }
139
146
  $version = Invoke-Native ($Docker + @('version', '--format', '{{.Server.Os}}|{{.Server.Version}}'))
140
147
  if ($version.ExitCode -ne 0 -or $version.Output -notmatch '^(\w+)\|(\S+)$') {
141
148
  $script:probeError = ($version.Output -split "`n" | Where-Object { $_ } | Select-Object -First 1) ?? 'no output'
@@ -144,6 +151,7 @@ function Get-EngineFact {
144
151
  $os, $serverVersion = $Matches[1], $Matches[2]
145
152
  $info = Invoke-Native ($Docker + @('info', '--format', '{{.OperatingSystem}}|{{.Isolation}}'))
146
153
  $operatingSystem, $isolation = if ($info.ExitCode -eq 0) { $info.Output -split '\|', 2 } else { $null, $null }
154
+ $security = Invoke-Native ($Docker + @('info', '--format', '{{.SecurityOptions}}'))
147
155
  $endpoint = Invoke-Native ($Docker + @('context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'))
148
156
  $names = Invoke-Native ($Docker + @('ps', '-a', '--format', '{{.Names}}'))
149
157
  $mine = Invoke-Native ($Docker + @('ps', '-aq', '--filter', "label=$($overrides.createdByLabel)"))
@@ -152,12 +160,42 @@ function Get-EngineFact {
152
160
  ServerVersion = $serverVersion
153
161
  OperatingSystem = $operatingSystem
154
162
  Isolation = if ($isolation) { $isolation } else { $null }
163
+ Rootless = if ($security.ExitCode -eq 0) { $security.Output -match 'rootless' } else { $null }
164
+ Selinux = if ($security.ExitCode -eq 0) { $security.Output -match 'selinux' } else { $null }
155
165
  Endpoint = if ($endpoint.ExitCode -eq 0) { $endpoint.Output } else { $null }
156
166
  Names = @($names.Output -split "`n" | Where-Object { $_ })
157
167
  ClaudeCount = @($mine.Output -split "`n" | Where-Object { $_ }).Count
158
168
  }
159
169
  }
160
170
 
171
+ # Asks a Podman CLI (an array such as 'wsl.exe','-d','Fedora','--exec','podman') about its engine, with Podman's
172
+ # own field names. Returns $null and sets $script:probeError when the engine doesn't answer.
173
+ function Get-PodmanFact {
174
+ param([Parameter(Mandatory)][string[]]$Podman)
175
+ $format = '{{.Host.OS}}|{{.Version.Version}}|{{.Host.Distribution.Distribution}} {{.Host.Distribution.Version}}|' +
176
+ '{{.Host.Security.Rootless}}|{{.Host.Security.SELinuxEnabled}}|{{.Host.RemoteSocket.Path}}'
177
+ $info = Invoke-Native ($Podman + @('info', '--format', $format))
178
+ $line = $info.Output -split "`n" | Where-Object { $_ -match '^\w+\|' } | Select-Object -First 1
179
+ if ($info.ExitCode -ne 0 -or -not $line) {
180
+ $script:probeError = ($info.Output -split "`n" | Where-Object { $_ } | Select-Object -First 1) ?? 'no output'
181
+ return $null
182
+ }
183
+ $os, $serverVersion, $operatingSystem, $rootless, $selinux, $endpoint = $line.Trim() -split '\|', 6
184
+ $names = Invoke-Native ($Podman + @('ps', '-a', '--format', '{{.Names}}'))
185
+ $mine = Invoke-Native ($Podman + @('ps', '-aq', '--filter', "label=$($overrides.createdByLabel)"))
186
+ [pscustomobject]@{
187
+ Os = $os
188
+ ServerVersion = $serverVersion
189
+ OperatingSystem = $operatingSystem
190
+ Isolation = $null
191
+ Rootless = if ($rootless -in 'true', 'false') { $rootless -eq 'true' } else { $null }
192
+ Selinux = if ($selinux -in 'true', 'false') { $selinux -eq 'true' } else { $null }
193
+ Endpoint = if ($endpoint) { $endpoint } else { $null }
194
+ Names = @($names.Output -split "`n" | Where-Object { $_ })
195
+ ClaudeCount = @($mine.Output -split "`n" | Where-Object { $_ }).Count
196
+ }
197
+ }
198
+
161
199
  # Host
162
200
  $os = Get-CimInstance Win32_OperatingSystem
163
201
  $hostBuild = [Environment]::OSVersion.Version.Build
@@ -221,34 +259,49 @@ try {
221
259
  $candidates = @()
222
260
  $wslCandidates = @($distros | Where-Object { $_.version -eq 2 -and $_.name -notlike 'docker-desktop*' } |
223
261
  Sort-Object { -not $_.default })
262
+ # Kind 'wsl' is Docker inside a WSL distro. Kind 'podman' is Podman, inside a WSL distro (Distro is set, which
263
+ # covers Podman Desktop's podman-machine distros) or through the native podman CLI (Distro is $null).
264
+ $nativePodman = [pscustomobject]@{ Kind = 'podman'; Distro = $null }
224
265
  switch -Regex ($overrides.linuxEngine) {
225
266
  '^auto$' {
226
267
  $candidates += $wslCandidates | ForEach-Object { [pscustomobject]@{ Kind = 'wsl'; Distro = $_.name } }
227
268
  $candidates += [pscustomobject]@{ Kind = 'docker'; Distro = $null }
228
269
  if ($dockerDesktopLinuxPipe) { $candidates += [pscustomobject]@{ Kind = 'docker-desktop-linux'; Distro = $null } }
270
+ $candidates += $wslCandidates | ForEach-Object { [pscustomobject]@{ Kind = 'podman'; Distro = $_.name } }
271
+ if (Get-Command podman -ErrorAction SilentlyContinue) { $candidates += $nativePodman }
272
+ }
273
+ '^wsl:(.+)$' {
274
+ $candidates += [pscustomobject]@{ Kind = 'wsl'; Distro = $Matches[1] }
275
+ $candidates += [pscustomobject]@{ Kind = 'podman'; Distro = $Matches[1] }
229
276
  }
230
- '^wsl:(.+)$' { $candidates += [pscustomobject]@{ Kind = 'wsl'; Distro = $Matches[1] } }
277
+ '^podman$' { $candidates += $nativePodman }
231
278
  '^(docker|docker-desktop-linux)$' { $candidates += [pscustomobject]@{ Kind = $Matches[1]; Distro = $null } }
232
279
  }
233
280
 
234
281
  $linuxEngine = [ordered]@{
235
282
  available = $false; kind = $null; distro = $null; invoke = $null; endpoint = $null; serverVersion = $null
236
- operatingSystem = $null; containers = $null; claudeContainers = $null; repoPath = $null; reason = $null; tried = @()
283
+ operatingSystem = $null; rootless = $null; selinux = $null; containers = $null; claudeContainers = $null
284
+ repoPath = $null; reason = $null; tried = @()
237
285
  }
238
286
  foreach ($candidate in $candidates) {
239
287
  $docker = switch ($candidate.Kind) {
240
288
  'wsl' { @('wsl.exe', '--distribution', $candidate.Distro, '--exec', 'docker') }
241
289
  'docker' { @('docker') }
242
290
  'docker-desktop-linux' { @('docker', '--host', 'npipe:////./pipe/dockerDesktopLinuxEngine') }
291
+ 'podman' {
292
+ if ($candidate.Distro) { @('wsl.exe', '--distribution', $candidate.Distro, '--exec', 'podman') } else { @('podman') }
293
+ }
243
294
  }
244
- $fact = if ($candidate.Kind -eq 'docker') { $defaultEngine } else { Get-EngineFact $docker }
295
+ $fact = if ($candidate.Kind -eq 'docker') { $defaultEngine }
296
+ elseif ($candidate.Kind -eq 'podman') { Get-PodmanFact $docker }
297
+ else { Get-EngineFact $docker }
245
298
  $failure = if (-not $fact) { if ($candidate.Kind -eq 'docker') { $defaultError } else { $script:probeError } }
246
299
  elseif ($fact.Os -ne 'linux') { "it is a $($fact.Os) engine" }
247
300
  if ($failure) {
248
301
  $linuxEngine.tried += [ordered]@{ kind = $candidate.Kind; distro = $candidate.Distro; error = $failure }
249
302
  continue
250
303
  }
251
- $repoPath = if ($candidate.Kind -eq 'wsl') {
304
+ $repoPath = if ($candidate.Distro) {
252
305
  (Invoke-Native @('wsl.exe', '--distribution', $candidate.Distro, '--exec', 'wslpath', '-a', $projectRoot)).Output
253
306
  } else { $null }
254
307
  $linuxEngine.available = $true
@@ -258,6 +311,8 @@ try {
258
311
  $linuxEngine.endpoint = $fact.Endpoint
259
312
  $linuxEngine.serverVersion = $fact.ServerVersion
260
313
  $linuxEngine.operatingSystem = $fact.OperatingSystem
314
+ $linuxEngine.rootless = $fact.Rootless
315
+ $linuxEngine.selinux = $fact.Selinux
261
316
  $linuxEngine.containers = $fact.Names.Count
262
317
  $linuxEngine.claudeContainers = $fact.ClaudeCount
263
318
  $linuxEngine.repoPath = $repoPath
@@ -265,6 +320,7 @@ try {
265
320
  }
266
321
  if (-not $linuxEngine.available) {
267
322
  $linuxEngine.reason = if ($overrides.linuxEngine -eq 'none') { 'disabled by the linuxEngine override' }
323
+ elseif ($linuxEngine.tried | Where-Object { $_.error -match 'permission denied' }) { 'socket permission denied' }
268
324
  elseif (-not $wsl.installed) { 'WSL is not installed and no other Linux engine answered' }
269
325
  else { 'no candidate answered; see tried' }
270
326
  }
@@ -9,7 +9,7 @@
9
9
  # The project root is $HOST_CONTAINER_CAPABILITIES_ROOT, else the git work tree around the current directory,
10
10
  # else the current directory. The report is cached in <root>/.claude/host-container-capabilities.<platform>.local.json.
11
11
  # Overrides are read from <root>/.claude/host-container-capabilities.overrides.local.json, a flat JSON object with
12
- # linuxEngine (auto, none or docker), sharedHost (true), maxAgeHours (24), createdByLabel, sessionLabelKey and
12
+ # linuxEngine (auto, none, docker or podman), sharedHost (true), maxAgeHours (24), createdByLabel, sessionLabelKey and
13
13
  # runnerContainerPattern. It never starts a daemon and installs nothing.
14
14
  set -eu
15
15
 
@@ -66,8 +66,8 @@ ov_session=$(json_value "$overrides" sessionLabelKey)
66
66
  ov_runners=$(json_value "$overrides" runnerContainerPattern)
67
67
  : "${ov_engine:=auto}" "${ov_shared:=true}" "${ov_age:=24}"
68
68
  : "${ov_label:=io.github.kothar-hq.created-by=agent}" "${ov_session:=io.github.kothar-hq.session}"
69
- case "$ov_engine" in auto | none | docker) ;; *)
70
- echo "host-capabilities.sh: override linuxEngine '$ov_engine' must be auto, none or docker here" >&2; exit 2 ;;
69
+ case "$ov_engine" in auto | none | docker | podman) ;; *)
70
+ echo "host-capabilities.sh: override linuxEngine '$ov_engine' must be auto, none, docker or podman here" >&2; exit 2 ;;
71
71
  esac
72
72
  case "$ov_shared" in true | false) ;; *) echo "host-capabilities.sh: override sharedHost must be true or false" >&2; exit 2 ;; esac
73
73
  case "$ov_age" in '' | *[!0-9]*) echo "host-capabilities.sh: override maxAgeHours must be a whole number" >&2; exit 2 ;; esac
@@ -84,46 +84,119 @@ if [ "$refresh" = 0 ] && [ -f "$cache" ] && [ -z "$(find "$overrides" -newer "$c
84
84
  fi
85
85
  fi
86
86
 
87
- # probe <docker command...>: asks a docker CLI about its engine. Sets p_os, p_version, p_osname, p_isolation,
88
- # p_endpoint, p_total and p_mine, or p_error when the engine doesn't answer.
89
- probe() {
90
- p_os='' p_version='' p_osname='' p_isolation='' p_endpoint='' p_total='' p_mine='' p_error=''
91
- if ! out=$("$@" version --format '{{.Server.Os}}|{{.Server.Version}}' 2>&1) || [ "${out#*|}" = "$out" ]; then
92
- p_error=$(printf '%s\n' "$out" | sed -n '/./{p;q;}')
93
- : "${p_error:=no output}"
87
+ # The probes set p_os, p_version, p_osname, p_isolation, p_endpoint, p_rootless, p_selinux, p_total and p_mine,
88
+ # or p_error when the engine doesn't answer.
89
+ reset_probe() {
90
+ p_os='' p_version='' p_osname='' p_isolation='' p_endpoint='' p_rootless='' p_selinux='' p_total='' p_mine='' p_error=''
91
+ }
92
+ # first_error <command...>: the first non-empty line the command prints on stderr
93
+ first_error() {
94
+ p_error=$("$@" 2>&1 >/dev/null | sed -n '/./{p;q;}' || true)
95
+ : "${p_error:=no output}"
96
+ }
97
+ count_containers() {
98
+ p_total=$("$@" ps -aq 2>/dev/null | wc -l | tr -d ' ')
99
+ p_mine=$("$@" ps -aq --filter "label=$ov_label" 2>/dev/null | wc -l | tr -d ' ')
100
+ }
101
+ # is_podman <cli>: true when the CLI is Podman, which includes the podman-docker shim named docker
102
+ is_podman() { "$1" --version 2>/dev/null | grep -qi podman; }
103
+
104
+ # probe_docker <docker command...>: asks a docker CLI about its engine
105
+ probe_docker() {
106
+ reset_probe
107
+ if ! out=$("$@" version --format '{{.Server.Os}}|{{.Server.Version}}' 2>/dev/null) || [ "${out#*|}" = "$out" ]; then
108
+ first_error "$@" version --format '{{.Server.Os}}|{{.Server.Version}}'
94
109
  return 1
95
110
  fi
96
111
  p_os=${out%%|*} p_version=${out#*|}
97
112
  info=$("$@" info --format '{{.OperatingSystem}}|{{.Isolation}}' 2>/dev/null || true)
98
113
  p_osname=${info%%|*} p_isolation=${info#*|}
114
+ security=$("$@" info --format '{{.SecurityOptions}}' 2>/dev/null || true)
115
+ case "$security" in *rootless*) p_rootless=true ;; *) p_rootless=false ;; esac
116
+ case "$security" in *selinux*) p_selinux=true ;; *) p_selinux=false ;; esac
99
117
  p_endpoint=$("$@" context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
100
- p_total=$("$@" ps -aq 2>/dev/null | wc -l | tr -d ' ')
101
- p_mine=$("$@" ps -aq --filter "label=$ov_label" 2>/dev/null | wc -l | tr -d ' ')
118
+ count_containers "$@"
102
119
  }
103
120
 
104
- # Linux engine: the docker CLI's current context (DOCKER_HOST, Docker Desktop, colima, a distro's own Docker)
105
- l_available=false l_kind='' l_invoke=null l_version='' l_osname='' l_endpoint='' l_total='' l_mine='' l_reason='' l_tried=''
106
- if [ "$ov_engine" = none ]; then
107
- l_reason='disabled by the linuxEngine override'
108
- elif ! command -v docker >/dev/null 2>&1; then
109
- l_reason='docker CLI not installed'
110
- l_tried="{\"kind\": \"docker\", \"distro\": null, \"error\": $(js "$l_reason")}"
111
- elif probe docker && [ "$p_os" = linux ]; then
112
- l_available=true l_kind=docker l_invoke='["docker"]' l_version=$p_version l_osname=$p_osname
113
- l_endpoint=$p_endpoint l_total=$p_total l_mine=$p_mine
114
- else
115
- if [ -n "$p_os" ]; then p_error="it is a $p_os engine"; fi
116
- l_reason='no candidate answered; see tried'
117
- l_tried="{\"kind\": \"docker\", \"distro\": null, \"error\": $(js "$p_error")}"
121
+ # probe_podman <podman command...>: asks a Podman CLI about its engine, with Podman's own field names
122
+ probe_podman() {
123
+ reset_probe
124
+ format='{{.Host.OS}}|{{.Version.Version}}|{{.Host.Distribution.Distribution}} {{.Host.Distribution.Version}}|{{.Host.Security.Rootless}}|{{.Host.Security.SELinuxEnabled}}|{{.Host.RemoteSocket.Path}}'
125
+ if ! out=$("$@" info --format "$format" 2>/dev/null) || [ "${out#*|}" = "$out" ]; then
126
+ first_error "$@" info --format "$format"
127
+ return 1
128
+ fi
129
+ IFS='|' read -r p_os p_version p_osname p_rootless p_selinux p_endpoint <<EOF
130
+ $out
131
+ EOF
132
+ case "$p_rootless" in true | false) ;; *) p_rootless='' ;; esac
133
+ case "$p_selinux" in true | false) ;; *) p_selinux='' ;; esac
134
+ case "$p_endpoint" in /*) p_endpoint="unix://$p_endpoint" ;; esac
135
+ count_containers "$@"
136
+ }
137
+
138
+ # Linux engine: Docker first (the docker CLI's current context: DOCKER_HOST, Docker Desktop, colima, a distro's own
139
+ # Docker), then Podman.
140
+ l_available=false l_kind='' l_invoke=null l_version='' l_osname='' l_endpoint='' l_rootless='' l_selinux=''
141
+ l_total='' l_mine='' l_reason='' l_tried='' l_denied=0
142
+ # A Windows engine behind the docker CLI's current context (DOCKER_HOST or a remote context)
143
+ remote_windows=0 rw_version='' rw_endpoint='' rw_isolation=''
144
+
145
+ add_tried() {
146
+ entry="{\"kind\": \"$1\", \"distro\": null, \"error\": $(js "$2")}"
147
+ if [ -n "$l_tried" ]; then l_tried="$l_tried, $entry"; else l_tried=$entry; fi
148
+ case "$2" in *[Pp]ermission\ denied*) l_denied=1 ;; esac
149
+ }
150
+ found_linux() {
151
+ l_available=true l_kind=$1 l_invoke="[\"$2\"]" l_version=$p_version l_osname=$p_osname l_endpoint=$p_endpoint
152
+ l_rootless=$p_rootless l_selinux=$p_selinux l_total=$p_total l_mine=$p_mine
153
+ }
154
+ try_docker() {
155
+ if ! command -v docker >/dev/null 2>&1; then add_tried docker 'docker CLI not installed'; return 1; fi
156
+ if is_podman docker; then add_tried docker 'docker is the podman-docker shim'; return 1; fi
157
+ if ! probe_docker docker; then add_tried docker "$p_error"; return 1; fi
158
+ if [ "$p_os" = linux ]; then found_linux docker docker; return 0; fi
159
+ if [ "$p_os" = windows ]; then
160
+ remote_windows=1 rw_version=$p_version rw_endpoint=$p_endpoint rw_isolation=$p_isolation
161
+ fi
162
+ add_tried docker "it is a $p_os engine"
163
+ return 1
164
+ }
165
+ try_podman() {
166
+ cli=podman
167
+ if ! command -v podman >/dev/null 2>&1; then
168
+ if command -v docker >/dev/null 2>&1 && is_podman docker; then
169
+ cli=docker
170
+ else
171
+ add_tried podman 'podman CLI not installed'
172
+ return 1
173
+ fi
174
+ fi
175
+ if ! probe_podman "$cli"; then add_tried podman "$p_error"; return 1; fi
176
+ if [ "$p_os" = linux ]; then found_linux podman "$cli"; return 0; fi
177
+ add_tried podman "it is a $p_os engine"
178
+ return 1
179
+ }
180
+ case "$ov_engine" in
181
+ none) l_reason='disabled by the linuxEngine override' ;;
182
+ docker) try_docker || true ;;
183
+ podman) try_podman || true ;;
184
+ auto) try_docker || try_podman || true ;;
185
+ esac
186
+ if [ "$l_available" = false ] && [ -z "$l_reason" ]; then
187
+ if [ "$l_denied" = 1 ]; then
188
+ l_reason='socket permission denied'
189
+ elif ! command -v docker >/dev/null 2>&1 && ! command -v podman >/dev/null 2>&1; then
190
+ l_reason='no docker or podman CLI is installed'
191
+ else
192
+ l_reason='no candidate answered; see tried'
193
+ fi
118
194
  fi
119
195
 
120
- # Windows engine: only reachable from WSL, through Windows' docker.exe
196
+ # Windows engine: from WSL, through Windows' docker.exe. From anywhere, a remote one behind the docker CLI's
197
+ # current context. Podman can't run Windows containers.
121
198
  w_available=false w_invoke=null w_endpoint='' w_version='' w_isolation='' w_runners='' w_reason=''
122
- if [ "$platform" != wsl ]; then
123
- w_reason="a $platform host has no Windows engine"
124
- elif ! command -v docker.exe >/dev/null 2>&1; then
125
- w_reason='docker.exe is not on the PATH (WSL interop off, or no Windows Docker)'
126
- elif probe docker.exe && [ "$p_os" = windows ]; then
199
+ if [ "$platform" = wsl ] && command -v docker.exe >/dev/null 2>&1 && probe_docker docker.exe && [ "$p_os" = windows ]; then
127
200
  w_available=true w_invoke='["docker.exe"]' w_endpoint=$p_endpoint w_version=$p_version w_isolation=$p_isolation
128
201
  if [ -n "$ov_runners" ]; then
129
202
  w_runners=0
@@ -133,6 +206,13 @@ elif probe docker.exe && [ "$p_os" = windows ]; then
133
206
  done
134
207
  fi
135
208
  w_reason='per-LTSC isolation is only worked out on Windows: run Get-HostCapabilities.ps1 with pwsh.exe'
209
+ elif [ "$remote_windows" = 1 ]; then
210
+ w_available=true w_invoke='["docker"]' w_endpoint=$rw_endpoint w_version=$rw_version w_isolation=$rw_isolation
211
+ w_reason='a remote engine behind the docker context: per-LTSC isolation is not worked out, and bind mounts of this host do not work'
212
+ elif [ "$platform" != wsl ]; then
213
+ w_reason="a $platform host has no Windows engine, and the docker context does not reach one"
214
+ elif ! command -v docker.exe >/dev/null 2>&1; then
215
+ w_reason='docker.exe is not on the PATH (WSL interop off, or no Windows Docker)'
136
216
  else
137
217
  if [ -n "$p_os" ]; then p_error="docker.exe reaches a $p_os engine"; fi
138
218
  w_reason="docker.exe: $p_error"
@@ -192,6 +272,8 @@ cat >"$cache" <<EOF
192
272
  "endpoint": $(js "$l_endpoint"),
193
273
  "serverVersion": $(js "$l_version"),
194
274
  "operatingSystem": $(js "$l_osname"),
275
+ "rootless": $(jn "$l_rootless"),
276
+ "selinux": $(jn "$l_selinux"),
195
277
  "containers": $(jn "$l_total"),
196
278
  "claudeContainers": $(jn "$l_mine"),
197
279
  "repoPath": $(js "$root"),