bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
734 lines
26 KiB
YAML
734 lines
26 KiB
YAML
name: CLI exit codes
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
"on":
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- python/**
|
|
- typescript/**
|
|
- .github/workflows/test_cli.yml
|
|
pull_request:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
changes:
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'pull_request'
|
|
permissions:
|
|
pull-requests: read
|
|
outputs:
|
|
hit: ${{ steps.filter.outputs.hit }}
|
|
steps:
|
|
- uses: dorny/paths-filter@v4
|
|
id: filter
|
|
with:
|
|
filters: |
|
|
hit:
|
|
- 'python/**'
|
|
- 'typescript/**'
|
|
- 'integ/cross.sh'
|
|
- 'integ/cross.yaml'
|
|
- 'integ/parity.sh'
|
|
- 'integ/cli_fuse.sh'
|
|
- 'integ/cli_config.sh'
|
|
- '.github/workflows/test_cli.yml'
|
|
|
|
python:
|
|
needs: changes
|
|
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.hit == 'true') }}
|
|
name: Python CLI
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v6
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v7
|
|
with:
|
|
enable-cache: true
|
|
|
|
- name: Install Python dependencies
|
|
working-directory: python
|
|
run: uv sync --all-extras --no-extra camel
|
|
|
|
- name: Install mirage package
|
|
working-directory: python
|
|
run: uv pip install .
|
|
|
|
- name: Smoke test exit code propagation
|
|
working-directory: python
|
|
shell: bash
|
|
run: |
|
|
set -u
|
|
MIRAGE=".venv/bin/mirage"
|
|
cat > /tmp/ws.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
YAML
|
|
|
|
$MIRAGE workspace delete smoke > /dev/null 2>&1 || true
|
|
$MIRAGE workspace create /tmp/ws.yaml --id smoke
|
|
|
|
fail=0
|
|
check() {
|
|
local desc="$1"
|
|
local expected_exit="$2"
|
|
local expected_field="$3"
|
|
shift 3
|
|
echo
|
|
echo "--- $desc"
|
|
echo "+ $*"
|
|
set +e
|
|
out=$("$@")
|
|
got_exit=$?
|
|
set -e
|
|
echo "stdout:"
|
|
echo "$out" | sed 's/^/ /'
|
|
echo "process exit: $got_exit (expected $expected_exit)"
|
|
if [ "$got_exit" != "$expected_exit" ]; then
|
|
echo "FAIL: process exit code mismatch"
|
|
fail=1
|
|
return
|
|
fi
|
|
if [ -n "$expected_field" ]; then
|
|
field_val=$(echo "$out" | jq -r '.exit_code // .result.exit_code // empty')
|
|
echo "json exit_code: $field_val (expected $expected_field)"
|
|
if [ "$field_val" != "$expected_field" ]; then
|
|
echo "FAIL: json exit_code mismatch"
|
|
fail=1
|
|
return
|
|
fi
|
|
fi
|
|
echo "PASS"
|
|
}
|
|
|
|
check "echo ok succeeds" 0 0 $MIRAGE execute -w smoke -c 'echo ok'
|
|
check "false exits 1" 1 1 $MIRAGE execute -w smoke -c 'false'
|
|
check "false | echo: pipefail off => last cmd wins" 0 0 \
|
|
$MIRAGE execute -w smoke -c 'false | echo done'
|
|
check "pipefail on => rightmost failure wins" 1 1 \
|
|
$MIRAGE execute -w smoke -c 'set -o pipefail; false | echo done'
|
|
|
|
echo
|
|
echo "--- background + job wait"
|
|
submit=$($MIRAGE execute -w smoke --bg -c 'false')
|
|
echo "submit response:"
|
|
echo "$submit" | sed 's/^/ /'
|
|
JID=$(echo "$submit" | jq -r .job_id)
|
|
echo "job id: $JID"
|
|
check "job wait on failing job" 1 1 $MIRAGE job wait "$JID"
|
|
|
|
echo
|
|
echo "--- shell composition (&& and ||)"
|
|
if $MIRAGE execute -w smoke -c 'false' > /dev/null && echo "should not print"; then
|
|
echo "FAIL: && short-circuit did not work"
|
|
fail=1
|
|
else
|
|
echo "PASS: && correctly short-circuited on inner failure"
|
|
fi
|
|
if $MIRAGE execute -w smoke -c 'echo ok' > /dev/null && echo "should print"; then
|
|
echo "PASS: && correctly proceeded on inner success"
|
|
else
|
|
echo "FAIL: && incorrectly short-circuited on success"
|
|
fail=1
|
|
fi
|
|
|
|
echo
|
|
echo "--- per-mount command safeguards"
|
|
cat > /tmp/sg.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
command_safeguards:
|
|
cat:
|
|
max_lines: 2
|
|
on_exceed: truncate
|
|
YAML
|
|
cat > /tmp/sgerr.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
command_safeguards:
|
|
cat:
|
|
max_lines: 2
|
|
on_exceed: error
|
|
YAML
|
|
$MIRAGE workspace delete sg > /dev/null 2>&1 || true
|
|
$MIRAGE workspace delete sgerr > /dev/null 2>&1 || true
|
|
$MIRAGE workspace create /tmp/sg.yaml --id sg
|
|
$MIRAGE workspace create /tmp/sgerr.yaml --id sgerr
|
|
$MIRAGE execute -w sg -c "printf '1\n2\n3\n4\n5\n' > /f.txt" > /dev/null
|
|
$MIRAGE execute -w sgerr -c "printf '1\n2\n3\n4\n5\n' > /f.txt" > /dev/null
|
|
|
|
check "cat over max_lines, on_exceed=truncate => exit 0" 0 0 \
|
|
$MIRAGE execute -w sg -c 'cat /f.txt'
|
|
echo "+ verify truncated to 2 lines"
|
|
lines=$($MIRAGE execute -w sg -c 'cat /f.txt' | jq -r '.stdout' | grep -c .)
|
|
echo " truncated stdout lines: $lines (expected 2)"
|
|
if [ "$lines" != "2" ]; then
|
|
echo "FAIL: truncate did not cap to 2 lines"
|
|
fail=1
|
|
else
|
|
echo "PASS"
|
|
fi
|
|
check "cat over max_lines, on_exceed=error => exit 1" 1 1 \
|
|
$MIRAGE execute -w sgerr -c 'cat /f.txt'
|
|
$MIRAGE workspace delete sg
|
|
$MIRAGE workspace delete sgerr
|
|
|
|
echo
|
|
echo "--- top-level snake_case config keys"
|
|
cat > /tmp/cfgkeys.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
default_session_id: mysess
|
|
default_agent_id: myagent
|
|
cache:
|
|
type: ram
|
|
limit: 256MB
|
|
max_drain_bytes: 1048576
|
|
index:
|
|
type: ram
|
|
ttl: 600
|
|
YAML
|
|
$MIRAGE workspace delete cfgkeys > /dev/null 2>&1 || true
|
|
sid=$($MIRAGE workspace create /tmp/cfgkeys.yaml --id cfgkeys | jq -r '.sessions[0].sessionId // .sessions[0].session_id')
|
|
echo " default_session_id (snake) => $sid (expected mysess)"
|
|
if [ "$sid" != "mysess" ]; then
|
|
echo "FAIL: snake_case top-level keys not applied"
|
|
fail=1
|
|
else
|
|
echo "PASS"
|
|
fi
|
|
$MIRAGE execute -w cfgkeys -c "printf 'x\ny\n' > /f.txt" > /dev/null
|
|
check "ops work with snake_case cache/index configured => exit 0" 0 0 \
|
|
$MIRAGE execute -w cfgkeys -c 'cat /f.txt'
|
|
$MIRAGE workspace delete cfgkeys
|
|
|
|
curl_check() {
|
|
local desc="$1"
|
|
local expected="$2"
|
|
shift 2
|
|
local got
|
|
got=$(curl -s -o /dev/null -w "%{http_code}" "$@")
|
|
echo " $desc: got $got (expected $expected)"
|
|
if [ "$got" != "$expected" ]; then
|
|
echo " FAIL"
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
echo
|
|
echo "===== auth: mode=local (daemon spawned by CLI, token auto-minted) ====="
|
|
TOKEN=$(cat "$HOME/.mirage/auth_token")
|
|
MODE=$(stat -c "%a" "$HOME/.mirage/auth_token" 2>/dev/null || stat -f "%Lp" "$HOME/.mirage/auth_token")
|
|
echo "token file mode: $MODE (expected 600)"
|
|
if [ "$MODE" != "600" ]; then
|
|
echo "FAIL: token file must be 0o600"
|
|
fail=1
|
|
fi
|
|
curl_check "correct bearer => 200" 200 \
|
|
-H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "wrong bearer => 401" 401 \
|
|
-H "Authorization: Bearer wrong-token" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "no header => 401" 401 http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
curl_check "JWT-shaped value treated as opaque => 401" 401 \
|
|
-H "Authorization: Bearer aaaa.bbbb.cccc" http://127.0.0.1:8765/v1/workspaces
|
|
|
|
$MIRAGE workspace delete smoke
|
|
$MIRAGE daemon stop || true
|
|
sleep 2
|
|
|
|
echo
|
|
echo "===== auth: mode=token (operator PAT) ====="
|
|
MIRAGE_AUTH_MODE=token MIRAGE_AUTH_TOKEN=operator-pat-xyz \
|
|
.venv/bin/python -m uvicorn mirage.server.daemon:app \
|
|
--host 127.0.0.1 --port 8765 --log-level warning >/tmp/auth-token.log 2>&1 &
|
|
TOKEN_PID=$!
|
|
sleep 2
|
|
curl_check "correct PAT => 200" 200 \
|
|
-H "Authorization: Bearer operator-pat-xyz" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "wrong PAT => 401" 401 \
|
|
-H "Authorization: Bearer something-else" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "no header => 401" 401 http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
kill $TOKEN_PID 2>/dev/null || true
|
|
sleep 2
|
|
|
|
echo
|
|
echo "===== auth: mode=jwt (RS256, external issuer) ====="
|
|
mkdir -p /tmp/jwt-test
|
|
.venv/bin/python - <<'PYEOF'
|
|
import time, jwt
|
|
from pathlib import Path
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
d = Path("/tmp/jwt-test")
|
|
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
priv_pem = priv.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption())
|
|
(d / "pub.pem").write_bytes(priv.public_key().public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo))
|
|
now = int(time.time())
|
|
(d / "good.jwt").write_text(jwt.encode(
|
|
{"sub": "agent", "exp": now + 60}, priv_pem, algorithm="RS256"))
|
|
(d / "expired.jwt").write_text(jwt.encode(
|
|
{"sub": "agent", "exp": now - 60}, priv_pem, algorithm="RS256"))
|
|
PYEOF
|
|
|
|
MIRAGE_AUTH_MODE=jwt \
|
|
MIRAGE_JWT_PUBKEY_FILE=/tmp/jwt-test/pub.pem \
|
|
MIRAGE_JWT_ALG=RS256 \
|
|
.venv/bin/python -m uvicorn mirage.server.daemon:app \
|
|
--host 127.0.0.1 --port 8765 --log-level warning >/tmp/auth-jwt.log 2>&1 &
|
|
JWT_PID=$!
|
|
sleep 2
|
|
GOOD=$(cat /tmp/jwt-test/good.jwt)
|
|
EXPIRED=$(cat /tmp/jwt-test/expired.jwt)
|
|
curl_check "valid signed JWT => 200" 200 \
|
|
-H "Authorization: Bearer $GOOD" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "expired JWT => 401" 401 \
|
|
-H "Authorization: Bearer $EXPIRED" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "opaque (not 3-part) => 401" 401 \
|
|
-H "Authorization: Bearer opaque-not-a-jwt" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
kill $JWT_PID 2>/dev/null || true
|
|
sleep 2
|
|
|
|
if [ "$fail" != "0" ]; then
|
|
echo
|
|
echo "One or more checks failed."
|
|
exit 1
|
|
fi
|
|
|
|
typescript:
|
|
needs: changes
|
|
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.hit == 'true') }}
|
|
name: TypeScript CLI
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
version: 10.32.1
|
|
|
|
- name: Set up Node
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: "22"
|
|
cache: pnpm
|
|
cache-dependency-path: typescript/pnpm-lock.yaml
|
|
|
|
- name: Install node-gyp
|
|
run: npm install -g node-gyp
|
|
|
|
- name: Install TypeScript dependencies
|
|
working-directory: typescript
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Build TypeScript packages
|
|
working-directory: typescript
|
|
run: pnpm -r build
|
|
|
|
- name: Smoke test exit code propagation
|
|
working-directory: typescript
|
|
shell: bash
|
|
run: |
|
|
set -u
|
|
MIRAGE="node packages/cli/dist/bin/mirage.js"
|
|
cat > /tmp/ws.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
YAML
|
|
|
|
$MIRAGE workspace delete smoke > /dev/null 2>&1 || true
|
|
$MIRAGE workspace create /tmp/ws.yaml --id smoke
|
|
|
|
fail=0
|
|
check() {
|
|
local desc="$1"
|
|
local expected_exit="$2"
|
|
local expected_field="$3"
|
|
shift 3
|
|
echo
|
|
echo "--- $desc"
|
|
echo "+ $*"
|
|
set +e
|
|
out=$("$@")
|
|
got_exit=$?
|
|
set -e
|
|
echo "stdout:"
|
|
echo "$out" | sed 's/^/ /'
|
|
echo "process exit: $got_exit (expected $expected_exit)"
|
|
if [ "$got_exit" != "$expected_exit" ]; then
|
|
echo "FAIL: process exit code mismatch"
|
|
fail=1
|
|
return
|
|
fi
|
|
if [ -n "$expected_field" ]; then
|
|
field_val=$(echo "$out" | jq -r '.exitCode // .result.exitCode // empty')
|
|
echo "json exitCode: $field_val (expected $expected_field)"
|
|
if [ "$field_val" != "$expected_field" ]; then
|
|
echo "FAIL: json exitCode mismatch"
|
|
fail=1
|
|
return
|
|
fi
|
|
fi
|
|
echo "PASS"
|
|
}
|
|
|
|
check "echo ok succeeds" 0 0 $MIRAGE execute -w smoke -c 'echo ok'
|
|
check "false exits 1" 1 1 $MIRAGE execute -w smoke -c 'false'
|
|
check "false | echo: pipefail off => last cmd wins" 0 0 \
|
|
$MIRAGE execute -w smoke -c 'false | echo done'
|
|
check "pipefail on => rightmost failure wins" 1 1 \
|
|
$MIRAGE execute -w smoke -c 'set -o pipefail; false | echo done'
|
|
|
|
echo
|
|
echo "--- background + job wait"
|
|
submit=$($MIRAGE execute -w smoke --bg -c 'false')
|
|
echo "submit response:"
|
|
echo "$submit" | sed 's/^/ /'
|
|
JID=$(echo "$submit" | jq -r .jobId)
|
|
echo "job id: $JID"
|
|
check "job wait on failing job" 1 1 $MIRAGE job wait "$JID"
|
|
|
|
echo
|
|
echo "--- shell composition (&& and ||)"
|
|
if $MIRAGE execute -w smoke -c 'false' > /dev/null && echo "should not print"; then
|
|
echo "FAIL: && short-circuit did not work"
|
|
fail=1
|
|
else
|
|
echo "PASS: && correctly short-circuited on inner failure"
|
|
fi
|
|
if $MIRAGE execute -w smoke -c 'echo ok' > /dev/null && echo "should print"; then
|
|
echo "PASS: && correctly proceeded on inner success"
|
|
else
|
|
echo "FAIL: && incorrectly short-circuited on success"
|
|
fail=1
|
|
fi
|
|
|
|
echo
|
|
echo "--- per-mount command safeguards"
|
|
cat > /tmp/sg.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
command_safeguards:
|
|
cat:
|
|
max_lines: 2
|
|
on_exceed: truncate
|
|
YAML
|
|
cat > /tmp/sgerr.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
command_safeguards:
|
|
cat:
|
|
max_lines: 2
|
|
on_exceed: error
|
|
YAML
|
|
$MIRAGE workspace delete sg > /dev/null 2>&1 || true
|
|
$MIRAGE workspace delete sgerr > /dev/null 2>&1 || true
|
|
$MIRAGE workspace create /tmp/sg.yaml --id sg
|
|
$MIRAGE workspace create /tmp/sgerr.yaml --id sgerr
|
|
$MIRAGE execute -w sg -c "printf '1\n2\n3\n4\n5\n' > /f.txt" > /dev/null
|
|
$MIRAGE execute -w sgerr -c "printf '1\n2\n3\n4\n5\n' > /f.txt" > /dev/null
|
|
|
|
check "cat over max_lines, on_exceed=truncate => exit 0" 0 0 \
|
|
$MIRAGE execute -w sg -c 'cat /f.txt'
|
|
echo "+ verify truncated to 2 lines"
|
|
lines=$($MIRAGE execute -w sg -c 'cat /f.txt' | jq -r '.stdout' | grep -c .)
|
|
echo " truncated stdout lines: $lines (expected 2)"
|
|
if [ "$lines" != "2" ]; then
|
|
echo "FAIL: truncate did not cap to 2 lines"
|
|
fail=1
|
|
else
|
|
echo "PASS"
|
|
fi
|
|
check "cat over max_lines, on_exceed=error => exit 1" 1 1 \
|
|
$MIRAGE execute -w sgerr -c 'cat /f.txt'
|
|
$MIRAGE workspace delete sg
|
|
$MIRAGE workspace delete sgerr
|
|
|
|
echo
|
|
echo "--- top-level snake_case config keys"
|
|
cat > /tmp/cfgkeys.yaml <<'YAML'
|
|
mounts:
|
|
/:
|
|
resource: ram
|
|
mode: WRITE
|
|
default_session_id: mysess
|
|
default_agent_id: myagent
|
|
cache:
|
|
type: ram
|
|
limit: 256MB
|
|
max_drain_bytes: 1048576
|
|
index:
|
|
type: ram
|
|
ttl: 600
|
|
YAML
|
|
$MIRAGE workspace delete cfgkeys > /dev/null 2>&1 || true
|
|
sid=$($MIRAGE workspace create /tmp/cfgkeys.yaml --id cfgkeys | jq -r '.sessions[0].sessionId // .sessions[0].session_id')
|
|
echo " default_session_id (snake) => $sid (expected mysess)"
|
|
if [ "$sid" != "mysess" ]; then
|
|
echo "FAIL: snake_case top-level keys not applied"
|
|
fail=1
|
|
else
|
|
echo "PASS"
|
|
fi
|
|
$MIRAGE execute -w cfgkeys -c "printf 'x\ny\n' > /f.txt" > /dev/null
|
|
check "ops work with snake_case cache/index configured => exit 0" 0 0 \
|
|
$MIRAGE execute -w cfgkeys -c 'cat /f.txt'
|
|
$MIRAGE workspace delete cfgkeys
|
|
|
|
curl_check() {
|
|
local desc="$1"
|
|
local expected="$2"
|
|
shift 2
|
|
local got
|
|
got=$(curl -s -o /dev/null -w "%{http_code}" "$@")
|
|
echo " $desc: got $got (expected $expected)"
|
|
if [ "$got" != "$expected" ]; then
|
|
echo " FAIL"
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
echo
|
|
echo "===== auth: mode=local (TS daemon spawned by CLI, token auto-minted) ====="
|
|
TOKEN=$(cat "$HOME/.mirage/auth_token")
|
|
MODE=$(stat -c "%a" "$HOME/.mirage/auth_token" 2>/dev/null || stat -f "%Lp" "$HOME/.mirage/auth_token")
|
|
echo "token file mode: $MODE (expected 600)"
|
|
if [ "$MODE" != "600" ]; then
|
|
echo "FAIL: token file must be 0o600"
|
|
fail=1
|
|
fi
|
|
curl_check "correct bearer => 200" 200 \
|
|
-H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "wrong bearer => 401" 401 \
|
|
-H "Authorization: Bearer wrong-token" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "no header => 401" 401 http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
curl_check "JWT-shaped value treated as opaque => 401" 401 \
|
|
-H "Authorization: Bearer aaaa.bbbb.cccc" http://127.0.0.1:8765/v1/workspaces
|
|
|
|
$MIRAGE workspace delete smoke
|
|
$MIRAGE daemon stop || true
|
|
sleep 2
|
|
|
|
echo
|
|
echo "===== auth: mode=token (TS daemon, operator PAT) ====="
|
|
MIRAGE_AUTH_MODE=token MIRAGE_AUTH_TOKEN=operator-pat-xyz \
|
|
node packages/server/dist/bin/daemon.js >/tmp/auth-token-ts.log 2>&1 &
|
|
TOKEN_PID=$!
|
|
sleep 2
|
|
curl_check "correct PAT => 200" 200 \
|
|
-H "Authorization: Bearer operator-pat-xyz" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "wrong PAT => 401" 401 \
|
|
-H "Authorization: Bearer something-else" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "no header => 401" 401 http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
kill $TOKEN_PID 2>/dev/null || true
|
|
sleep 2
|
|
|
|
echo
|
|
echo "===== auth: mode=jwt (TS daemon, RS256, external issuer) ====="
|
|
mkdir -p /tmp/jwt-test-ts
|
|
cat > packages/server/mint-jwt.mjs <<'NODEEOF'
|
|
import { writeFileSync } from 'node:fs'
|
|
import { generateKeyPair, SignJWT, exportSPKI } from 'jose'
|
|
const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048 })
|
|
writeFileSync('/tmp/jwt-test-ts/pub.pem', await exportSPKI(publicKey))
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const sign = (exp) => new SignJWT({ sub: 'agent' })
|
|
.setProtectedHeader({ alg: 'RS256' })
|
|
.setExpirationTime(exp)
|
|
.sign(privateKey)
|
|
writeFileSync('/tmp/jwt-test-ts/good.jwt', await sign(now + 60))
|
|
writeFileSync('/tmp/jwt-test-ts/expired.jwt', await sign(now - 60))
|
|
NODEEOF
|
|
node packages/server/mint-jwt.mjs
|
|
rm packages/server/mint-jwt.mjs
|
|
|
|
MIRAGE_AUTH_MODE=jwt \
|
|
MIRAGE_JWT_PUBKEY_FILE=/tmp/jwt-test-ts/pub.pem \
|
|
MIRAGE_JWT_ALG=RS256 \
|
|
node packages/server/dist/bin/daemon.js >/tmp/auth-jwt-ts.log 2>&1 &
|
|
JWT_PID=$!
|
|
sleep 2
|
|
GOOD=$(cat /tmp/jwt-test-ts/good.jwt)
|
|
EXPIRED=$(cat /tmp/jwt-test-ts/expired.jwt)
|
|
curl_check "valid signed JWT => 200" 200 \
|
|
-H "Authorization: Bearer $GOOD" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "expired JWT => 401" 401 \
|
|
-H "Authorization: Bearer $EXPIRED" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "opaque (not 3-part) => 401" 401 \
|
|
-H "Authorization: Bearer opaque-not-a-jwt" http://127.0.0.1:8765/v1/workspaces
|
|
curl_check "/v1/health bypass => 200" 200 http://127.0.0.1:8765/v1/health
|
|
kill $JWT_PID 2>/dev/null || true
|
|
sleep 2
|
|
|
|
if [ "$fail" != "0" ]; then
|
|
echo
|
|
echo "One or more checks failed."
|
|
exit 1
|
|
fi
|
|
|
|
cross:
|
|
needs: changes
|
|
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.hit == 'true') }}
|
|
name: Cross-language snapshot interop
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
services:
|
|
redis:
|
|
image: redis:7
|
|
ports:
|
|
- 6379:6379
|
|
options: >-
|
|
--health-cmd "redis-cli ping"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
env:
|
|
REDIS_URL: redis://localhost:6379/0
|
|
S3_BUCKET: mirage-cross
|
|
S3_ENDPOINT: http://localhost:9000
|
|
S3_REGION: us-east-1
|
|
AWS_ACCESS_KEY_ID: minio
|
|
AWS_SECRET_ACCESS_KEY: minio123
|
|
CROSS_DISK_ROOT: /tmp/mirage-cross-disk
|
|
CROSS_REDIS_PREFIX: mirage-cross/
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install libfuse (mfusepy + @zkochan/fuse-native, for the CLI FUSE e2e)
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y fuse3 libfuse3-dev libfuse2t64 || \
|
|
sudo apt-get install -y fuse3 libfuse3-dev libfuse2
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v6
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install uv
|
|
uses: astral-sh/setup-uv@v7
|
|
with:
|
|
enable-cache: true
|
|
|
|
- name: Install Python dependencies
|
|
working-directory: python
|
|
run: uv sync --all-extras --no-extra camel
|
|
|
|
- name: Install mirage package
|
|
working-directory: python
|
|
run: uv pip install .
|
|
|
|
- name: Install pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
version: 10.32.1
|
|
|
|
- name: Set up Node
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: "24"
|
|
|
|
- name: Install TypeScript dependencies
|
|
working-directory: typescript
|
|
run: pnpm install --frozen-lockfile=false
|
|
|
|
- name: Build TypeScript packages
|
|
working-directory: typescript
|
|
run: pnpm -r build
|
|
|
|
- name: Start MinIO
|
|
run: |
|
|
for i in $(seq 1 5); do
|
|
docker pull minio/minio:latest && break
|
|
echo "docker pull failed (attempt $i), retrying"; sleep 10
|
|
done
|
|
docker run -d --name minio -p 9000:9000 \
|
|
-e MINIO_ROOT_USER=minio -e MINIO_ROOT_PASSWORD=minio123 \
|
|
minio/minio:latest server /data
|
|
for i in $(seq 1 30); do
|
|
curl -sf http://localhost:9000/minio/health/live && break
|
|
sleep 1
|
|
done
|
|
|
|
- name: Create MinIO bucket
|
|
run: |
|
|
curl -sSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /tmp/mc
|
|
chmod +x /tmp/mc
|
|
/tmp/mc alias set local http://localhost:9000 minio minio123
|
|
/tmp/mc mb local/mirage-cross || true
|
|
|
|
- name: Run cross-language snapshot interop (both directions)
|
|
run: |
|
|
bash integ/cross.sh \
|
|
"python/.venv/bin/mirage" \
|
|
"node typescript/packages/cli/dist/bin/mirage.js"
|
|
|
|
- name: Run CLI feature parity (subshell, sessions, versioning, fuse)
|
|
run: |
|
|
bash integ/parity.sh \
|
|
"python/.venv/bin/mirage" \
|
|
"node typescript/packages/cli/dist/bin/mirage.js"
|
|
|
|
- name: Run CLI config battery (relocation, validation, resolved view)
|
|
run: |
|
|
bash integ/cli_config.sh \
|
|
"python/.venv/bin/mirage" \
|
|
"node typescript/packages/cli/dist/bin/mirage.js"
|
|
|
|
- name: Run CLI end-to-end FUSE (two per-mount subtrees, real libfuse)
|
|
run: |
|
|
bash integ/cli_fuse.sh \
|
|
"python/.venv/bin/mirage" \
|
|
"node typescript/packages/cli/dist/bin/mirage.js"
|
|
|
|
gate:
|
|
name: cli-gate
|
|
runs-on: ubuntu-latest
|
|
needs: [changes, python, typescript, cross]
|
|
if: always()
|
|
steps:
|
|
- name: Check required jobs
|
|
run: |
|
|
results="${{ join(needs.*.result, ' ') }}"
|
|
echo "$results"
|
|
case "$results" in
|
|
*failure*|*cancelled*) exit 1 ;;
|
|
esac
|