Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8c65bbe
feat: add optional no-SIP image variant to update-vm-tools workflow
celanthe May 8, 2026
e01b4bf
fix: address review feedback on no-SIP workflow and VNC script
celanthe May 18, 2026
232200b
fix: skip no-SIP steps when image is filtered out
celanthe May 18, 2026
7fe582a
fix: prevent VM IP poll loop from exiting early on orka-engine error
celanthe Jun 1, 2026
b9cb415
debug: restore stderr output in VM IP poll loop
celanthe Jun 1, 2026
2eb9f22
debug: add VM state diagnostic step after run
celanthe Jun 1, 2026
dc1e8b3
fix: use unfiltered vm list and jq name filter for IP polling
celanthe Jun 1, 2026
f7fe51c
chore: remove diagnostic step after root cause confirmed
celanthe Jun 1, 2026
91f911b
debug: print orka-engine vm run help to inspect network flags
celanthe Jun 1, 2026
6f3899c
fix: wait for SSH readiness before copying scripts into VM
celanthe Jun 1, 2026
40d4f02
debug: print orka-engine vm run and vm list help output
celanthe Jun 2, 2026
7dd699e
fix: use known MAC address and arp fallback for VM IP detection
celanthe Jun 2, 2026
7b4f066
debug: expand VM IP diagnostics, revert mac-address flag
celanthe Jun 2, 2026
975f51a
debug: capture vm state, entitlements, and vmnet log immediately afte…
celanthe Jun 2, 2026
041c5f0
debug: capture VM MAC and use arp fallback in IP wait loop
celanthe Jun 2, 2026
a1fd93f
debug: add diagnostics to Wait for SSH, extend timeout to 15m
celanthe Jun 2, 2026
e747854
fix: disable graphical console on vm run
celanthe Jun 2, 2026
01d5af9
debug: try headless vnc-port combo, add crash log check
celanthe Jun 2, 2026
ae1d9af
debug: try sudo vm run, extend diagnose to 30s, read crash log content
celanthe Jun 2, 2026
7d453c3
debug: remove display flags (TCC denial), add pre-run env check, fix …
celanthe Jun 2, 2026
81917cb
debug: grant TCC ScreenCapture to RunVZ, restore disable-graphical-co…
celanthe Jun 2, 2026
00a8ced
debug: try user TCC database grant for RunVZ screen recording
celanthe Jun 2, 2026
71a6301
debug: try launchctl asuser to run orka-engine in user GUI session
celanthe Jun 3, 2026
a88f781
debug: replace static sleep with RunVZ PID monitor to find exact exit…
celanthe Jun 3, 2026
4f90caf
debug: read async.log when RunVZ exits to capture exit reason
celanthe Jun 3, 2026
100b8cf
debug: capture Sentry envelopes and config.json to identify RunVZ exi…
celanthe Jun 3, 2026
e2e0f3d
fix: run VMs via launchctl asuser to provide GUI session context for …
celanthe Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 134 additions & 5 deletions .github/workflows/update-vm-tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ on:
description: 'Comma-separated list of images to update (e.g., sequoia,sonoma). Leave blank to update all.'
required: false
default: ''
noSip:
description: 'Also build a no-SIP variant of each image'
required: false
default: false
type: boolean

permissions:
contents: read
Expand Down Expand Up @@ -73,6 +78,8 @@ jobs:
run: |
echo "VM_NAME=vm-tools-updater-${{ github.run_id }}-${{ matrix.name }}-${{ matrix.tag }}" >> "$GITHUB_OUTPUT"
echo "TEMP_NAME=vm-tools-updated-${{ github.run_id }}-${{ matrix.name }}-${{ matrix.tag }}" >> "$GITHUB_OUTPUT"
echo "NOSIP_VM_NAME=vm-tools-nosip-${{ github.run_id }}-${{ matrix.name }}-${{ matrix.tag }}" >> "$GITHUB_OUTPUT"
echo "NOSIP_TEMP_NAME=vm-tools-nosip-updated-${{ github.run_id }}-${{ matrix.name }}-${{ matrix.tag }}" >> "$GITHUB_OUTPUT"

- name: Pull image
if: env.SKIP_IMAGE != 'true'
Expand All @@ -83,22 +90,58 @@ jobs:
- name: Run VM
if: env.SKIP_IMAGE != 'true'
run: |
orka-engine vm run ${{ steps.vars.outputs.VM_NAME }} \
# launchctl asuser runs orka-engine inside the runner's GUI login session,
# which RunVZ requires for LaunchServices access on macOS 15.
sudo launchctl asuser "$(id -u)" /usr/local/bin/orka-engine vm run ${{ steps.vars.outputs.VM_NAME }} \
--image ghcr.io/macstadium/orka-images/${{ matrix.name }}:${{ matrix.source_tag }} \
--disable-graphical-console \
-d

- name: Wait for VM IP
if: env.SKIP_IMAGE != 'true'
timeout-minutes: 5
timeout-minutes: 10
id: vm-ip
run: |
vm_name="${{ steps.vars.outputs.VM_NAME }}"
output=""
itr=0
while [ -z "$output" ]; do
output=$(orka-engine vm list ${{ steps.vars.outputs.VM_NAME }} -o json | jq -r '.[0].ip // empty')
sleep 1
output=$(orka-engine vm list -o json | jq -r --arg name "$vm_name" '.[] | select(.name == $name) | .ip // empty') || true
if [ -z "$output" ]; then
itr=$((itr + 1))
if [ $((itr % 12)) -eq 0 ]; then
echo "=== No IP after $((itr * 5))s ==="
orka-engine vm list || true
fi
sleep 5
fi
done
echo "VM_IP=$output" >> "$GITHUB_OUTPUT"

- name: Wait for SSH
if: env.SKIP_IMAGE != 'true'
timeout-minutes: 15
env:
VM_DEFAULT_PASSWORD: ${{ secrets.VM_DEFAULT_PASSWORD }}
run: |
vm_ip="${{ steps.vm-ip.outputs.VM_IP }}"
attempt=0
until sshpass -p "${VM_DEFAULT_PASSWORD}" \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
"admin@${vm_ip}" "echo ok" &>/dev/null; do
attempt=$((attempt + 1))
if [ $((attempt % 6)) -eq 0 ]; then
echo "=== Waiting for SSH ($((attempt * 5))s elapsed) ==="
ps aux | grep -i RunVZ | grep -v grep || echo "-- RunVZ: (not running)"
sshpass -p "${VM_DEFAULT_PASSWORD}" \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
"admin@${vm_ip}" "echo ok" 2>&1 || true
fi
sleep 5
done

- name: Copy scripts into VM
if: env.SKIP_IMAGE != 'true'
env:
Expand Down Expand Up @@ -180,7 +223,93 @@ jobs:
${{ steps.vars.outputs.TEMP_NAME }} \
ghcr.io/macstadium/orka-images/${{ matrix.name }}:${{ matrix.tag }}

- name: Run VM in recovery for no-SIP variant
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
run: |
sudo launchctl asuser "$(id -u)" /usr/local/bin/orka-engine vm run ${{ steps.vars.outputs.NOSIP_VM_NAME }} \
--image ${{ steps.vars.outputs.TEMP_NAME }} \
--recovery \
--vnc-port 5901 \
--disable-graphical-console \
-d

- name: Wait for recovery VM IP
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
timeout-minutes: 10
id: nosip-ip
run: |
vm_name="${{ steps.vars.outputs.NOSIP_VM_NAME }}"
output=""
itr=0
while [ -z "$output" ]; do
output=$(orka-engine vm list -o json | jq -r --arg name "$vm_name" '.[] | select(.name == $name) | .ip // empty') || true
if [ -z "$output" ]; then
itr=$((itr + 1))
if [ $((itr % 12)) -eq 0 ]; then
echo "=== No IP after $((itr * 5))s ==="
orka-engine vm list || true
fi
sleep 5
fi
done
echo "NOSIP_VM_IP=$output" >> "$GITHUB_OUTPUT"

- name: Disable SIP via VNC
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
timeout-minutes: 10
env:
VM_DEFAULT_PASSWORD: ${{ secrets.VM_DEFAULT_PASSWORD }}
run: |
python3 setup/vnc-disable-sip.py \
--host 127.0.0.1 \
--port 5901 \
--username admin \
--password "${VM_DEFAULT_PASSWORD}"

- name: Wait for no-SIP VM to come back online
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
timeout-minutes: 10
env:
VM_DEFAULT_PASSWORD: ${{ secrets.VM_DEFAULT_PASSWORD }}
run: |
nosip_ip="${{ steps.nosip-ip.outputs.NOSIP_VM_IP }}"
while ! sshpass -p "${VM_DEFAULT_PASSWORD}" \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"admin@${nosip_ip}" "echo 'VM is back online'" &>/dev/null; do
sleep 5
done

- name: Verify SIP is disabled
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
env:
VM_DEFAULT_PASSWORD: ${{ secrets.VM_DEFAULT_PASSWORD }}
run: |
sshpass -p "${VM_DEFAULT_PASSWORD}" \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"admin@${{ steps.nosip-ip.outputs.NOSIP_VM_IP }}" \
"csrutil status | grep -q 'disabled'"

- name: Save and push no-SIP image
if: ${{ (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true' }}
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
orka-engine vm save ${{ steps.vars.outputs.NOSIP_VM_NAME }} \
${{ steps.vars.outputs.NOSIP_TEMP_NAME }}

orka-engine image push \
--username ${{ github.actor }} \
--password "${GITHUB_TOKEN}" \
${{ steps.vars.outputs.NOSIP_TEMP_NAME }} \
ghcr.io/macstadium/orka-images/${{ matrix.name }}:${{ matrix.tag }}-no-sip

- name: Cleanup
if: always() && env.SKIP_IMAGE != 'true'
run: |
orka-engine vm delete -f ${{ steps.vars.outputs.VM_NAME }}
sudo launchctl asuser "$(id -u)" /usr/local/bin/orka-engine vm delete -f ${{ steps.vars.outputs.VM_NAME }}

- name: Cleanup no-SIP VM
if: always() && (inputs.noSip == true || inputs.noSip == 'true') && env.SKIP_IMAGE != 'true'
run: |
sudo launchctl asuser "$(id -u)" /usr/local/bin/orka-engine vm delete -f ${{ steps.vars.outputs.NOSIP_VM_NAME }}
112 changes: 112 additions & 0 deletions setup/vnc-disable-sip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Disables SIP in macOS Recovery via VNC.

Boot the VM with --recovery and --vnc-port, then run this script to open
Terminal from the Recovery Utilities menu, run csrutil disable, and reboot.

Usage:
python3 vnc-disable-sip.py --host 127.0.0.1 --port 5901 --username admin --password <admin_password>

Requirements:
pip install vncdotool
"""

import argparse
import sys
import time

try:
import vncdotool.api as vncapi
except ImportError:
print("vncdotool is required: pip install vncdotool", file=sys.stderr)
sys.exit(1)


class VNCSession:
def __init__(self, host, port):
self.client = vncapi.connect(host, port=port)

def close(self):
self.client.disconnect()

def wait(self, seconds):
time.sleep(seconds)

def key(self, name):
self.client.keyPress(name)

def type(self, text):
self.client.type(text)

def ctrl_f2(self):
# Ctrl+F2 focuses the Apple menu on macOS (accessibility keyboard shortcut)
self.client.keyDown('ctrl_l')
self.client.keyPress('F2')
self.client.keyUp('ctrl_l')


def disable_sip(vnc, username, password):
print("Waiting for Recovery UI to load...")
vnc.wait(90)

# Focus Apple menu via Ctrl+F2, then navigate right to Utilities
# Menu order: [Apple] [File] [Edit] [Utilities] [Window] [Help]
print("Opening Utilities > Terminal...")
vnc.ctrl_f2()
vnc.wait(1)

for _ in range(3):
vnc.key('Right')
vnc.wait(0.3)

vnc.key('Return')
vnc.wait(1)

# Jump to Terminal by pressing 't' in the open menu
vnc.key('t')
vnc.wait(0.5)
vnc.key('Return')

print("Waiting for Terminal to open...")
vnc.wait(5)

print("Disabling SIP...")
vnc.type('csrutil disable')
vnc.key('Return')
vnc.wait(3)

# csrutil disable prompts for admin username and password in Recovery
vnc.type(username)
vnc.key('Return')
vnc.wait(2)

vnc.type(password)
vnc.key('Return')
vnc.wait(5)

print("Rebooting...")
vnc.type('reboot')
vnc.key('Return')


def main():
parser = argparse.ArgumentParser(description='Disable SIP in macOS Recovery via VNC')
parser.add_argument('--host', required=True, help='VNC server host')
parser.add_argument('--port', type=int, default=5901, help='VNC port (default: 5901)')
parser.add_argument('--username', required=True, help='Admin username for csrutil authentication')
parser.add_argument('--password', required=True, help='Admin password for csrutil authentication')
args = parser.parse_args()

print(f'Connecting to VNC at {args.host}:{args.port}...')
vnc = VNCSession(args.host, args.port)

try:
disable_sip(vnc, args.username, args.password)
print('SIP disable sequence complete. Waiting for VM to reboot...')
finally:
vnc.close()


if __name__ == '__main__':
main()
Loading