From 3f3f85034bee0fd988d828827292c301b92a7234 Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Thu, 23 Jul 2026 16:27:11 -0400 Subject: [PATCH 01/10] add entitleyment, seatbelt change --- packages/macos/Tangram/Resources/TangramHelper.entitlements | 2 ++ packages/sandbox/src/seatbelt/spawn.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/macos/Tangram/Resources/TangramHelper.entitlements b/packages/macos/Tangram/Resources/TangramHelper.entitlements index cca812aeea..4ab8083e45 100644 --- a/packages/macos/Tangram/Resources/TangramHelper.entitlements +++ b/packages/macos/Tangram/Resources/TangramHelper.entitlements @@ -2,6 +2,8 @@ + com.apple.security.cs.allow-jit + com.apple.security.inherit diff --git a/packages/sandbox/src/seatbelt/spawn.rs b/packages/sandbox/src/seatbelt/spawn.rs index 9834a50f4b..5df3860172 100644 --- a/packages/sandbox/src/seatbelt/spawn.rs +++ b/packages/sandbox/src/seatbelt/spawn.rs @@ -102,6 +102,7 @@ fn create_sandbox_profile(arg: &crate::Arg) -> tg::Result { (deny default) ;; Allow most system operations. + (allow dynamic-code-generation) (allow syscall*) (allow system-socket) (allow mach*) From 3d7e0addf230b67cf6bb45a83af8d53ab1b7975c Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Thu, 23 Jul 2026 16:51:57 -0400 Subject: [PATCH 02/10] fix(fskit): support executable artifact symlinks --- packages/macos/TangramFSKit/main.swift | 46 ++++++++++++++++++++++++-- packages/server/src/checkin/input.rs | 18 +++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/macos/TangramFSKit/main.swift b/packages/macos/TangramFSKit/main.swift index ae42d02d10..5bdad45f53 100644 --- a/packages/macos/TangramFSKit/main.swift +++ b/packages/macos/TangramFSKit/main.swift @@ -654,6 +654,10 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper reply(nil) return } + if item.handle != nil { + reply(nil) + return + } submit({ response, status in var handle: UInt64 = 0 guard let response, tg_response_open(response, &handle, nil) == 0 else { @@ -668,7 +672,8 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper } func closeItem(_ item: FSItem, modes: FSVolume.OpenModes, replyHandler reply: @escaping (Error?) -> Void) { - if let item = item as? TangramItem, let handle = item.handle { + // FSKit reports the modes that remain open, so release the provider handle only after the final close. + if modes.isEmpty, let item = item as? TangramItem, let handle = item.handle { _ = self.withProvider { tg_provider_close_sync($0, handle) } item.handle = nil } @@ -682,15 +687,50 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper into buffer: FSMutableFileDataBuffer, replyHandler reply: @escaping (Int, Error?) -> Void, ) { - guard let item = item as? TangramItem, let handle = item.handle else { - reply(0, posixError(EBADF)) + guard let item = item as? TangramItem else { + reply(0, posixError(EINVAL)) return } guard offset >= 0 else { reply(0, posixError(EINVAL)) return } + if let handle = item.handle { + read(handle: handle, offset: offset, length: length, into: buffer, closeAfterRead: false, replyHandler: reply) + return + } + // FSKit can request executable pages before it opens the item, so use a temporary handle for that read. submit({ response, status in + var handle: UInt64 = 0 + guard let response, tg_response_open(response, &handle, nil) == 0 else { + reply(0, posixError(status < 0 ? -status : EIO)) + return + } + self.read( + handle: handle, + offset: offset, + length: length, + into: buffer, + closeAfterRead: true, + replyHandler: reply, + ) + }, { callback, context in + self.withProvider { tg_provider_open_async($0, item.nodeID, callback, context) } + }) + } + + private func read( + handle: UInt64, + offset: off_t, + length: Int, + into buffer: FSMutableFileDataBuffer, + closeAfterRead: Bool, + replyHandler reply: @escaping (Int, Error?) -> Void, + ) { + submit({ response, status in + if closeAfterRead { + _ = self.withProvider { tg_provider_close_sync($0, handle) } + } var pointer: UnsafePointer? var count = 0 guard let response, tg_response_bytes(response, &pointer, &count) == 0, let pointer else { diff --git a/packages/server/src/checkin/input.rs b/packages/server/src/checkin/input.rs index 9fdd3e71b5..839a0e84cd 100644 --- a/packages/server/src/checkin/input.rs +++ b/packages/server/src/checkin/input.rs @@ -607,10 +607,20 @@ impl Session { return Ok(()); }; - // If the target is in the cache directory, then treat it as an artifact symlink. - if let Ok(path_in_cache_path) = absolute_target.strip_prefix(self.server.cache_path()) { + // If the target is in the cache or VFS artifacts directory, then treat it as an artifact symlink. + let cache_path = self.server.cache_path(); + let artifacts_path = self.server.artifacts_path(); + let target_in_artifact_path = absolute_target + .strip_prefix(&cache_path) + .map(|path| (&cache_path, path)) + .or_else(|_| { + absolute_target + .strip_prefix(&artifacts_path) + .map(|path| (&artifacts_path, path)) + }); + if let Ok((artifact_path, path_in_artifact_path)) = target_in_artifact_path { // Get the artifact. - let mut components = path_in_cache_path.components(); + let mut components = path_in_artifact_path.components(); let Some(artifact) = components.next().and_then(|component| { if let std::path::Component::Normal(component) = component { component.to_str()?.parse::().ok() @@ -631,7 +641,7 @@ impl Session { // If this is a destructive checkin and the target is absolute, make it relative. if state.arg.options.destructive && target.is_absolute() { - let mut source = self.server.cache_path().join("_"); + let mut source = artifact_path.join("_"); if let Ok(path) = path.strip_prefix(state.root) { source.push(path); } From 2eae6e7e553db403c9dc07271e1163fc36b4f7d4 Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Thu, 23 Jul 2026 17:23:31 -0400 Subject: [PATCH 03/10] fix(fskit): enable the LMDB fast path Name LMDB lock semaphores within the app-group IPC namespace so the sandboxed FSKit extension can open them. Use matching production and test prefixes while respecting macOS semaphore name-length limits. --- packages/cli/test.nu | 16 +++++++++++----- packages/macos/TangramFSKit/main.swift | 9 ++++----- packages/server/src/config.rs | 3 ++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/cli/test.nu b/packages/cli/test.nu index 5268d3799b..5fbda6d6bd 100644 --- a/packages/cli/test.nu +++ b/packages/cli/test.nu @@ -923,12 +923,19 @@ export def --env spawn [ --quickjs # Use QuickJS as the JS engine. --url (-u): string ] { + let use_fskit = (($env.TANGRAM_TEST_FSKIT? | default "") | str length) > 0 + # Give the object store's lock semaphores a unique prefix per server. lmdb # appends 'r' and 'w' to form the two POSIX semaphore names, which are global - # to the machine, so concurrent test servers must not share a prefix. The - # prefix plus one character must fit the platform limit, which is 31 - # characters on macOS. - let object_store_posix_sem_prefix = $'/tg-((random chars) | str lowercase | str substring 0..7)' + # to the machine, so concurrent test servers must not share a prefix. FSKit + # requires the names to use the app group's IPC namespace. The prefix plus + # one character must fit the platform limit of 31 characters on macOS. + let object_store_posix_sem_prefix = if $use_fskit { + let app_group_identifier = (identifiers).app_group_identifier + $'($app_group_identifier)/((random chars) | str lowercase | str substring 0..5)' + } else { + $'/tg-((random chars) | str lowercase | str substring 0..7)' + } mut default_config = { advanced: { @@ -978,7 +985,6 @@ export def --env spawn [ } } - let use_fskit = (($env.TANGRAM_TEST_FSKIT? | default "") | str length) > 0 if $use_fskit { $default_config = $default_config | merge deep { vfs: { diff --git a/packages/macos/TangramFSKit/main.swift b/packages/macos/TangramFSKit/main.swift index 5bdad45f53..0d783f1c9b 100644 --- a/packages/macos/TangramFSKit/main.swift +++ b/packages/macos/TangramFSKit/main.swift @@ -307,11 +307,10 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper let objectStoreMapSize = options["object_store_map_size"].flatMap(UInt64.init) ?? 0 let objectStorePath = options["object_store_path"] ?? "" - // The object store's lock semaphores must be named so that the sandboxed - // extension and the server can both open them. When the server does not - // send a prefix, default to the app group identifier, which the sandbox - // permits both processes to open. LMDB appends an 'r' or 'w' character. - let objectStorePosixSemPrefix = options["object_store_posix_sem_prefix"] ?? appGroupIdentifier + // The object store's lock semaphores must use a service name in the shared + // app group so that the sandboxed extension and the server can both open + // them. LMDB appends an 'r' or 'w' character. + let objectStorePosixSemPrefix = options["object_store_posix_sem_prefix"] ?? "\(appGroupIdentifier)/lmdb" // The client connects to the server over the unix socket the server sends as // a mount option, which lets concurrent servers each serve on their own diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index 5840c7fcb0..7194fddac8 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -1549,13 +1549,14 @@ impl Default for LmdbObjectStore { } impl LmdbObjectStore { - /// Returns the POSIX semaphore prefix, falling back to the macOS app group identifier that the app passes in the environment. This lets the server and the sandboxed file system extension share the same lock by default, because the sandbox permits both processes to open a semaphore named for their shared app group. The server and the extension resolve the same value so the writer and the reader agree. + /// Returns the POSIX semaphore prefix, falling back to a service name in the macOS app group that the app passes in the environment. This lets the server and the sandboxed file system extension share the same lock by default. The server and the extension resolve the same value so the writer and the reader agree. #[must_use] pub fn resolved_posix_sem_prefix(&self) -> Option { self.posix_sem_prefix.clone().or_else(|| { std::env::var("TANGRAM_MACOS_APP_GROUP_IDENTIFIER") .ok() .filter(|value| !value.is_empty()) + .map(|identifier| format!("{identifier}/lmdb")) }) } } From b1931a64cd52dc0b254538665356727857674913 Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Fri, 24 Jul 2026 11:46:31 -0400 Subject: [PATCH 04/10] test(fskit): restart the provider before the suite --- packages/cli/test.nu | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/cli/test.nu b/packages/cli/test.nu index 5fbda6d6bd..32fcac5bdf 100644 --- a/packages/cli/test.nu +++ b/packages/cli/test.nu @@ -114,6 +114,8 @@ def main [ # Build and install the current macOS app and file system extension. Isolate # its default-feature Cargo build from the all-features test binary. if $fskit { + force_unmount_vfs (fskit_temp_root) + stop_fskit_provider $release let build_args = if $release { ['--release'] } else { [] } let cargo_target_dir = ($repository_path | path join 'target/macos') ^bun run macos:build --cargo-target-dir $cargo_target_dir ...$build_args @@ -1680,6 +1682,35 @@ def force_unmount_vfs_macos [path: string] { } } +def stop_fskit_provider [release: bool] { + let app_name = if $release { 'Tangram' } else { 'Tangram Dev' } + let executable = ( + $env.HOME + | path join $'Applications/($app_name).app/Contents/Extensions/TangramFSKit.appex/Contents/MacOS/TangramFSKit' + ) + let pids = fskit_provider_pids $executable + for pid in $pids { + try { kill --quiet $pid } + } + for _ in 1..100 { + if (fskit_provider_pids $executable | is-empty) { + return + } + sleep 50ms + } + for pid in (fskit_provider_pids $executable) { + try { kill --force --quiet $pid } + } +} + +def fskit_provider_pids [executable: path] { + ps --long + | where { |process| + ($process.command? | default '') | str starts-with $executable + } + | get pid +} + def force_unmount_vfs_linux [path: string] { let mounted_artifacts_paths = ( try { From 1440e6bc23e2c157c85af9128b7e250775b8ddea Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Fri, 24 Jul 2026 11:51:50 -0400 Subject: [PATCH 05/10] test(fskit): inspect the VFS backing cache --- packages/cli/test.nu | 9 ++++++++- packages/cli/tests/cache/branch_directory.nu | 2 +- packages/cli/tests/cache/directory.nu | 2 +- .../cli/tests/cache/directory_containing_file_cycle.nu | 6 +++--- .../directory_with_file_that_depends_on_directory.nu | 6 +++--- .../tests/cache/directory_with_file_with_dependency.nu | 2 +- packages/cli/tests/cache/directory_with_symlink.nu | 2 +- packages/cli/tests/cache/directory_with_symlink_cycle.nu | 6 +++--- .../cache/directory_with_symlink_with_dependency.nu | 2 +- .../cache/directory_with_two_entries_in_same_graph.nu | 6 +++--- .../tests/cache/directory_with_two_identical_files.nu | 2 +- packages/cli/tests/cache/executable_file.nu | 2 +- packages/cli/tests/cache/file.nu | 2 +- packages/cli/tests/cache/file_cycle.nu | 6 +++--- packages/cli/tests/cache/file_with_dependency.nu | 2 +- packages/cli/tests/cache/graph_directory.nu | 6 +++--- packages/cli/tests/cache/graph_file.nu | 2 +- packages/cli/tests/cache/graph_symlink.nu | 2 +- packages/cli/tests/cache/reference_to_cycle.nu | 6 +++--- packages/cli/tests/cache/symlink.nu | 2 +- 20 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/cli/test.nu b/packages/cli/test.nu index 32fcac5bdf..054c8d0672 100644 --- a/packages/cli/test.nu +++ b/packages/cli/test.nu @@ -1206,7 +1206,14 @@ export def --env spawn [ rm -rf $path } - { config: $config_path, directory: $directory_path, url: $url } + # Determine the physical cache directory. When a VFS is enabled, the + # artifacts directory is the mount point and the cache directory is its + # backing store. + let vfs = $config | get --optional vfs + let cache_directory_name = if $vfs == null or $vfs == false { 'artifacts' } else { 'cache' } + let cache_directory = $directory_path | path join $cache_directory_name + + { cache_directory: $cache_directory, config: $config_path, directory: $directory_path, url: $url } } def clean_databases [id: string] { diff --git a/packages/cli/tests/cache/branch_directory.nu b/packages/cli/tests/cache/branch_directory.nu index 89c74545cd..0d5d58b888 100644 --- a/packages/cli/tests/cache/branch_directory.nu +++ b/packages/cli/tests/cache/branch_directory.nu @@ -28,4 +28,4 @@ let id = tg checkin $path tg cache $id # Snapshot the artifacts directory. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory.nu b/packages/cli/tests/cache/directory.nu index 366269f730..aa8842ff8e 100644 --- a/packages/cli/tests/cache/directory.nu +++ b/packages/cli/tests/cache/directory.nu @@ -13,4 +13,4 @@ let id = tg put $artifact let output = tg cache $id -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_containing_file_cycle.nu b/packages/cli/tests/cache/directory_containing_file_cycle.nu index eb501e98fb..4a86b65685 100644 --- a/packages/cli/tests/cache/directory_containing_file_cycle.nu +++ b/packages/cli/tests/cache/directory_containing_file_cycle.nu @@ -25,11 +25,11 @@ let path = artifact { '# } let id = tg build --no-cache-pointers $path -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory # Cache. tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_file_that_depends_on_directory.nu b/packages/cli/tests/cache/directory_with_file_that_depends_on_directory.nu index 7e23f967cc..65871115ca 100644 --- a/packages/cli/tests/cache/directory_with_file_that_depends_on_directory.nu +++ b/packages/cli/tests/cache/directory_with_file_that_depends_on_directory.nu @@ -21,10 +21,10 @@ let path = artifact { '# } let id = tg build --no-cache-pointers $path -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory # Cache. tg cache $id -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_file_with_dependency.nu b/packages/cli/tests/cache/directory_with_file_with_dependency.nu index 04ca6ca27b..516bc97722 100644 --- a/packages/cli/tests/cache/directory_with_file_with_dependency.nu +++ b/packages/cli/tests/cache/directory_with_file_with_dependency.nu @@ -23,4 +23,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_symlink.nu b/packages/cli/tests/cache/directory_with_symlink.nu index 9c3bf22888..a14890c36c 100644 --- a/packages/cli/tests/cache/directory_with_symlink.nu +++ b/packages/cli/tests/cache/directory_with_symlink.nu @@ -19,4 +19,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_symlink_cycle.nu b/packages/cli/tests/cache/directory_with_symlink_cycle.nu index f68217bbb8..a103a47ffd 100644 --- a/packages/cli/tests/cache/directory_with_symlink_cycle.nu +++ b/packages/cli/tests/cache/directory_with_symlink_cycle.nu @@ -33,11 +33,11 @@ let artifact = artifact { } let id = tg checkin --no-cache-pointers $artifact let id = tg build $id -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory # Cache. tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_symlink_with_dependency.nu b/packages/cli/tests/cache/directory_with_symlink_with_dependency.nu index b436fc6267..3569152adb 100644 --- a/packages/cli/tests/cache/directory_with_symlink_with_dependency.nu +++ b/packages/cli/tests/cache/directory_with_symlink_with_dependency.nu @@ -18,4 +18,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_two_entries_in_same_graph.nu b/packages/cli/tests/cache/directory_with_two_entries_in_same_graph.nu index 467a607673..85c55fd27f 100644 --- a/packages/cli/tests/cache/directory_with_two_entries_in_same_graph.nu +++ b/packages/cli/tests/cache/directory_with_two_entries_in_same_graph.nu @@ -32,9 +32,9 @@ let path = artifact { '# } let id = tg build --no-cache-pointers $path -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory tg cache $id | complete -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/directory_with_two_identical_files.nu b/packages/cli/tests/cache/directory_with_two_identical_files.nu index 22870701bb..f6acb601f6 100644 --- a/packages/cli/tests/cache/directory_with_two_identical_files.nu +++ b/packages/cli/tests/cache/directory_with_two_identical_files.nu @@ -17,4 +17,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/executable_file.nu b/packages/cli/tests/cache/executable_file.nu index c28a53d512..f2f4471b76 100644 --- a/packages/cli/tests/cache/executable_file.nu +++ b/packages/cli/tests/cache/executable_file.nu @@ -17,4 +17,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/file.nu b/packages/cli/tests/cache/file.nu index b233029ff0..3c6fc55f46 100644 --- a/packages/cli/tests/cache/file.nu +++ b/packages/cli/tests/cache/file.nu @@ -14,4 +14,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/file_cycle.nu b/packages/cli/tests/cache/file_cycle.nu index f17b4734bf..9b95216073 100644 --- a/packages/cli/tests/cache/file_cycle.nu +++ b/packages/cli/tests/cache/file_cycle.nu @@ -21,11 +21,11 @@ let path = artifact { '# } let id = tg build --no-cache-pointers $path -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory # Cache. let output = tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/file_with_dependency.nu b/packages/cli/tests/cache/file_with_dependency.nu index f28833a898..ccd18f0291 100644 --- a/packages/cli/tests/cache/file_with_dependency.nu +++ b/packages/cli/tests/cache/file_with_dependency.nu @@ -21,4 +21,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/graph_directory.nu b/packages/cli/tests/cache/graph_directory.nu index 691ff0cada..9fd213c056 100644 --- a/packages/cli/tests/cache/graph_directory.nu +++ b/packages/cli/tests/cache/graph_directory.nu @@ -28,11 +28,11 @@ let artifact = artifact { } let id = tg checkin --no-cache-pointers $artifact let id = tg build $id -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory # Cache. tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/graph_file.nu b/packages/cli/tests/cache/graph_file.nu index 26d5710403..bbdec4a1b2 100644 --- a/packages/cli/tests/cache/graph_file.nu +++ b/packages/cli/tests/cache/graph_file.nu @@ -25,4 +25,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/graph_symlink.nu b/packages/cli/tests/cache/graph_symlink.nu index 60dbed937e..d1529dbc93 100644 --- a/packages/cli/tests/cache/graph_symlink.nu +++ b/packages/cli/tests/cache/graph_symlink.nu @@ -25,7 +25,7 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") ' +snapshot --path $server.cache_directory ' { "kind": "directory", "entries": { diff --git a/packages/cli/tests/cache/reference_to_cycle.nu b/packages/cli/tests/cache/reference_to_cycle.nu index 7189fb392c..a36a256f84 100644 --- a/packages/cli/tests/cache/reference_to_cycle.nu +++ b/packages/cli/tests/cache/reference_to_cycle.nu @@ -21,9 +21,9 @@ let artifact = artifact { ' } let id = tg build --no-cache-pointers $artifact -rm --recursive --force ($server.directory | path join "artifacts") -mkdir ($server.directory | path join "artifacts") +rm --recursive --force $server.cache_directory +mkdir $server.cache_directory let output = tg cache $id -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory diff --git a/packages/cli/tests/cache/symlink.nu b/packages/cli/tests/cache/symlink.nu index 51f08baf32..82af8463de 100644 --- a/packages/cli/tests/cache/symlink.nu +++ b/packages/cli/tests/cache/symlink.nu @@ -16,4 +16,4 @@ let id = tg put $artifact tg cache $id # Snapshot. -snapshot --path ($server.directory | path join "artifacts") +snapshot --path $server.cache_directory From b0e0b0e758f082434dd8bb883e52d2e18e505141 Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Fri, 24 Jul 2026 12:04:09 -0400 Subject: [PATCH 06/10] fix(server): read authorized remote blobs --- packages/server/src/read.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/server/src/read.rs b/packages/server/src/read.rs index d7f6a29b22..2fb82ae2eb 100644 --- a/packages/server/src/read.rs +++ b/packages/server/src/read.rs @@ -246,21 +246,25 @@ impl Reader { tg::object::Id::from(id.clone()), blob.state().token(), ); - session + let authorized = session .authorize(resource, permission) .await? - .filter(|permissions| permissions.contains(permission)) - .ok_or_else(|| tg::error!(%id, "failed to get the object"))?; - let arg = crate::object::store::TryGetArg { - id: id.clone().into(), + .is_some_and(|permissions| permissions.contains(permission)); + let cache_pointer = if authorized { + let arg = crate::object::store::TryGetArg { + id: id.clone().into(), + }; + session + .server + .object_store + .try_get(arg) + .await + .map_err(|error| tg::error!(!error, %id, "failed to get the object"))? + .object + .and_then(|object| object.cache_pointer) + } else { + None }; - let object = session - .server - .object_store - .try_get(arg) - .await - .map_err(|error| tg::error!(!error, %id, "failed to get the object"))?; - let cache_pointer = object.object.and_then(|object| object.cache_pointer); let reader = if let Some(cache_pointer) = cache_pointer { let mut path = session .server From 0c59fd9afc5b4270cc68700a6fa592e4fb4a4d1f Mon Sep 17 00:00:00 2001 From: mikedorf Date: Fri, 24 Jul 2026 11:15:21 -0500 Subject: [PATCH 07/10] fixes to run tangram directly from the .app --- Cargo.lock | 1 + packages/cli/Cargo.toml | 1 + .../Resources/TangramHelper.entitlements | 2 ++ packages/server/src/config.rs | 23 ++++++++++++++++++- packages/server/src/lib.rs | 15 ++++++++---- packages/server/src/vfs/fskit.rs | 16 ++++++++----- 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc5c944359..33e95c453b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7766,6 +7766,7 @@ dependencies = [ "indoc", "itertools 0.15.0", "libc", + "liblzma", "maml", "miette", "nu-ansi-term", diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 55b5faf47e..4d13b8c376 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -61,6 +61,7 @@ opentelemetry_sdk = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } libc = { workspace = true } +liblzma = { workspace = true } maml = { workspace = true } miette = { workspace = true } num = { workspace = true } diff --git a/packages/macos/Tangram/Resources/TangramHelper.entitlements b/packages/macos/Tangram/Resources/TangramHelper.entitlements index 4ab8083e45..6cdb50709a 100644 --- a/packages/macos/Tangram/Resources/TangramHelper.entitlements +++ b/packages/macos/Tangram/Resources/TangramHelper.entitlements @@ -4,6 +4,8 @@ com.apple.security.cs.allow-jit + com.apple.security.cs.disable-library-validation + com.apple.security.inherit diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index 7194fddac8..63aca2e113 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -1160,9 +1160,12 @@ pub struct SyncPutStore { pub process_concurrency: usize, } -#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub struct Vfs { + /// The macos app group identifier the fskit file system extension shares with the server, used to locate the group container's socket when the app does not provide it in the environment. + pub app_group_identifier: Option, + pub kind: VfsKind, pub io: VfsIo, @@ -1784,6 +1787,7 @@ impl Default for SyncPutStore { impl Default for Vfs { fn default() -> Self { Self { + app_group_identifier: None, kind: VfsKind::Auto, io: VfsIo::Auto, passthrough: VfsPassthrough::Auto, @@ -1791,6 +1795,23 @@ impl Default for Vfs { } } +impl Vfs { + /// Resolves the macos app group socket path, preferring the path the app passes in the environment and falling back to the socket in the configured app group's container. This lets a standalone server share the socket with the sandboxed file system extension without being launched by the app. + #[must_use] + pub fn resolved_app_group_socket(&self) -> Option { + if let Some(path) = std::env::var_os("TANGRAM_MACOS_APP_GROUP_SOCKET") { + return Some(PathBuf::from(path)); + } + let identifier = self.app_group_identifier.as_ref()?; + let home = std::env::var_os("HOME")?; + let socket = PathBuf::from(home) + .join("Library/Group Containers") + .join(identifier) + .join("socket"); + Some(socket) + } +} + impl Default for Watch { fn default() -> Self { Self { diff --git a/packages/server/src/lib.rs b/packages/server/src/lib.rs index 9be3701ca6..a73b0f9b03 100644 --- a/packages/server/src/lib.rs +++ b/packages/server/src/lib.rs @@ -866,7 +866,7 @@ impl Server { // Start the VFS if enabled. let artifacts_path = server.artifacts_path(); let cache_path = server.path.join("cache"); - let vfs_kind = match server.config.vfs.unwrap_or_default().kind { + let vfs_kind = match server.config.vfs.clone().unwrap_or_default().kind { config::VfsKind::Auto => { if cfg!(target_os = "macos") && std::env::var_os("TANGRAM_MACOS_APP_SOCKET").is_some() @@ -897,7 +897,7 @@ impl Server { let cache_exists = tokio::fs::try_exists(&cache_path) .await .map_err(|error| tg::error!(!error, "failed to stat the path"))?; - if let Some(options) = server.config.vfs { + if let Some(options) = server.config.vfs.clone() { if artifacts_exists && !cache_exists { tokio::fs::rename(&artifacts_path, &cache_path) .await @@ -950,9 +950,14 @@ impl Server { } else { config.listeners.clone() }; - // On macOS, also listen on the socket in the shared app group - // container so the sandboxed file system extension can connect. - if let Some(path) = std::env::var_os("TANGRAM_MACOS_APP_GROUP_SOCKET") { + // On macOS, also listen on the socket in the shared app group container so the sandboxed file system extension can connect. The path comes from the environment when the app launches the server, or from the configured app group identifier otherwise. + let group_socket = server + .config + .vfs + .clone() + .unwrap_or_default() + .resolved_app_group_socket(); + if let Some(path) = group_socket { let url = Uri::builder() .scheme("http+unix") .authority(path.to_str().unwrap()) diff --git a/packages/server/src/vfs/fskit.rs b/packages/server/src/vfs/fskit.rs index 60c8d26957..e32fe594e1 100644 --- a/packages/server/src/vfs/fskit.rs +++ b/packages/server/src/vfs/fskit.rs @@ -70,7 +70,7 @@ impl Server { let token = std::env::var("TANGRAM_MACOS_APP_TOKEN").map_err(|error| { tg::error!(!error, "missing the macos app token environment variable") })?; - let group_socket = Self::group_socket()?; + let group_socket = Self::group_socket(server)?; // Create the request. let object_store = Self::object_store(server); @@ -122,7 +122,7 @@ impl Server { } async fn start_with_mount(server: &crate::Server, path: &Path) -> tg::Result { - let group_socket = Self::group_socket()?; + let group_socket = Self::group_socket(server)?; let object_store = Self::object_store(server); let options = Self::options(object_store.as_ref(), &group_socket); let output = tokio::process::Command::new("/sbin/mount") @@ -165,10 +165,14 @@ impl Server { Ok(()) } - fn group_socket() -> tg::Result { - let group_socket = std::env::var_os("TANGRAM_MACOS_APP_GROUP_SOCKET") - .ok_or_else(|| tg::error!("missing the macos app group socket environment variable"))?; - Ok(PathBuf::from(group_socket)) + fn group_socket(server: &crate::Server) -> tg::Result { + let vfs = server.config.vfs.clone().unwrap_or_default(); + let group_socket = vfs.resolved_app_group_socket().ok_or_else(|| { + tg::error!( + "missing the macos app group socket environment variable and the vfs app group identifier is not configured" + ) + })?; + Ok(group_socket) } fn object_store(server: &crate::Server) -> Option { From 84694f2408f948208916481f9307a6c777c3d66b Mon Sep 17 00:00:00 2001 From: mikedorf Date: Fri, 24 Jul 2026 11:22:01 -0500 Subject: [PATCH 08/10] add macos:install command --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 42af40b5e9..d09d80cfb2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "macos:build": "nu scripts/macos/build.nu", "macos:check": "nu scripts/macos/check.nu", "macos:doctor": "nu scripts/macos/doctor.nu", + "macos:install": "nu scripts/macos/install.nu", "macos:release": "nu scripts/macos/release.nu", "macos:run": "nu scripts/macos/run.nu" }, From b18fcff0ac6cbb9ce1e73d848e03399d62a109aa Mon Sep 17 00:00:00 2001 From: mikedorf Date: Fri, 24 Jul 2026 11:24:53 -0500 Subject: [PATCH 09/10] attempt to unmount before mounting to avoid EBUSY --- packages/server/src/vfs/fskit.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/src/vfs/fskit.rs b/packages/server/src/vfs/fskit.rs index e32fe594e1..cc7ed4fcee 100644 --- a/packages/server/src/vfs/fskit.rs +++ b/packages/server/src/vfs/fskit.rs @@ -55,6 +55,8 @@ struct Response { impl Server { pub async fn start(server: &crate::Server, path: &Path) -> tg::Result { + Self::unmount(path).await.ok(); + // Ask the app to mount if the app started this server, because only the app can prompt for the file system extension's approval. Otherwise mount directly, which is the path the test harness takes. if std::env::var_os("TANGRAM_MACOS_APP_SOCKET").is_some() { Self::start_with_app(server, path).await From d2dc7e73e6ba1f5e61f709c2342e27cd4bf3510f Mon Sep 17 00:00:00 2001 From: Ben Lovy Date: Fri, 24 Jul 2026 12:27:17 -0400 Subject: [PATCH 10/10] style: trim fskit comments --- packages/cli/test.nu | 9 +-------- packages/macos/TangramFSKit/main.swift | 7 ++----- packages/server/src/checkin/input.rs | 2 +- packages/server/src/config.rs | 6 +++--- packages/server/src/lib.rs | 2 +- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/cli/test.nu b/packages/cli/test.nu index 054c8d0672..6d2d35ab33 100644 --- a/packages/cli/test.nu +++ b/packages/cli/test.nu @@ -927,11 +927,7 @@ export def --env spawn [ ] { let use_fskit = (($env.TANGRAM_TEST_FSKIT? | default "") | str length) > 0 - # Give the object store's lock semaphores a unique prefix per server. lmdb - # appends 'r' and 'w' to form the two POSIX semaphore names, which are global - # to the machine, so concurrent test servers must not share a prefix. FSKit - # requires the names to use the app group's IPC namespace. The prefix plus - # one character must fit the platform limit of 31 characters on macOS. + # Use unique semaphore names in the namespace FSKit can access. let object_store_posix_sem_prefix = if $use_fskit { let app_group_identifier = (identifiers).app_group_identifier $'($app_group_identifier)/((random chars) | str lowercase | str substring 0..5)' @@ -1206,9 +1202,6 @@ export def --env spawn [ rm -rf $path } - # Determine the physical cache directory. When a VFS is enabled, the - # artifacts directory is the mount point and the cache directory is its - # backing store. let vfs = $config | get --optional vfs let cache_directory_name = if $vfs == null or $vfs == false { 'artifacts' } else { 'cache' } let cache_directory = $directory_path | path join $cache_directory_name diff --git a/packages/macos/TangramFSKit/main.swift b/packages/macos/TangramFSKit/main.swift index 0d783f1c9b..a12afc41a3 100644 --- a/packages/macos/TangramFSKit/main.swift +++ b/packages/macos/TangramFSKit/main.swift @@ -307,9 +307,7 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper let objectStoreMapSize = options["object_store_map_size"].flatMap(UInt64.init) ?? 0 let objectStorePath = options["object_store_path"] ?? "" - // The object store's lock semaphores must use a service name in the shared - // app group so that the sandboxed extension and the server can both open - // them. LMDB appends an 'r' or 'w' character. + // Share the LMDB lock through the app group. let objectStorePosixSemPrefix = options["object_store_posix_sem_prefix"] ?? "\(appGroupIdentifier)/lmdb" // The client connects to the server over the unix socket the server sends as @@ -671,7 +669,6 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper } func closeItem(_ item: FSItem, modes: FSVolume.OpenModes, replyHandler reply: @escaping (Error?) -> Void) { - // FSKit reports the modes that remain open, so release the provider handle only after the final close. if modes.isEmpty, let item = item as? TangramItem, let handle = item.handle { _ = self.withProvider { tg_provider_close_sync($0, handle) } item.handle = nil @@ -698,7 +695,7 @@ final class TangramVolume: FSVolume, FSVolume.Operations, FSVolume.OpenCloseOper read(handle: handle, offset: offset, length: length, into: buffer, closeAfterRead: false, replyHandler: reply) return } - // FSKit can request executable pages before it opens the item, so use a temporary handle for that read. + // FSKit can read an executable before opening it. submit({ response, status in var handle: UInt64 = 0 guard let response, tg_response_open(response, &handle, nil) == 0 else { diff --git a/packages/server/src/checkin/input.rs b/packages/server/src/checkin/input.rs index 839a0e84cd..ab771dbfb6 100644 --- a/packages/server/src/checkin/input.rs +++ b/packages/server/src/checkin/input.rs @@ -607,7 +607,7 @@ impl Session { return Ok(()); }; - // If the target is in the cache or VFS artifacts directory, then treat it as an artifact symlink. + // Check for an artifact symlink. let cache_path = self.server.cache_path(); let artifacts_path = self.server.artifacts_path(); let target_in_artifact_path = absolute_target diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index 63aca2e113..be32272c12 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -1163,7 +1163,7 @@ pub struct SyncPutStore { #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub struct Vfs { - /// The macos app group identifier the fskit file system extension shares with the server, used to locate the group container's socket when the app does not provide it in the environment. + /// The macOS app group identifier. pub app_group_identifier: Option, pub kind: VfsKind, @@ -1552,7 +1552,7 @@ impl Default for LmdbObjectStore { } impl LmdbObjectStore { - /// Returns the POSIX semaphore prefix, falling back to a service name in the macOS app group that the app passes in the environment. This lets the server and the sandboxed file system extension share the same lock by default. The server and the extension resolve the same value so the writer and the reader agree. + /// Returns the configured POSIX semaphore prefix or the app group default. #[must_use] pub fn resolved_posix_sem_prefix(&self) -> Option { self.posix_sem_prefix.clone().or_else(|| { @@ -1796,7 +1796,7 @@ impl Default for Vfs { } impl Vfs { - /// Resolves the macos app group socket path, preferring the path the app passes in the environment and falling back to the socket in the configured app group's container. This lets a standalone server share the socket with the sandboxed file system extension without being launched by the app. + /// Resolves the macOS app group socket path. #[must_use] pub fn resolved_app_group_socket(&self) -> Option { if let Some(path) = std::env::var_os("TANGRAM_MACOS_APP_GROUP_SOCKET") { diff --git a/packages/server/src/lib.rs b/packages/server/src/lib.rs index a73b0f9b03..2d9800c6cc 100644 --- a/packages/server/src/lib.rs +++ b/packages/server/src/lib.rs @@ -950,7 +950,7 @@ impl Server { } else { config.listeners.clone() }; - // On macOS, also listen on the socket in the shared app group container so the sandboxed file system extension can connect. The path comes from the environment when the app launches the server, or from the configured app group identifier otherwise. + // Add the socket shared with the macOS extension. let group_socket = server .config .vfs