diff --git a/.github/workflows/template-validation.yml b/.github/workflows/template-validation.yml index 9f5ba35..aaf2ba7 100644 --- a/.github/workflows/template-validation.yml +++ b/.github/workflows/template-validation.yml @@ -46,6 +46,16 @@ jobs: run: | brew install ansible + - name: Validate Tart Metal capabilities + run: | + bash -n scripts/install-tart-metal-capabilities.sh scripts/test-tart-metal-capabilities.sh + bash scripts/test-tart-metal-capabilities.sh + + - name: Validate TCC database provisioning + run: | + bash -n scripts/update-tcc-database.sh scripts/test-update-tcc-database.sh + bash scripts/test-update-tcc-database.sh + - name: Prepare validation inputs run: | mkdir -p "$HOME/XcodesCache" @@ -189,7 +199,7 @@ jobs: git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt build=false - if grep -Eq '^(\.github/workflows/base\.yml|templates/base\.pkr\.hcl|templates/disable-sip.*\.pkr\.hcl|data/(github_known_hosts|limit\.maxfiles\.plist|setup-info-template\.json|tart-guest-.*\.plist)|scripts/(automationmodetool\.expect|(install-actions-runner|update-tcc-database)\.sh)|ansible/)' changed-files.txt; then + if grep -Eq '^(\.github/workflows/base\.yml|templates/base\.pkr\.hcl|templates/disable-sip.*\.pkr\.hcl|data/(github_known_hosts|limit\.maxfiles\.plist|setup-info-template\.json|tart-guest-.*\.plist|tart-metal-capabilities/)|scripts/(automationmodetool\.expect|(install-actions-runner|install-tart-metal-capabilities|update-tcc-database)\.sh)|ansible/)' changed-files.txt; then build=true fi diff --git a/README.md b/README.md index 48d6730..6a12165 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,35 @@ The following image variants are currently available: See a full list of VMs available [here](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos-). +## Metal capabilities + +Base images include the experimental [Tart Metal shim](data/tart-metal-capabilities), +based on [the Lume team's work](https://github.com/trycua/cua/blob/main/blog/gpu-passthrough-macos-vms.md). +It is installed at `/usr/local/lib/TartMetalCapabilities.dylib` but is not +enabled by default, including for the guest agent or `tart exec`. + +Before starting the VM, enable unrestricted virtual-GPU features on the host +as the user running Tart: + +```shell +defaults write com.apple.gpusw.ParavirtualizedGraphics ForceUnrestrictedDeviceFeatureLevel -bool true +``` + +Restart an already-running VM after changing that preference. To enable the +shim for one command with Apple family 9 and 64 KiB of threadgroup memory: + +```shell +tart exec my-vm /usr/bin/env \ + DYLD_INSERT_LIBRARIES=/usr/local/lib/TartMetalCapabilities.dylib \ + TART_METAL_APPLE_FAMILY_MAX=1009 \ + /path/to/workload +``` + +Omit these environment variables to run normally. Each opted-in process gets +its own settings; `TART_METAL_APPLE_FAMILY_MAX=0` disables the capability +override. Protected executables may reject injection, and GPU support depends +on the host, guest, and workload. + ## Release Cadence Once a new version of Xcode is released, we will initiate a GitHub release which will automatically build and push diff --git a/data/tart-metal-capabilities/LICENSE b/data/tart-metal-capabilities/LICENSE new file mode 100644 index 0000000..c60b1c2 --- /dev/null +++ b/data/tart-metal-capabilities/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cua AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/data/tart-metal-capabilities/README.md b/data/tart-metal-capabilities/README.md new file mode 100644 index 0000000..7d0c6b1 --- /dev/null +++ b/data/tart-metal-capabilities/README.md @@ -0,0 +1,31 @@ +# Tart Metal capabilities + +This is a vendored adaptation of the minimal [Cua/Lume Metal capability shim](https://github.com/trycua/cua/tree/3c1acf27748c3e0f8ff71cd0c9ab072b1e160997/libs/lume/metal-capability-shim) +at revision `3c1acf27748c3e0f8ff71cd0c9ab072b1e160997`. The full shim source and +Metal capability probe are copied here. The source's `Lume` identifiers, +diagnostics, and `LUME_METAL_*` settings are renamed to `Tart` and `TART_METAL_*`. +The original Cua copyright and [MIT license](LICENSE) are retained. The upstream +source SHA-256 before renaming is +`e1371b1e579bca895e6b3a2b581b9f328203d9786bf809912a4a5d1672010cce`. + +The shim changes only Apple-family capability answers and selected memory +limits. It does not advertise additional Common, Mac, or Metal families. It +uses private, version-sensitive behavior; reported capabilities are not a +guarantee that every corresponding GPU operation is supported. + +Configuration is read once, when each process loads the library: + +| Variable | Behavior | +| --- | --- | +| `TART_METAL_APPLE_FAMILY_MAX` | Required Apple-family ceiling, from 1001 through 1999. Missing, zero, or invalid values disable the shim. | +| `TART_METAL_MAX_THREADGROUP_MEMORY` | Memory floor in bytes; defaults to 65536. | +| `TART_METAL_RECOMMENDED_WORKING_SET_SIZE` | Optional working-set floor in bytes; unchanged when unset. | + +The old `LUME_METAL_*` names are not recognized. Base images install the library +without enabling it for the guest agent or other processes. See the repository's +[Metal capabilities instructions](../../README.md#metal-capabilities) for +explicit per-command activation and host setup. + +From the repository root, run `bash scripts/test-tart-metal-capabilities.sh` to +build and test without installing anything on the host. The installer accepts +`DESTDIR` for staging and `TART_METAL_SOURCE_DIR` for Packer's uploaded sources. diff --git a/data/tart-metal-capabilities/Sources/TartMetalCapabilities.m b/data/tart-metal-capabilities/Sources/TartMetalCapabilities.m new file mode 100644 index 0000000..6900e73 --- /dev/null +++ b/data/tart-metal-capabilities/Sources/TartMetalCapabilities.m @@ -0,0 +1,207 @@ +// Copyright 2026 Cua AI, Inc. +// SPDX-License-Identifier: MIT +// Adapted from the Cua/Lume team's process-scoped Metal capability shim. +// Credit to the Lume folks for the idea and original implementation: +// https://github.com/trycua/cua/blob/3c1acf27748c3e0f8ff71cd0c9ab072b1e160997/blog/gpu-passthrough-macos-vms.md + +#import +#import +#import + +#include +#include + +typedef unsigned long long TartU64; + +typedef struct { + BOOL enabled; + NSUInteger appleFamilyMax; + NSUInteger maxThreadgroupMemory; + BOOL hasRecommendedWorkingSetSize; + NSUInteger recommendedWorkingSetSize; +} TartMetalConfiguration; + +static TartMetalConfiguration gConfiguration = {0}; +static IMP gOriginalInitGPUFamilySupport = NULL; +static IMP gOriginalMaxThreadgroupMemoryLength = NULL; +static IMP gOriginalRecommendedMaxWorkingSetSize = NULL; +static IMP gOriginalSupportsFamily = NULL; +static BOOL gDeviceHooksInstalled = NO; + +static BOOL parseUnsignedEnvironmentValue(const char *name, TartU64 *value) { + const char *rawValue = getenv(name); + if (!rawValue || !*rawValue) return NO; + + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(rawValue, &end, 0); + if (errno != 0 || end == rawValue || !end || *end != '\0') return NO; + + *value = parsed; + return YES; +} + +static BOOL loadConfiguration(void) { + TartU64 appleFamilyMax = 0; + if (!parseUnsignedEnvironmentValue( + "TART_METAL_APPLE_FAMILY_MAX", + &appleFamilyMax + ) || appleFamilyMax < 1001 || appleFamilyMax >= 2000) { + return NO; + } + + TartU64 maxThreadgroupMemory = 65536; + const char *rawThreadgroupMemory = getenv("TART_METAL_MAX_THREADGROUP_MEMORY"); + if (rawThreadgroupMemory && *rawThreadgroupMemory && + !parseUnsignedEnvironmentValue("TART_METAL_MAX_THREADGROUP_MEMORY", &maxThreadgroupMemory)) { + return NO; + } + + TartU64 recommendedWorkingSetSize = 0; + const char *rawWorkingSetSize = getenv("TART_METAL_RECOMMENDED_WORKING_SET_SIZE"); + BOOL hasRecommendedWorkingSetSize = rawWorkingSetSize && *rawWorkingSetSize; + if (hasRecommendedWorkingSetSize && + !parseUnsignedEnvironmentValue( + "TART_METAL_RECOMMENDED_WORKING_SET_SIZE", + &recommendedWorkingSetSize + )) { + return NO; + } + + gConfiguration.enabled = YES; + gConfiguration.appleFamilyMax = (NSUInteger)appleFamilyMax; + gConfiguration.maxThreadgroupMemory = (NSUInteger)maxThreadgroupMemory; + gConfiguration.hasRecommendedWorkingSetSize = hasRecommendedWorkingSetSize; + gConfiguration.recommendedWorkingSetSize = (NSUInteger)recommendedWorkingSetSize; + return YES; +} + +static NSUInteger hookMaxThreadgroupMemoryLength(id self, SEL selector) { + NSUInteger original = gOriginalMaxThreadgroupMemoryLength + ? ((NSUInteger(*)(id, SEL))(void *)gOriginalMaxThreadgroupMemoryLength)(self, selector) + : 0; + return original < gConfiguration.maxThreadgroupMemory + ? gConfiguration.maxThreadgroupMemory + : original; +} + +static NSUInteger hookRecommendedMaxWorkingSetSize(id self, SEL selector) { + NSUInteger original = gOriginalRecommendedMaxWorkingSetSize + ? ((NSUInteger(*)(id, SEL))(void *)gOriginalRecommendedMaxWorkingSetSize)(self, selector) + : 0; + return original < gConfiguration.recommendedWorkingSetSize + ? gConfiguration.recommendedWorkingSetSize + : original; +} + +static BOOL hookSupportsFamily(id self, SEL selector, NSUInteger family) { + BOOL original = gOriginalSupportsFamily + ? ((BOOL(*)(id, SEL, NSUInteger))(void *)gOriginalSupportsFamily)(self, selector, family) + : NO; + BOOL isConfiguredAppleFamily = family >= 1001 && + family <= gConfiguration.appleFamilyMax; + return original || isConfiguredAppleFamily; +} + +static BOOL replaceMethod( + Class deviceClass, + NSString *selectorName, + IMP replacement, + IMP *original +) { + SEL selector = NSSelectorFromString(selectorName); + Method method = class_getInstanceMethod(deviceClass, selector); + if (!method) return NO; + + *original = method_setImplementation(method, replacement); + return *original != NULL; +} + +static void installDeviceHooks(id device) { + @synchronized([device class]) { + if (gDeviceHooksInstalled) return; + + Class deviceClass = [device class]; + Method maxThreadgroupMemory = class_getInstanceMethod( + deviceClass, + NSSelectorFromString(@"maxThreadgroupMemoryLength") + ); + Method supportsFamily = class_getInstanceMethod( + deviceClass, + NSSelectorFromString(@"supportsFamily:") + ); + Method recommendedWorkingSetSize = gConfiguration.hasRecommendedWorkingSetSize + ? class_getInstanceMethod( + deviceClass, + NSSelectorFromString(@"recommendedMaxWorkingSetSize") + ) + : NULL; + + if (!maxThreadgroupMemory || !supportsFamily || + (gConfiguration.hasRecommendedWorkingSetSize && !recommendedWorkingSetSize)) { + NSLog(@"[TartMetalCapabilities] Required device methods are unavailable; leaving stock capabilities unchanged"); + return; + } + + BOOL installed = replaceMethod( + deviceClass, + @"maxThreadgroupMemoryLength", + (IMP)hookMaxThreadgroupMemoryLength, + &gOriginalMaxThreadgroupMemoryLength + ); + installed = installed && replaceMethod( + deviceClass, + @"supportsFamily:", + (IMP)hookSupportsFamily, + &gOriginalSupportsFamily + ); + + if (installed && gConfiguration.hasRecommendedWorkingSetSize) { + installed = replaceMethod( + deviceClass, + @"recommendedMaxWorkingSetSize", + (IMP)hookRecommendedMaxWorkingSetSize, + &gOriginalRecommendedMaxWorkingSetSize + ); + } + + if (!installed) { + NSLog(@"[TartMetalCapabilities] Capability hook installation was incomplete"); + return; + } + + gDeviceHooksInstalled = YES; + NSLog( + @"[TartMetalCapabilities] Enabled for %@ (appleFamilyMax=%llu maxThreadgroupMemory=%llu)", + [NSProcessInfo processInfo].processName, + (TartU64)gConfiguration.appleFamilyMax, + (TartU64)gConfiguration.maxThreadgroupMemory + ); + } +} + +static void hookInitGPUFamilySupport(id self, SEL selector) { + installDeviceHooks(self); + ((void(*)(id, SEL))(void *)gOriginalInitGPUFamilySupport)(self, selector); +} + +__attribute__((constructor)) +static void initializeTartMetalCapabilities(void) { + @autoreleasepool { + if (!loadConfiguration()) return; + + Class deviceClass = NSClassFromString(@"_MTLDevice"); + if (!deviceClass) return; + + Method method = class_getInstanceMethod( + deviceClass, + NSSelectorFromString(@"initGPUFamilySupport") + ); + if (!method) return; + + gOriginalInitGPUFamilySupport = method_setImplementation( + method, + (IMP)hookInitGPUFamilySupport + ); + } +} diff --git a/data/tart-metal-capabilities/Tests/configuration.m b/data/tart-metal-capabilities/Tests/configuration.m new file mode 100644 index 0000000..1d743d5 --- /dev/null +++ b/data/tart-metal-capabilities/Tests/configuration.m @@ -0,0 +1,66 @@ +#import "../Sources/TartMetalCapabilities.m" + +#include +#include +#include + +static void resetConfiguration(void) { + unsetenv("TART_METAL_APPLE_FAMILY_MAX"); + unsetenv("TART_METAL_MAX_THREADGROUP_MEMORY"); + unsetenv("TART_METAL_RECOMMENDED_WORKING_SET_SIZE"); + unsetenv("LUME_METAL_APPLE_FAMILY_MAX"); + gConfiguration = (TartMetalConfiguration){0}; +} + +int main(int argc, const char *argv[]) { + // In this mode, check the configuration loaded by the real constructor + // before main(), as it would be for a newly executed workload. + if (argc == 2) { + NSUInteger expected = (NSUInteger)strtoull(argv[1], NULL, 10); + assert(gConfiguration.enabled == (expected != 0)); + assert(gConfiguration.appleFamilyMax == expected); + return 0; + } + + resetConfiguration(); + assert(!loadConfiguration()); + setenv("LUME_METAL_APPLE_FAMILY_MAX", "1009", 1); + assert(!loadConfiguration()); + + const char *invalidFamilies[] = {"", "0", "1000", "2000", "-1", "1009x"}; + for (size_t i = 0; i < sizeof(invalidFamilies) / sizeof(invalidFamilies[0]); i++) { + resetConfiguration(); + setenv("TART_METAL_APPLE_FAMILY_MAX", invalidFamilies[i], 1); + assert(!loadConfiguration()); + assert(!gConfiguration.enabled); + } + + resetConfiguration(); + setenv("TART_METAL_APPLE_FAMILY_MAX", "1009", 1); + assert(loadConfiguration()); + assert(gConfiguration.appleFamilyMax == 1009); + assert(gConfiguration.maxThreadgroupMemory == 65536); + assert(!gConfiguration.hasRecommendedWorkingSetSize); + + resetConfiguration(); + setenv("TART_METAL_APPLE_FAMILY_MAX", "1999", 1); + setenv("TART_METAL_MAX_THREADGROUP_MEMORY", "32768", 1); + setenv("TART_METAL_RECOMMENDED_WORKING_SET_SIZE", "1073741824", 1); + assert(loadConfiguration()); + assert(gConfiguration.appleFamilyMax == 1999); + assert(gConfiguration.maxThreadgroupMemory == 32768); + assert(gConfiguration.hasRecommendedWorkingSetSize); + assert(gConfiguration.recommendedWorkingSetSize == 1073741824); + + resetConfiguration(); + setenv("TART_METAL_APPLE_FAMILY_MAX", "1009", 1); + setenv("TART_METAL_MAX_THREADGROUP_MEMORY", "invalid", 1); + assert(!loadConfiguration()); + unsetenv("TART_METAL_MAX_THREADGROUP_MEMORY"); + setenv("TART_METAL_RECOMMENDED_WORKING_SET_SIZE", "invalid", 1); + assert(!loadConfiguration()); + + resetConfiguration(); + puts("configuration: OK"); + return 0; +} diff --git a/data/tart-metal-capabilities/Tests/exec-environment.c b/data/tart-metal-capabilities/Tests/exec-environment.c new file mode 100644 index 0000000..da1c3a7 --- /dev/null +++ b/data/tart-metal-capabilities/Tests/exec-environment.c @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include +#include +#include + +static pid_t startChild(const char *executable, const char *library, const char *family) { + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + char *libraryAssignment = NULL; + char *familyAssignment = NULL; + assert(asprintf(&libraryAssignment, "DYLD_INSERT_LIBRARIES=%s", library) >= 0); + assert(asprintf(&familyAssignment, "TART_METAL_APPLE_FAMILY_MAX=%s", family) >= 0); + execl("/usr/bin/env", "env", libraryAssignment, familyAssignment, + executable, family, "child", NULL); + _exit(1); + } + return child; +} + +int main(int argc, char **argv) { + assert(argc >= 2); + const char *library = getenv("DYLD_INSERT_LIBRARIES"); + const char *family = getenv("TART_METAL_APPLE_FAMILY_MAX"); + const char *memory = getenv("TART_METAL_MAX_THREADGROUP_MEMORY"); + int loaded = 0; + for (uint32_t i = 0; i < _dyld_image_count(); i++) { + if (strstr(_dyld_get_image_name(i), "/TartMetalCapabilities.dylib")) loaded = 1; + } + if (strcmp(argv[1], "stock") == 0) { + assert(!library && !family && !memory && !loaded); + puts("stock process without injection: OK"); + return 0; + } + assert(library && family && memory); + assert(strcmp(family, argv[1]) == 0); + assert(strcmp(memory, "65536") == 0); + assert(loaded); + if (argc == 3) return 0; + + // Concurrent workloads must get independent overrides while their parent + // retains its explicit opt-in. Exercise the documented /usr/bin/env path. + pid_t children[] = { + startChild(argv[0], library, "1008"), + startChild(argv[0], library, "0"), + }; + for (size_t i = 0; i < sizeof(children) / sizeof(children[0]); i++) { + int status = 0; + assert(waitpid(children[i], &status, 0) == children[i]); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + } + assert(strcmp(getenv("TART_METAL_APPLE_FAMILY_MAX"), argv[1]) == 0); + puts("per-process injection and overrides: OK"); + return 0; +} diff --git a/data/tart-metal-capabilities/Tests/metal-capabilities.m b/data/tart-metal-capabilities/Tests/metal-capabilities.m new file mode 100644 index 0000000..ad12890 --- /dev/null +++ b/data/tart-metal-capabilities/Tests/metal-capabilities.m @@ -0,0 +1,42 @@ +#import +#import + +#include +#include + +static BOOL parseFamily(const char *raw, NSUInteger *family) { + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(raw, &end, 0); + if (errno != 0 || end == raw || !end || *end != '\0') return NO; + *family = (NSUInteger)parsed; + return YES; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + NSUInteger family = 1009; + if (argc > 1 && !parseFamily(argv[1], &family)) { + fprintf(stderr, "invalid family: %s\n", argv[1]); + return 2; + } + + id device = MTLCreateSystemDefaultDevice(); + if (!device) { + fprintf(stderr, "Metal device unavailable\n"); + return 1; + } + + printf("device=%s\n", device.name.UTF8String); + printf("family=%llu\n", (unsigned long long)family); + printf( + "supports_family=%s\n", + [device supportsFamily:(MTLGPUFamily)family] ? "true" : "false" + ); + printf( + "max_threadgroup_memory=%llu\n", + (unsigned long long)device.maxThreadgroupMemoryLength + ); + } + return 0; +} diff --git a/scripts/install-tart-metal-capabilities.sh b/scripts/install-tart-metal-capabilities.sh new file mode 100644 index 0000000..82a9178 --- /dev/null +++ b/scripts/install-tart-metal-capabilities.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# Packer supplies the uploaded source directory. Local builds use the vendored +# copy next to this script, so building an image needs no upstream download. +source_dir=${TART_METAL_SOURCE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../data/tart-metal-capabilities" && pwd)"} +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT +test -f "$source_dir/LICENSE" + +# One path must work for the native guest agent, arm64e processes, and Rosetta +# children. Keep the deployment target compatible with the oldest template. +xcrun clang \ + -arch arm64 -arch arm64e -arch x86_64 \ + -O3 -Wall -Wextra -Werror -fobjc-arc -fblocks -fvisibility=hidden \ + -dynamiclib -install_name /usr/local/lib/TartMetalCapabilities.dylib \ + -mmacosx-version-min=12.0 -framework Foundation -framework Metal \ + "$source_dir/Sources/TartMetalCapabilities.m" -o "$work_dir/TartMetalCapabilities.dylib" +codesign --force --sign - "$work_dir/TartMetalCapabilities.dylib" +# Xcode 27's lipo rejects multiple architectures in one -verify_arch call. +for architecture in arm64 arm64e x86_64; do + lipo "$work_dir/TartMetalCapabilities.dylib" -verify_arch "$architecture" +done +codesign --verify --strict "$work_dir/TartMetalCapabilities.dylib" + +# DESTDIR allows the exact installer to be exercised without modifying the host. +install_root=${DESTDIR:-} +install_command=(sudo install -o root -g wheel) +if [[ -n "$install_root" ]]; then + install_command=(install) +fi +"${install_command[@]}" -d -m 0755 "$install_root/usr/local/lib" \ + "$install_root/usr/local/share/licenses/tart-metal-capabilities" +"${install_command[@]}" -m 0644 "$work_dir/TartMetalCapabilities.dylib" \ + "$install_root/usr/local/lib/TartMetalCapabilities.dylib" +"${install_command[@]}" -m 0644 "$source_dir/LICENSE" \ + "$install_root/usr/local/share/licenses/tart-metal-capabilities/LICENSE" diff --git a/scripts/test-tart-metal-capabilities.sh b/scripts/test-tart-metal-capabilities.sh new file mode 100644 index 0000000..1d7f3a2 --- /dev/null +++ b/scripts/test-tart-metal-capabilities.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source_dir="$script_dir/../data/tart-metal-capabilities" +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +DESTDIR="$work_dir/root" TART_METAL_SOURCE_DIR="$source_dir" \ + bash "$script_dir/install-tart-metal-capabilities.sh" +library="$work_dir/root/usr/local/lib/TartMetalCapabilities.dylib" +cmp "$source_dir/LICENSE" \ + "$work_dir/root/usr/local/share/licenses/tart-metal-capabilities/LICENSE" + +xcrun clang -O2 -Wall -Wextra -Werror -fobjc-arc \ + -framework Foundation -framework Metal \ + "$source_dir/Tests/configuration.m" -o "$work_dir/configuration" +/usr/bin/env -u TART_METAL_APPLE_FAMILY_MAX \ + -u TART_METAL_MAX_THREADGROUP_MEMORY -u TART_METAL_RECOMMENDED_WORKING_SET_SIZE \ + "$work_dir/configuration" +for family in 1009 1008 0; do + /usr/bin/env -u TART_METAL_MAX_THREADGROUP_MEMORY \ + -u TART_METAL_RECOMMENDED_WORKING_SET_SIZE \ + TART_METAL_APPLE_FAMILY_MAX="$family" "$work_dir/configuration" "$family" +done + +xcrun clang -O2 -Wall -Wextra -Werror -arch arm64 -arch x86_64 \ + -mmacosx-version-min=12.0 "$source_dir/Tests/exec-environment.c" \ + -o "$work_dir/exec-environment" +run_stock() { + "$@" /usr/bin/env -u DYLD_INSERT_LIBRARIES -u TART_METAL_APPLE_FAMILY_MAX \ + -u TART_METAL_MAX_THREADGROUP_MEMORY -u TART_METAL_RECOMMENDED_WORKING_SET_SIZE \ + "$work_dir/exec-environment" stock +} +run_injected() { + "$@" /usr/bin/env DYLD_INSERT_LIBRARIES="$library" \ + TART_METAL_APPLE_FAMILY_MAX=1009 TART_METAL_MAX_THREADGROUP_MEMORY=65536 \ + "$work_dir/exec-environment" 1009 +} +run_stock +run_injected +if [[ $(uname -m) == arm64 ]]; then + if arch -x86_64 /usr/bin/true; then + run_stock arch -x86_64 + run_injected arch -x86_64 + else + echo "Rosetta runtime check skipped: Rosetta is not installed" + fi +fi + +for variant in agent daemon; do + plist="$script_dir/../data/tart-guest-$variant.plist" + plutil -lint "$plist" + agent_environment=$(plutil -extract EnvironmentVariables xml1 -o - "$plist") + if printf '%s\n' "$agent_environment" \ + | grep -Eq '(DYLD_INSERT_LIBRARIES|TART_METAL_[^<]+)'; then + echo "Unexpected default Metal injection in $plist" >&2 + exit 1 + fi +done diff --git a/scripts/test-update-tcc-database.sh b/scripts/test-update-tcc-database.sh new file mode 100644 index 0000000..54fdab0 --- /dev/null +++ b/scripts/test-update-tcc-database.sh @@ -0,0 +1,170 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +export TCC_TEST_ROOT +TCC_TEST_ROOT=$(mktemp -d) +trap 'rm -rf "$TCC_TEST_ROOT"' EXIT +export TCC_TEST_SQLITE +TCC_TEST_SQLITE=$(command -v sqlite3) +export TCC_TEST_SYSTEM_DB='/Library/Application Support/com.apple.TCC/TCC.db' +export TCC_TEST_LEGACY_DB="$HOME/Library/Application Support/com.apple.TCC/TCC.db" +export TCC_TEST_PROTECTED_DB='/private/var/containers/Data/ProtectedSystem/TEST-USER/Data/Library/Application Support/com.apple.TCC/TCC.db' +export TCC_TEST_AGENT='/opt/homebrew/Cellar/tart-guest-agent/0.13.0/bin/tart-guest-agent' + +# Run the real provisioning script against temporary SQLite databases. Every +# privileged operation is intercepted; these tests never access the host's TCC. +source() { + [[ $# == 1 && "$1" == "$HOME/.zprofile" ]] +} +sw_vers() { + [[ $# == 1 && "$1" == -productVersion ]] || return 1 + printf '%s\n' "$TCC_TEST_MACOS_VERSION" +} +id() { + [[ $# == 1 && "$1" == -u ]] || return 1 + printf '501\n' +} +realpath() { + [[ $# == 1 && "$1" == /opt/homebrew/bin/tart-guest-agent ]] || return 1 + [[ "$TCC_TEST_AGENT_EXISTS" == 1 ]] || return 1 + printf '%s\n' "$TCC_TEST_AGENT" +} +sudo() { + local subcommand="$1" database + shift + case "$subcommand" in + lsof) + [[ "$TCC_TEST_EXPECT_LSOF" == 1 && "$*" == '-a -u 501 -c tccd -Fn' ]] || return 1 + printf '%s\n' "$TCC_TEST_OPEN_FILES" + return "$TCC_TEST_LSOF_STATUS" + ;; + test) + [[ $# == 2 && "$1" == -f ]] || return 1 + case "$2" in + "$TCC_TEST_LEGACY_DB") [[ "$TCC_TEST_LEGACY_EXISTS" == 1 ]] ;; + "$TCC_TEST_PROTECTED_DB") [[ "$TCC_TEST_PROTECTED_EXISTS" == 1 ]] ;; + *) return 1 ;; + esac + ;; + stat) + [[ $# == 3 && "$1" == -f && "$2" == %u && "$3" == "$TCC_TEST_PROTECTED_DB" ]] || return 1 + printf '%s\n' "$TCC_TEST_OWNER" + ;; + sqlite3) + [[ $# == 1 ]] || return 1 + case "$1" in + "$TCC_TEST_SYSTEM_DB") database="$TCC_TEST_ROOT/system.db" ;; + "$TCC_TEST_LEGACY_DB"|"$TCC_TEST_PROTECTED_DB") database="$TCC_TEST_ROOT/user.db" ;; + *) echo "Unexpected database: $1" >&2; return 1 ;; + esac + printf '%s\n' "$1" >> "$TCC_TEST_ROOT/writes" + "$TCC_TEST_SQLITE" "$database" + ;; + *) echo "Unexpected sudo command: $subcommand" >&2; return 1 ;; + esac +} +export -f source sw_vers id realpath sudo + +reset_case() { + export TCC_TEST_MACOS_VERSION=26.6.2 TCC_TEST_AGENT_EXISTS=1 + export TCC_TEST_LEGACY_EXISTS=1 TCC_TEST_PROTECTED_EXISTS=1 TCC_TEST_OWNER=501 + export TCC_TEST_EXPECT_LSOF=0 TCC_TEST_LSOF_STATUS=0 TCC_TEST_OPEN_FILES='' + : > "$TCC_TEST_ROOT/writes" + local database + for database in system user; do + "$TCC_TEST_SQLITE" "$TCC_TEST_ROOT/$database.db" <<'SQL' +DROP TABLE IF EXISTS access; +CREATE TABLE access ( + service TEXT NOT NULL, + client_type INTEGER NOT NULL, + client TEXT NOT NULL, + auth_value INTEGER NOT NULL, + auth_reason INTEGER NOT NULL, + auth_version INTEGER NOT NULL, + indirect_object_identifier_type INTEGER, + indirect_object_identifier TEXT NOT NULL, + PRIMARY KEY (service, client, client_type, indirect_object_identifier) +); +SQL + done +} + +golden_gate_case() { + reset_case + export TCC_TEST_MACOS_VERSION=27.0 TCC_TEST_EXPECT_LSOF=1 + # Include an obsolete legacy copy, the system database, a WAL, and duplicate + # descriptors. Only the current user's active protected database may win. + TCC_TEST_OPEN_FILES=$(printf 'p123\nn%s\nn%s-wal\nn%s\nn%s\nn%s\n' \ + "$TCC_TEST_SYSTEM_DB" "$TCC_TEST_PROTECTED_DB" "$TCC_TEST_LEGACY_DB" \ + "$TCC_TEST_PROTECTED_DB" "$TCC_TEST_PROTECTED_DB") + export TCC_TEST_OPEN_FILES +} + +assert_equal() { + if [[ "$1" != "$2" ]]; then + printf 'Expected: %s\nActual: %s\n' "$1" "$2" >&2 + exit 1 + fi +} + +expect_success() { + local user_database="$1" database expected_writes + bash "$script_dir/update-tcc-database.sh" + bash "$script_dir/update-tcc-database.sh" + expected_writes=$(printf '%s\n%s\n%s\n%s' \ + "$TCC_TEST_SYSTEM_DB" "$user_database" "$TCC_TEST_SYSTEM_DB" "$user_database") + assert_equal "$expected_writes" "$(< "$TCC_TEST_ROOT/writes")" + for database in system user; do + assert_equal 18 "$("$TCC_TEST_SQLITE" "$TCC_TEST_ROOT/$database.db" 'SELECT count(*) FROM access WHERE auth_value=2;')" + assert_equal 4 "$("$TCC_TEST_SQLITE" "$TCC_TEST_ROOT/$database.db" "SELECT count(*) FROM access WHERE client='$TCC_TEST_AGENT' AND client_type=1;")" + assert_equal 1 "$("$TCC_TEST_SQLITE" "$TCC_TEST_ROOT/$database.db" "SELECT count(*) FROM access WHERE client='org.python.python' AND service='kTCCServiceMicrophone';")" + done +} + +expect_failure() { + if bash "$script_dir/update-tcc-database.sh" > "$TCC_TEST_ROOT/output" 2>&1; then + echo 'Expected provisioning to fail' >&2 + exit 1 + fi + if [[ -n "$1" ]] && ! grep -Fq "$1" "$TCC_TEST_ROOT/output"; then + cat "$TCC_TEST_ROOT/output" >&2 + exit 1 + fi + assert_equal '' "$(< "$TCC_TEST_ROOT/writes")" +} + +reset_case +expect_success "$TCC_TEST_LEGACY_DB" +reset_case +TCC_TEST_MACOS_VERSION=12.7.6 +expect_success "$TCC_TEST_LEGACY_DB" +golden_gate_case +expect_success "$TCC_TEST_PROTECTED_DB" + +reset_case +TCC_TEST_LEGACY_EXISTS=0 +expect_failure 'User TCC database does not exist' +golden_gate_case +TCC_TEST_LSOF_STATUS=1 +expect_failure 'Unable to inspect the user TCC daemon' +golden_gate_case +TCC_TEST_OPEN_FILES="n$TCC_TEST_SYSTEM_DB" +expect_failure 'Unable to find the active user TCC database' +golden_gate_case +TCC_TEST_OPEN_FILES="$TCC_TEST_OPEN_FILES"$'\n'"n${TCC_TEST_PROTECTED_DB/TEST-USER/OTHER-USER}" +expect_failure 'Found multiple active user TCC databases' +golden_gate_case +TCC_TEST_PROTECTED_EXISTS=0 +expect_failure 'User TCC database does not exist' +golden_gate_case +TCC_TEST_OWNER=502 +expect_failure 'Unexpected owner for user TCC database' +golden_gate_case +TCC_TEST_AGENT_EXISTS=0 +expect_failure '' +reset_case +TCC_TEST_MACOS_VERSION=invalid +expect_failure 'Unexpected macOS version' + +echo 'TCC database tests passed' diff --git a/scripts/update-tcc-database.sh b/scripts/update-tcc-database.sh index 58653f5..a74fa85 100644 --- a/scripts/update-tcc-database.sh +++ b/scripts/update-tcc-database.sh @@ -12,6 +12,54 @@ source ~/.zprofile # set -euo pipefail +resolve_user_tcc_database() { + local macos_version macos_major user_id open_files line candidate database="" + macos_version="$(sw_vers -productVersion)" + macos_major="${macos_version%%.*}" + case "$macos_major" in + ''|*[!0-9]*) echo "Unexpected macOS version: $macos_version" >&2; return 1 ;; + esac + + if [[ "$macos_major" -lt 27 ]]; then + database="${HOME}/Library/Application Support/com.apple.TCC/TCC.db" + else + # macOS 27 moved the user database into a per-user ProtectedSystem + # container. Inspect the daemon's open files to avoid using a stale copy. + # https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes#TCC + user_id="$(id -u)" + if ! open_files="$(sudo lsof -a -u "$user_id" -c tccd -Fn)"; then + echo "Unable to inspect the user TCC daemon for UID $user_id" >&2 + return 1 + fi + while IFS= read -r line; do + case "$line" in + n/private/var/containers/Data/ProtectedSystem/*/Data/Library/Application\ Support/com.apple.TCC/TCC.db) + candidate="${line#n}" + if [[ -n "$database" && "$database" != "$candidate" ]]; then + echo "Found multiple active user TCC databases for UID $user_id" >&2 + return 1 + fi + database="$candidate" + ;; + esac + done <<< "$open_files" + if [[ -z "$database" ]]; then + echo "Unable to find the active user TCC database for UID $user_id" >&2 + return 1 + fi + fi + + if ! sudo test -f "$database"; then + echo "User TCC database does not exist: $database" >&2 + return 1 + fi + if [[ "$macos_major" -ge 27 && "$(sudo stat -f %u "$database")" != "$user_id" ]]; then + echo "Unexpected owner for user TCC database: $database" >&2 + return 1 + fi + printf '%s\n' "$database" +} + update_tcc_database() { local tart_guest_agent_path tart_guest_agent_path="$(realpath /opt/homebrew/bin/tart-guest-agent)" @@ -53,8 +101,12 @@ update_tcc_database() { EOF } +# Resolve the user database before making either update. Never create an empty +# database at an obsolete path or silently omit user-level grants. +user_tcc_database="$(resolve_user_tcc_database)" + # Update TCC.db for all users update_tcc_database "/Library/Application Support/com.apple.TCC/TCC.db" # Update TCC.db for the current user -update_tcc_database "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" +update_tcc_database "$user_tcc_database" diff --git a/templates/base.pkr.hcl b/templates/base.pkr.hcl index a715104..20b492e 100644 --- a/templates/base.pkr.hcl +++ b/templates/base.pkr.hcl @@ -155,6 +155,22 @@ build { ] } + // Install the process-scoped Metal shim for opt-in workloads. + provisioner "shell" { + inline = ["mkdir -p ~/tart-metal-capabilities-src"] + } + provisioner "file" { + source = "data/tart-metal-capabilities/" + destination = "~/tart-metal-capabilities-src/" + } + provisioner "shell" { + environment_vars = ["TART_METAL_SOURCE_DIR=/Users/admin/tart-metal-capabilities-src"] + script = "scripts/install-tart-metal-capabilities.sh" + } + provisioner "shell" { + inline = ["rm -rf ~/tart-metal-capabilities-src"] + } + // Guest agent for Tart VMs provisioner "file" { source = "data/tart-guest-daemon.plist"