mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
222 lines
9.2 KiB
PowerShell
222 lines
9.2 KiB
PowerShell
# 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
|