Files
EOS/ai/run.ps1
T

222 lines
9.2 KiB
PowerShell
Raw Normal View History

# ai/run.ps1
# Entry point for the AkkudoktorEOS AI development sandbox (Windows PowerShell).
#
# Launcher:
# - Sanity-checks ai/compose.yaml for the main sandbox controls. This is not
# a complete validation; the protection of the sandbox definition is the
# read-only mount of ai/ in the container.
# - Forwards all arguments as the in-container command, never as docker
# compose options. PowerShell consumes an unquoted `--` itself, so `--`
# is optional: .\ai\run.ps1 claude or .\ai\run.ps1 -- claude
# - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
# - Requires every published port to bind to 127.0.0.1 (loopback on the host).
# - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
# - Optional publishing of the compose ports (AI_SANDBOX_PORTS=1).
# - Windows: UID/GID bind-mount mapping does not apply, HOST_UID/HOST_GID
# fall back to the defaults from ai/.env.
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
Set-Location $RepoRoot
$ComposeFile = "ai/compose.yaml"
$EnvFile = "ai/.env"
$EnvExample = "ai/.env.example"
# ---------------------------------------------------------------------------
# Helper: hard exit with a message on stderr.
# ---------------------------------------------------------------------------
function Fail([string]$Message, [int]$Code = 1) {
[Console]::Error.WriteLine("ERROR: $Message")
exit $Code
}
# ---------------------------------------------------------------------------
# 0. Arguments: all of them form the in-container command. A leading '--'
# (passed quoted, or kept by some PowerShell versions) is dropped.
# Cast to string: PowerShell passes numbers (e.g. `-n 4`) as Int32.
# ---------------------------------------------------------------------------
$ExtraCmd = @($args | ForEach-Object { [string]$_ })
if ($ExtraCmd.Count -gt 0 -and $ExtraCmd[0] -eq '--') {
$ExtraCmd = @($ExtraCmd | Select-Object -Skip 1)
}
# A command never starts with '-': such an argument is a docker compose option
# (e.g. --build), which the launcher does not forward.
if ($ExtraCmd.Count -gt 0 -and $ExtraCmd[0].StartsWith('-')) {
Fail ("'$($ExtraCmd[0])' is not a command. Docker compose options cannot be passed; " +
"all arguments form the in-container command.`n " +
"Use AI_SANDBOX_REBUILD=1 to rebuild and AI_SANDBOX_PORTS=1 to publish the ports.") 2
}
# ---------------------------------------------------------------------------
# 1. Prerequisites.
# ---------------------------------------------------------------------------
Write-Host "==> Checking prerequisites..."
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Fail "docker is not installed."
}
docker compose version *> $null
if ($LASTEXITCODE -ne 0) {
Fail "docker compose v2 is not available."
}
docker info *> $null
if ($LASTEXITCODE -ne 0) {
Fail "docker daemon is not reachable.`n - Is Docker Desktop running?`n - Try 'docker info' manually."
}
if (-not (Test-Path "pyproject.toml")) {
Fail "pyproject.toml not found; run from inside the AkkudoktorEOS repository."
}
if (-not (Test-Path $ComposeFile)) {
Fail "$ComposeFile missing."
}
# ---------------------------------------------------------------------------
# 2. Sanity-check the sandbox controls in compose.yaml.
# This catches accidental edits, not every weakening edit. The agent
# cannot edit compose.yaml from inside the sandbox (ai/ is read-only there);
# review changes to ai/ before you start the sandbox.
# ---------------------------------------------------------------------------
Write-Host "==> Checking sandbox controls in $ComposeFile ..."
$composeText = Get-Content -Raw -LiteralPath $ComposeFile
function Require-InCompose([string]$Pattern, [string]$Why) {
if ($composeText -notmatch $Pattern) {
Fail "compose.yaml does not contain: $Pattern`n $Why" 3
}
}
Require-InCompose '(?m)^\s*cap_drop:\s*$|(?m)^\s*cap_drop:\s*\[' 'Missing cap_drop (must drop ALL).'
Require-InCompose '(?m)^\s*-\s*"?ALL"?\s*$' 'cap_drop must include ALL.'
Require-InCompose 'no-new-privileges:\s*true' 'Missing no-new-privileges:true.'
Require-InCompose 'read_only:\s*true' 'Missing read_only:true.'
Require-InCompose '\.\./ai:/workspace/ai:ro' 'ai/ must be mounted read-only.'
# The docker socket (Linux path or Windows named pipe) must never be mounted.
if ($composeText -match '/var/run/docker\.sock') {
Fail "compose.yaml mounts the Docker socket. This is an escape vector." 3
}
if ($composeText -match '//\./pipe/docker_engine|\\\\\.\\pipe\\docker_engine') {
Fail "compose.yaml mounts the Docker named pipe. This is an escape vector." 3
}
# privileged must be false or absent.
if ($composeText -match 'privileged:\s*true') {
Fail "compose.yaml sets privileged:true; refusing to start." 3
}
# network_mode: host must never be used.
if ($composeText -match 'network_mode:\s*"?host"?') {
Fail "compose.yaml sets network_mode: host; refusing to start." 3
}
# Every published port MUST bind to 127.0.0.1.
if ($composeText -match '(?m)^\s*ports:\s*$') {
$portBlock = [regex]::Match(
$composeText,
'(?m)^[ \t]*ports:[ \t]*\r?\n((?:^[ \t]*-[^\r\n]*(?:\r?\n|\z))+)'
)
if ($portBlock.Success) {
$entries = [regex]::Matches($portBlock.Groups[1].Value, '(?m)^\s*-\s*(.+?)\s*$')
foreach ($m in $entries) {
$raw = $m.Groups[1].Value.Trim().Trim('"').Trim("'")
if (-not $raw.StartsWith('127.0.0.1:')) {
Fail "compose.yaml publishes a port on a non-loopback interface: '$raw'.`n Every entry under 'ports:' must start with '127.0.0.1:'." 3
}
}
}
}
# ---------------------------------------------------------------------------
# 3. Ensure ai/.env exists. HOST_UID/HOST_GID do not apply on Windows (bind
# mounts via Docker Desktop ignore them); the defaults in ai/.env stand.
# ---------------------------------------------------------------------------
if (-not (Test-Path $EnvFile)) {
if (Test-Path $EnvExample) {
Write-Host "==> Creating $EnvFile from $EnvExample"
Copy-Item -LiteralPath $EnvExample -Destination $EnvFile -Force
} else {
Write-Host "==> Creating empty $EnvFile"
New-Item -ItemType File -Path $EnvFile -Force | Out-Null
}
}
# ---------------------------------------------------------------------------
# 4. Rebuild is opt-in.
# ---------------------------------------------------------------------------
$BuildFlag = @()
if ($env:AI_SANDBOX_REBUILD -eq '1') {
Write-Host "==> Rebuilding image (AI_SANDBOX_REBUILD=1)"
$BuildFlag = @('--build')
} else {
Write-Host "==> Using cached image (set AI_SANDBOX_REBUILD=1 to rebuild)"
}
# ---------------------------------------------------------------------------
# 5. Port publishing is opt-in. `docker compose run` publishes the ports of
# compose.yaml (loopback-only, verified above) only with --service-ports.
# Off by default, so several sandboxes can run in parallel.
# ---------------------------------------------------------------------------
$PortsFlag = @()
if ($env:AI_SANDBOX_PORTS -eq '1') {
Write-Host "==> Publishing the compose ports on 127.0.0.1 (AI_SANDBOX_PORTS=1)"
$PortsFlag = @('--service-ports')
} else {
Write-Host "==> Ports not published (set AI_SANDBOX_PORTS=1 to publish them)"
}
# ---------------------------------------------------------------------------
# 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
# Always set the mode explicitly, so a value in ai/.env cannot change it.
# ---------------------------------------------------------------------------
# $env: changes outlive the script in the calling session; restored below.
$PrevSkillsMode = $env:AI_SANDBOX_SKILLS_MODE
if ($env:AI_SANDBOX_SKILLS_RW -eq '1') {
Write-Host "==> ai/skills is writable in the sandbox (AI_SANDBOX_SKILLS_RW=1)"
Write-Host " Review every change to ai/skills before you commit or push it."
$env:AI_SANDBOX_SKILLS_MODE = 'rw'
} else {
$env:AI_SANDBOX_SKILLS_MODE = 'ro'
}
# ---------------------------------------------------------------------------
# 7. Run.
# ---------------------------------------------------------------------------
Write-Host "==> Starting AI dev sandbox..."
$dockerArgs = @(
'compose',
'-f', $ComposeFile,
'run', '--rm'
) + $BuildFlag + $PortsFlag + @('ai-dev') + $ExtraCmd
$rc = $null
$PrevErrorAction = $ErrorActionPreference
try {
# Windows PowerShell 5.1 turns native stderr into error records when the
# script's output is redirected (e.g. .\ai\run.ps1 2>&1 | Tee-Object log.txt);
# with "Stop" the first status line of docker compose would abort the script.
$ErrorActionPreference = "Continue"
& docker @dockerArgs
$rc = $LASTEXITCODE
} finally {
$ErrorActionPreference = $PrevErrorAction
$env:AI_SANDBOX_SKILLS_MODE = $PrevSkillsMode
}
# Preserve docker compose's exit code, but if docker itself wasn't invoked
# (e.g. Parse error) we still exit non-zero.
if ($null -eq $rc) { $rc = 1 }
exit $rc