From e93634172359e772edc774a6bdc4b607b96d03c1 Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Wed, 1 Apr 2026 18:10:16 +0300 Subject: [PATCH 1/5] Fix Spiderweb terminal routing for native app --- .../macos/SpiderwebFSKitApp/ContentView.swift | 12 +- .../SpiderAppWorkflowStore.swift | 183 ++++++++++++++++++ .../SpiderwebAppController.swift | 42 +++- .../package-spiderweb-macos-release.sh | 90 ++++++++- .../macos/scripts/quickstart-regression.sh | 2 + src/acheron/namespace_client.zig | 2 +- src/acheron/session.zig | 166 ++++++++++++++-- src/acheron/session_bound_venoms.zig | 89 ++++++--- src/acheron/session_catalog_bindings.zig | 9 +- src/acheron/session_node_venoms.zig | 20 ++ src/runtime/terminal_namespace.zig | 12 +- 11 files changed, 554 insertions(+), 73 deletions(-) create mode 100644 platform/macos/SpiderwebFSKitApp/SpiderAppWorkflowStore.swift diff --git a/platform/macos/SpiderwebFSKitApp/ContentView.swift b/platform/macos/SpiderwebFSKitApp/ContentView.swift index 82764fe5..a2f8d052 100644 --- a/platform/macos/SpiderwebFSKitApp/ContentView.swift +++ b/platform/macos/SpiderwebFSKitApp/ContentView.swift @@ -1225,10 +1225,13 @@ private enum SpiderwebRecipe: String, Identifiable { case .mountExistingSpiderweb: return controller.savedMounts.contains(where: { $0.kind == .remote }) ? .done : .guide case .shareThisSpiderweb: + if controller.hasCompletedAnySpiderAppWorkflow(.addSecondDevice) { + return .done + } if controller.serviceStatus?.loaded == true, controller.authStatus?.accessPresent == true, controller.serviceStatus?.remoteReachable != false { - return .done + return .ready } if controller.serviceStatus?.loaded == true, controller.authStatus?.accessPresent == true { return .ready @@ -1243,6 +1246,9 @@ private enum SpiderwebRecipe: String, Identifiable { } return .guide case .setupUsefulWorkspace: + if controller.hasCompletedAnySpiderAppWorkflow(.startLocalWorkspace) { + return .done + } if !controller.mountableLocalWorkspaces.isEmpty { return .done } @@ -1251,6 +1257,10 @@ private enum SpiderwebRecipe: String, Identifiable { } return .guide case .packagesAndServices: + if controller.hasCompletedAnySpiderAppWorkflow(.installPackage) || + controller.hasCompletedAnySpiderAppWorkflow(.runRemoteService) { + return .done + } if controller.quickstartState?.isComplete == true, controller.quickstartCanOpenSpiderApp { return .done } diff --git a/platform/macos/SpiderwebFSKitApp/SpiderAppWorkflowStore.swift b/platform/macos/SpiderwebFSKitApp/SpiderAppWorkflowStore.swift new file mode 100644 index 00000000..98e77e9a --- /dev/null +++ b/platform/macos/SpiderwebFSKitApp/SpiderAppWorkflowStore.swift @@ -0,0 +1,183 @@ +import Foundation + +enum SpiderAppWorkflowID: String { + case startLocalWorkspace = "start_local_workspace" + case addSecondDevice = "add_second_device" + case installPackage = "install_package" + case runRemoteService = "run_remote_service" + case connectToAnotherSpiderweb = "connect_to_another_spiderweb" + case spiderwebHandoffCompleted = "spiderweb_handoff_completed" +} + +enum SpiderAppDeepLinkRoute: String { + case workspace + case devices + case capabilities + case explore + case settings +} + +enum SpiderAppDeepLinkAction: String { + case openWorkspace = "open_workspace" + case openDevices = "open_devices" + case openCapabilities = "open_capabilities" + case openExplore = "open_explore" + case openSettings = "open_settings" +} + +private struct SpiderAppWorkflowEntry { + var profileID: String + var workspaceID: String? + var workflowID: String + var completedAtMS: Int64 +} + +enum SpiderAppWorkflowStore { + static let defaultProfileID = "default" + + static func hasCompletion(_ workflowID: SpiderAppWorkflowID, profileID: String = defaultProfileID, workspaceID: String? = nil) -> Bool { + loadEntries().contains { entry in + guard entry.profileID == profileID else { return false } + guard entry.workflowID == workflowID.rawValue else { return false } + return normalize(entry.workspaceID) == normalize(workspaceID) + } + } + + static func hasAnyCompletion(_ workflowID: SpiderAppWorkflowID, profileID: String = defaultProfileID) -> Bool { + loadEntries().contains { entry in + entry.profileID == profileID && entry.workflowID == workflowID.rawValue + } + } + + static func markCompleted(_ workflowID: SpiderAppWorkflowID, profileID: String = defaultProfileID, workspaceID: String? = nil) { + updateRootObject { rootObject in + var entries = parseEntries(from: rootObject) + let normalizedWorkspaceID = normalize(workspaceID) + let nowMS = Int64(Date().timeIntervalSince1970 * 1000) + + if let index = entries.firstIndex(where: { + $0.profileID == profileID && + $0.workflowID == workflowID.rawValue && + normalize($0.workspaceID) == normalizedWorkspaceID + }) { + entries[index].completedAtMS = nowMS + } else { + entries.append( + SpiderAppWorkflowEntry( + profileID: profileID, + workspaceID: normalizedWorkspaceID, + workflowID: workflowID.rawValue, + completedAtMS: nowMS + ) + ) + } + + rootObject["onboarding_workflows"] = entries.map { entry in + var dict: [String: Any] = [ + "profile_id": entry.profileID, + "workflow_id": entry.workflowID, + "completed_at_ms": entry.completedAtMS + ] + if let workspaceID = normalize(entry.workspaceID) { + dict["workspace_id"] = workspaceID + } + return dict + } + } + } + + static func deepLinkURL( + profileID: String = defaultProfileID, + workspaceID: String? = nil, + route: SpiderAppDeepLinkRoute, + action: SpiderAppDeepLinkAction? = nil, + mountpoint: String? = nil, + degraded: Bool = false, + handoff: Bool = false + ) -> URL? { + var components = URLComponents() + components.scheme = "spiderapp" + components.host = "open" + + var queryItems = [URLQueryItem(name: "profile_id", value: profileID)] + if let workspaceID = normalize(workspaceID) { + queryItems.append(URLQueryItem(name: "workspace_id", value: workspaceID)) + } + queryItems.append(URLQueryItem(name: "route", value: route.rawValue)) + if let action { + queryItems.append(URLQueryItem(name: "action", value: action.rawValue)) + } + if let mountpoint = normalize(mountpoint) { + queryItems.append(URLQueryItem(name: "mountpoint", value: mountpoint)) + } + if degraded { + queryItems.append(URLQueryItem(name: "degraded", value: "1")) + } + if handoff { + queryItems.append(URLQueryItem(name: "handoff", value: "1")) + } + components.queryItems = queryItems + return components.url + } + + private static func configURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/spider", isDirectory: true) + .appendingPathComponent("config.json") + } + + private static func loadEntries() -> [SpiderAppWorkflowEntry] { + guard let data = try? Data(contentsOf: configURL()), + let rootObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return [] + } + return parseEntries(from: rootObject) + } + + private static func parseEntries(from rootObject: [String: Any]) -> [SpiderAppWorkflowEntry] { + guard let rawEntries = rootObject["onboarding_workflows"] as? [[String: Any]] else { return [] } + return rawEntries.compactMap { raw in + guard let profileID = normalize(raw["profile_id"] as? String), + let workflowID = normalize(raw["workflow_id"] as? String) else { + return nil + } + let completedAtMS = (raw["completed_at_ms"] as? NSNumber)?.int64Value ?? 0 + return SpiderAppWorkflowEntry( + profileID: profileID, + workspaceID: normalize(raw["workspace_id"] as? String), + workflowID: workflowID, + completedAtMS: completedAtMS + ) + } + } + + private static func updateRootObject(_ mutate: (inout [String: Any]) -> Void) { + let url = configURL() + let configDirectory = url.deletingLastPathComponent() + + var rootObject: [String: Any] = [:] + if let data = try? Data(contentsOf: url), + let loaded = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] { + rootObject = loaded + } + if rootObject["schema_version"] == nil { + rootObject["schema_version"] = 2 + } + + mutate(&rootObject) + + do { + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: rootObject, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: .atomic) + } catch { + NSLog("SpiderAppWorkflowStore save failed: %@", error.localizedDescription) + } + } + + private static func normalize(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift index 5c4bc4fc..bc16555f 100644 --- a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift +++ b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift @@ -516,12 +516,36 @@ final class SpiderwebAppController: ObservableObject { Self.findSpiderAppApplicationURL() != nil || Self.findSpiderAppExecutablePath() != nil } + func hasCompletedSpiderAppWorkflow(_ workflowID: SpiderAppWorkflowID, workspaceID: String? = nil) -> Bool { + SpiderAppWorkflowStore.hasCompletion(workflowID, workspaceID: workspaceID) + } + + func hasCompletedAnySpiderAppWorkflow(_ workflowID: SpiderAppWorkflowID) -> Bool { + SpiderAppWorkflowStore.hasAnyCompletion(workflowID) + } + + private func preferredSpiderAppLaunchURL() -> URL? { + let workspaceID = quickstartState?.result?.workspaceID ?? quickstartState?.workspaceID + let mountpoint = quickstartState?.result?.mountpoint ?? quickstartState?.mountpoint + let degraded = quickstartState?.result?.driveAvailable == false || quickstartState?.result?.driveIssueSummary != nil + let handoff = quickstartState?.isComplete == true + + return SpiderAppWorkflowStore.deepLinkURL( + workspaceID: workspaceID, + route: .workspace, + action: nil, + mountpoint: mountpoint, + degraded: degraded, + handoff: handoff + ) + } + var quickstartNextStepDetail: String { if let driveIssueSummary = quickstartState?.result?.driveIssueSummary, !driveIssueSummary.isEmpty { - return "Open SpiderApp to keep working while the drive mount is blocked, then retry the drive from Spiderweb after macOS clears the stuck FSKit state." + return "Open SpiderApp to keep working while the drive mount is blocked, then use Remote Terminal or the workspace shell until macOS clears the stuck FSKit state." } if quickstartCanOpenSpiderApp { - return "Open SpiderApp’s workspace shell and keep Devices, Capabilities, Explore, and Settings close by." + return "Open SpiderApp’s native shell, then jump into Remote Terminal, Devices, Capabilities, Explore, or Settings from a cleaner Mac-first starting point." } return "SpiderApp is not available on this Mac yet, so the mounted drive remains the fastest next step." } @@ -1606,6 +1630,7 @@ final class SpiderwebAppController: ObservableObject { return next } let completedState = quickstart + SpiderAppWorkflowStore.markCompleted(.startLocalWorkspace, workspaceID: workspace.summary.id) await MainActor.run { self.savedMounts = mergedMounts @@ -1655,8 +1680,18 @@ final class SpiderwebAppController: ObservableObject { func openSpiderApp() { lastError = nil + statusMessage = "Opening SpiderApp..." + + if let deepLinkURL = preferredSpiderAppLaunchURL(), + NSWorkspace.shared.open(deepLinkURL) { + if quickstartState?.isComplete == true { + SpiderAppWorkflowStore.markCompleted(.spiderwebHandoffCompleted) + } + statusMessage = "Opened SpiderApp" + return + } + if let appURL = Self.findSpiderAppApplicationURL() { - statusMessage = "Opening SpiderApp..." let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true let applyOpenResult: @MainActor (String?) -> Void = { [weak self] errorDescription in @@ -1677,7 +1712,6 @@ final class SpiderwebAppController: ObservableObject { } if let executablePath = Self.findSpiderAppExecutablePath() { - statusMessage = "Opening SpiderApp..." Task.detached(priority: .userInitiated) { do { try Self.launchDetachedProcess(executableURL: URL(fileURLWithPath: executablePath), arguments: []) diff --git a/platform/macos/scripts/package-spiderweb-macos-release.sh b/platform/macos/scripts/package-spiderweb-macos-release.sh index ce21eb02..b100ed19 100755 --- a/platform/macos/scripts/package-spiderweb-macos-release.sh +++ b/platform/macos/scripts/package-spiderweb-macos-release.sh @@ -189,13 +189,85 @@ profile_field() { rm -f "$plist_path" } +profile_bundle_id() { + local profile_path="$1" + local app_identifier + app_identifier="$(profile_field "$profile_path" 'Entitlements:com.apple.application-identifier' 2>/dev/null || true)" + app_identifier="${app_identifier#*.}" + printf '%s' "$app_identifier" +} + +profile_name_lower() { + local profile_path="$1" + profile_field "$profile_path" Name 2>/dev/null | tr '[:upper:]' '[:lower:]' +} + +find_installed_profile_for_bundle_id() { + local bundle_id="$1" + local profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + local profile_path + local best_path="" + local fallback_path="" + + [[ -d "$profiles_dir" ]] || return 1 + + while IFS= read -r profile_path; do + [[ -n "$profile_path" ]] || continue + if [[ "$(profile_bundle_id "$profile_path")" != "$bundle_id" ]]; then + continue + fi + + if [[ -z "$fallback_path" ]]; then + fallback_path="$profile_path" + fi + + local lowered_name + lowered_name="$(profile_name_lower "$profile_path")" + if [[ "$lowered_name" == *devid* || "$lowered_name" == *"developer id"* ]]; then + best_path="$profile_path" + break + fi + done < <(find "$profiles_dir" -maxdepth 1 -name '*.provisionprofile' | sort) + + if [[ -n "$best_path" ]]; then + printf '%s\n' "$best_path" + return 0 + fi + if [[ -n "$fallback_path" ]]; then + printf '%s\n' "$fallback_path" + return 0 + fi + return 1 +} + +resolve_profile_path() { + local explicit_path="${1:-}" + local bundle_id="$2" + local env_name="$3" + + if [[ -n "$explicit_path" ]]; then + printf '%s\n' "$explicit_path" + return 0 + fi + + if resolved="$(find_installed_profile_for_bundle_id "$bundle_id")"; then + printf '%s\n' "$resolved" + return 0 + fi + + fail "no provisioning profile found for ${bundle_id}; set ${env_name} explicitly if auto-discovery is insufficient" +} + install_profile() { local profile_path="$1" local uuid uuid="$(profile_field "$profile_path" UUID)" local dest_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + local dest_path="$dest_dir/$uuid.provisionprofile" mkdir -p "$dest_dir" - cp "$profile_path" "$dest_dir/$uuid.provisionprofile" + if [[ "$profile_path" != "$dest_path" ]]; then + cp "$profile_path" "$dest_path" + fi profile_field "$profile_path" Name } @@ -368,12 +440,18 @@ fi if [[ -z "${SPIDERWEB_MACOS_APP_PROFILE:-}" && -n "${SPIDERWEB_MACOS_EXTENSION_PROFILE:-}" ]]; then fail "SPIDERWEB_MACOS_APP_PROFILE is required when SPIDERWEB_MACOS_EXTENSION_PROFILE is set" fi -if [[ -n "${SPIDERWEB_MACOS_APP_PROFILE:-}" ]]; then - app_profile_name="$(install_profile "$SPIDERWEB_MACOS_APP_PROFILE")" -fi -if [[ -n "${SPIDERWEB_MACOS_EXTENSION_PROFILE:-}" ]]; then - extension_profile_name="$(install_profile "$SPIDERWEB_MACOS_EXTENSION_PROFILE")" +if [[ -n "${SPIDERWEB_MACOS_APP_PROFILE:-}" || -n "${SPIDERWEB_MACOS_EXTENSION_PROFILE:-}" ]]; then + app_profile_path="$(resolve_profile_path "${SPIDERWEB_MACOS_APP_PROFILE:-}" "$APP_BUNDLE_ID" SPIDERWEB_MACOS_APP_PROFILE)" + extension_profile_path="$(resolve_profile_path "${SPIDERWEB_MACOS_EXTENSION_PROFILE:-}" "$EXTENSION_BUNDLE_ID" SPIDERWEB_MACOS_EXTENSION_PROFILE)" +else + app_profile_path="$(resolve_profile_path "" "$APP_BUNDLE_ID" SPIDERWEB_MACOS_APP_PROFILE)" + extension_profile_path="$(resolve_profile_path "" "$EXTENSION_BUNDLE_ID" SPIDERWEB_MACOS_EXTENSION_PROFILE)" fi +app_profile_name="$(install_profile "$app_profile_path")" +extension_profile_name="$(install_profile "$extension_profile_path")" +echo "==> Using provisioning profiles" +echo " App: $app_profile_name" +echo " Extension: $extension_profile_name" write_export_options "$export_options_plist" "$app_profile_name" "$extension_profile_name" echo "==> Building Spiderweb CLI binaries for host architecture ($HOST_ARCH)" diff --git a/platform/macos/scripts/quickstart-regression.sh b/platform/macos/scripts/quickstart-regression.sh index 439fc030..cc9923ec 100755 --- a/platform/macos/scripts/quickstart-regression.sh +++ b/platform/macos/scripts/quickstart-regression.sh @@ -8,6 +8,7 @@ TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT cp "$APP_DIR/SpiderwebAppController.swift" "$TMPDIR/SpiderwebAppController.swift" +cp "$APP_DIR/SpiderAppWorkflowStore.swift" "$TMPDIR/SpiderAppWorkflowStore.swift" cp "$APP_DIR/SpiderwebSetupModel.swift" "$TMPDIR/SpiderwebSetupModel.swift" python3 - <<'PY' "$TMPDIR/SpiderwebAppController.swift" @@ -274,6 +275,7 @@ SWIFT swiftc \ -o "$TMPDIR/quickstart-regression" \ "$TMPDIR/QuickstartRegression.swift" \ + "$TMPDIR/SpiderAppWorkflowStore.swift" \ "$TMPDIR/SpiderwebAppController.swift" \ "$TMPDIR/SpiderwebSetupModel.swift" diff --git a/src/acheron/namespace_client.zig b/src/acheron/namespace_client.zig index 5dc24ff6..afc7fba5 100644 --- a/src/acheron/namespace_client.zig +++ b/src/acheron/namespace_client.zig @@ -927,7 +927,7 @@ pub const NamespaceClient = struct { }; } - fn waitForSessionReady(self: *NamespaceClient, session_key: []const u8) !void { + pub fn waitForSessionReady(self: *NamespaceClient, session_key: []const u8) !void { const deadline_ms = std.time.milliTimestamp() + @as(i64, control_reply_timeout_ms); while (true) { var status = try self.controlSessionStatus(session_key, true); diff --git a/src/acheron/session.zig b/src/acheron/session.zig index 02172719..2f8d0f10 100644 --- a/src/acheron/session.zig +++ b/src/acheron/session.zig @@ -2036,7 +2036,9 @@ pub const Session = struct { const target_path = bind_value.object.get("target_path") orelse continue; if (bind_path != .string or bind_path.string.len == 0) continue; if (target_path != .string or target_path.string.len == 0) continue; - try self.appendProjectBind(.workspace, bind_path.string, target_path.string); + const normalized_target_path = try self.normalizeWorkspaceBindTargetPath(bind_path.string, target_path.string); + defer self.allocator.free(normalized_target_path); + try self.appendProjectBind(.workspace, bind_path.string, normalized_target_path); if (session_service_discovery.parseTargetBindingForSession(bind_path.string)) |_| { const projected_bind_path = try std.fmt.allocPrint( self.allocator, @@ -2044,12 +2046,39 @@ pub const Session = struct { .{ local_fs_world_prefix, bind_path.string }, ); defer self.allocator.free(projected_bind_path); - try self.appendProjectBind(.workspace_projected, projected_bind_path, target_path.string); + try self.appendProjectBind(.workspace_projected, projected_bind_path, normalized_target_path); } } try self.appendManagedWorkspaceEntrypointBinds(); } + fn normalizeWorkspaceBindTargetPath(self: *Session, bind_path: []const u8, target_path: []const u8) ![]u8 { + if (!std.mem.startsWith(u8, bind_path, "/.spiderweb/venoms/")) { + return try self.allocator.dupe(u8, target_path); + } + + const alias = bind_path["/.spiderweb/venoms/".len..]; + if (alias.len == 0 or std.mem.indexOfScalar(u8, alias, '/') != null) { + return try self.allocator.dupe(u8, target_path); + } + if (!std.mem.startsWith(u8, target_path, "/nodes/local/venoms/")) { + return try self.allocator.dupe(u8, target_path); + } + + const target_alias = target_path["/nodes/local/venoms/".len..]; + if (!std.mem.eql(u8, target_alias, alias)) { + return try self.allocator.dupe(u8, target_path); + } + + if (try self.resolveLocalCapabilityVenom(alias)) |resolved_value| { + var resolved = resolved_value; + defer resolved.deinit(self.allocator); + return try std.fmt.allocPrint(self.allocator, "/nodes/local/venoms/{s}", .{resolved.venom_id}); + } + + return try self.allocator.dupe(u8, target_path); + } + fn clearProjectBindsByKind(self: *Session, kind: PathBindKind) void { var index: usize = 0; while (index < self.workspace_binds.items.len) { @@ -2099,9 +2128,6 @@ pub const Session = struct { } fn appendProjectBindIfMissing(self: *Session, kind: PathBindKind, bind_path: []const u8, target_path: []const u8) !void { - for (self.workspace_binds.items) |existing| { - if (std.mem.eql(u8, existing.bind_path, bind_path)) return; - } try self.appendProjectBind(kind, bind_path, target_path); } @@ -2176,7 +2202,10 @@ pub const Session = struct { const preferred_provider_node_id = try self.resolvePreferredBoundVenomNodeId(venom_id); defer if (preferred_provider_node_id) |value| self.allocator.free(value); const provider_path = if (preferred_provider_node_id) |value| - try std.fmt.allocPrint(self.allocator, "/nodes/{s}/venoms/{s}", .{ value, venom_id }) + if (std.mem.startsWith(u8, target_path, "/nodes/")) + try self.allocator.dupe(u8, target_path) + else + try std.fmt.allocPrint(self.allocator, "/nodes/{s}/venoms/{s}", .{ value, venom_id }) else try self.allocator.dupe(u8, target_path); defer self.allocator.free(provider_path); @@ -2282,6 +2311,12 @@ pub const Session = struct { pub fn resolveManagedCapabilityVenomTargetPath(self: *Session, venom_id: []const u8) !?[]u8 { if (!venom_model.isCapabilityVenomId(venom_id)) return null; + if (try self.resolveLocalCapabilityVenom(venom_id)) |resolved_value| { + var resolved = resolved_value; + defer resolved.deinit(self.allocator); + return try std.fmt.allocPrint(self.allocator, "/nodes/local/venoms/{s}", .{resolved.venom_id}); + } + const canonical_path = try std.fmt.allocPrint(self.allocator, "/.spiderweb/venoms/{s}", .{venom_id}); defer self.allocator.free(canonical_path); if (try self.resolveBoundPathOnce(canonical_path)) |target_path| { @@ -2309,6 +2344,60 @@ pub const Session = struct { return null; } + const ResolvedLocalCapabilityVenom = struct { + venom_id: []u8, + dir_id: u32, + + pub fn deinit(self: *ResolvedLocalCapabilityVenom, allocator: std.mem.Allocator) void { + allocator.free(self.venom_id); + self.* = undefined; + } + }; + + fn localNodeVenomsRootId(self: *Session) ?u32 { + const nodes_root = self.lookupChild(self.root_id, "nodes") orelse return null; + const local_node_dir = self.lookupChild(nodes_root, "local") orelse return null; + return self.lookupChild(local_node_dir, "venoms"); + } + + pub fn resolveLocalCapabilityVenom(self: *Session, capability_id: []const u8) !?ResolvedLocalCapabilityVenom { + const venoms_root = self.localNodeVenomsRootId() orelse return null; + const root_node = self.nodes.get(venoms_root) orelse return null; + var it = root_node.children.iterator(); + while (it.next()) |entry| { + const venom_name = entry.key_ptr.*; + const dir_id = entry.value_ptr.*; + const node = self.nodes.get(dir_id) orelse continue; + if (node.kind != .dir) continue; + if (!self.venomDirMatchesCapabilityId(dir_id, capability_id)) continue; + return .{ + .venom_id = try self.allocator.dupe(u8, venom_name), + .dir_id = dir_id, + }; + } + + if (self.lookupChild(venoms_root, capability_id)) |dir_id| { + return .{ + .venom_id = try self.allocator.dupe(u8, capability_id), + .dir_id = dir_id, + }; + } + return null; + } + + fn venomDirMatchesCapabilityId(self: *Session, dir_id: u32, capability_id: []const u8) bool { + const package_id_node = self.lookupChild(dir_id, "PACKAGE.json") orelse return false; + const node = self.nodes.get(package_id_node) orelse return false; + if (node.kind != .file) return false; + + var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, node.content, .{}) catch return false; + defer parsed.deinit(); + if (parsed.value != .object) return false; + const package_value = parsed.value.object.get("package_id") orelse parsed.value.object.get("venom_id") orelse return false; + if (package_value != .string or package_value.string.len == 0) return false; + return std.mem.eql(u8, package_value.string, capability_id); + } + fn materializeProjectBindPrefixDirectories(self: *Session) !void { for (self.workspace_binds.items) |bind| { try self.materializeBindPrefixDirectories(bind.bind_path); @@ -4127,6 +4216,17 @@ pub const Session = struct { try self.allocator.dupe(u8, absolute_path); defer self.allocator.free(resolved_path); + // Local terminal namespaces are seeded as real special nodes in the + // session tree. When those control files exist locally, prefer the + // internal special write path instead of proxying back out through the + // mounted bound-venom router. The proxy route is for remote/provider + // services; local terminal control writes should execute directly. + if (try self.resolveAbsolutePathForMountGraphNoBinds(resolved_path)) |resolved_id| { + if (self.nodes.get(resolved_id)) |resolved_node| { + if (isTerminalSpecial(resolved_node.special)) return false; + } + } + const proxy = (try self.boundVenomProxyPathForAbsolutePath(resolved_path)) orelse return false; defer self.allocator.free(proxy.remote_path); if (std.mem.eql(u8, proxy.remote_path, "/")) return false; @@ -4354,13 +4454,13 @@ pub const Session = struct { if (binding.provider_node_id) |node_id| { proxy.provider_node_id = node_id; - if (!std.mem.eql(u8, node_id, "local")) { - if (binding.provider_venom_path) |provider_path| { - if (parseNodeVenomServicePath(provider_path)) |provider_service| { - proxy.venom_id = provider_service.venom_id; - proxy.provider_export_name = provider_service.venom_id; - } + if (binding.provider_venom_path) |provider_path| { + if (parseNodeVenomServicePath(provider_path)) |provider_service| { + proxy.venom_id = provider_service.venom_id; + proxy.provider_export_name = provider_service.venom_id; } + } else if (!std.mem.eql(u8, node_id, "local")) { + proxy.provider_export_name = binding.venom_id; } } return proxy; @@ -4637,10 +4737,11 @@ pub const Session = struct { defer if (routed_fs_url) |value| self.allocator.free(value); const fs_url = blk: { // Clients should always stay on Spiderweb's routed authority. - // Server-internal namespace refreshes can talk to mounted export - // endpoints directly so Spiderweb does not deadlock routing back - // through itself while composing the namespace view. - if (route_mode == .server_internal and export_name != null) { + // Server-internal mounted export refreshes can talk directly to + // mounted node filesystem exports, but capability venoms still + // need Spiderweb's routed node filesystem path so control writes + // land on the correct export instead of the workspace mount. + if (route_mode == .server_internal and export_name != null and std.mem.eql(u8, venom_id, "fs")) { if (workspace_mount_fs_url) |value| break :blk value; if (routed_fs_url) |value| break :blk value; } else { @@ -4673,7 +4774,10 @@ pub const Session = struct { }, ); } - const selected_export_name = if (std.mem.eql(u8, venom_id, "fs")) export_name else venom_id; + const selected_export_name = if (std.mem.eql(u8, venom_id, "fs")) + export_name + else + export_name orelse venom_id; return try acheron_router.Router.init(self.allocator, &[_]acheron_router.EndpointConfig{.{ .name = node_id, @@ -6211,14 +6315,26 @@ pub const Session = struct { fn buildNamespaceRoutedNodeFsUrl(self: *Session, node_id: []const u8) !?[]u8 { const namespace_mount_url = self.namespace_mount_url orelse return null; const namespace_url = parseWsUrlParts(namespace_mount_url) orelse return null; + const routed_path = try joinControlPath(self.allocator, namespace_url.path, "/v2/fs/node"); + defer self.allocator.free(routed_path); const routed = try std.fmt.allocPrint( self.allocator, - "{s}://{s}/fs/node/{s}", - .{ namespace_url.scheme, namespace_url.authority, node_id }, + "{s}://{s}{s}/{s}", + .{ namespace_url.scheme, namespace_url.authority, routed_path, node_id }, ); return routed; } + fn joinControlPath(self_allocator: std.mem.Allocator, base_path: []const u8, suffix: []const u8) ![]u8 { + const trimmed_base = std.mem.trimRight(u8, base_path, "/"); + const normalized_base = if (trimmed_base.len == 0) "/" else trimmed_base; + const trimmed_suffix = std.mem.trimLeft(u8, suffix, "/"); + if (std.mem.eql(u8, normalized_base, "/")) { + return std.fmt.allocPrint(self_allocator, "/{s}", .{trimmed_suffix}); + } + return std.fmt.allocPrint(self_allocator, "{s}/{s}", .{ normalized_base, trimmed_suffix }); + } + const ParsedWsUrl = struct { scheme: []const u8, authority: []const u8, @@ -7193,12 +7309,20 @@ pub const Session = struct { null; const direct_workspace_root = if (cwd) |value| blk: { - if (!pathMatchesPrefixBoundary(value, local_fs_world_prefix)) break :blk null; - break :blk try self.resolveWorkspaceHostPath(local_fs_world_prefix); + if (pathMatchesPrefixBoundary(value, local_fs_world_prefix)) { + break :blk try self.resolveWorkspaceHostPath(local_fs_world_prefix); + } + if (std.fs.path.isAbsolute(value) and pathExistsAbsolute(value)) { + break :blk try self.allocator.dupe(u8, value); + } + break :blk null; } else null; defer if (direct_workspace_root) |value| self.allocator.free(value); const resolved_cwd = if (cwd) |value| blk: { + if (direct_workspace_root != null and std.fs.path.isAbsolute(value)) { + break :blk try self.allocator.dupe(u8, "."); + } if (!pathMatchesPrefixBoundary(value, local_fs_world_prefix)) { break :blk try self.allocator.dupe(u8, value); } diff --git a/src/acheron/session_bound_venoms.zig b/src/acheron/session_bound_venoms.zig index 1f7a786b..02bd34e0 100644 --- a/src/acheron/session_bound_venoms.zig +++ b/src/acheron/session_bound_venoms.zig @@ -1,5 +1,31 @@ const std = @import("std"); +fn resolveNodeVenomDir(session: anytype, node_name: []const u8, venom_id: []const u8) !?struct { dir_id: u32, actual_venom_id: []u8 } { + const nodes_root = session.lookupChild(session.root_id, "nodes") orelse return null; + const node_dir_id = session.lookupChild(nodes_root, node_name) orelse return null; + const venoms_root_id = session.lookupChild(node_dir_id, "venoms") orelse return null; + + if (session.lookupChild(venoms_root_id, venom_id)) |venom_dir_id| { + return .{ + .dir_id = venom_dir_id, + .actual_venom_id = try session.allocator.dupe(u8, venom_id), + }; + } + + if (std.mem.eql(u8, node_name, "local")) { + if (try session.resolveLocalCapabilityVenom(venom_id)) |resolved_value| { + var resolved = resolved_value; + defer resolved.deinit(session.allocator); + return .{ + .dir_id = resolved.dir_id, + .actual_venom_id = try session.allocator.dupe(u8, resolved.venom_id), + }; + } + } + + return null; +} + pub fn seedActiveScopedVenomBindings( session: anytype, active_agent_venoms_dir: u32, @@ -79,15 +105,13 @@ pub fn seedBoundNodeVenomNamespaceAt( var selected_node_id: ?[]const u8 = null; var selected_venom_dir_id: ?u32 = null; + var selected_actual_venom_id: ?[]u8 = null; + if (preferred_node_id) |selected| { - const preferred_node_dir_id = session.lookupChild(nodes_root, selected); - if (preferred_node_dir_id) |node_dir_id| { - if (session.lookupChild(node_dir_id, "venoms")) |venoms_root_id| { - if (session.lookupChild(venoms_root_id, venom_id)) |venom_dir_id| { - selected_node_id = selected; - selected_venom_dir_id = venom_dir_id; - } - } + if (try resolveNodeVenomDir(session, selected, venom_id)) |resolved| { + selected_node_id = selected; + selected_venom_dir_id = resolved.dir_id; + selected_actual_venom_id = resolved.actual_venom_id; } } @@ -96,17 +120,18 @@ pub fn seedBoundNodeVenomNamespaceAt( var node_it = nodes_root_node.children.iterator(); while (node_it.next()) |entry| { const node_name = entry.key_ptr.*; - const node_dir_id = entry.value_ptr.*; - const venoms_root_id = session.lookupChild(node_dir_id, "venoms") orelse continue; - const venom_dir_id = session.lookupChild(venoms_root_id, venom_id) orelse continue; + const resolved = (try resolveNodeVenomDir(session, node_name, venom_id)) orelse continue; selected_node_id = node_name; - selected_venom_dir_id = venom_dir_id; + selected_venom_dir_id = resolved.dir_id; + selected_actual_venom_id = resolved.actual_venom_id; break; } } const provider_node_id = selected_node_id orelse return false; const provider_dir_id = selected_venom_dir_id orelse return false; + const provider_venom_id = selected_actual_venom_id orelse return false; + defer session.allocator.free(provider_venom_id); const alias_dir_id = try session.addDir(alias_root, venom_id, false); try session.copyOptionalServiceFile(provider_dir_id, alias_dir_id, "README.md"); @@ -125,7 +150,7 @@ pub fn seedBoundNodeVenomNamespaceAt( const provider_venom_path = try std.fmt.allocPrint( session.allocator, "/nodes/{s}/venoms/{s}", - .{ provider_node_id, venom_id }, + .{ provider_node_id, provider_venom_id }, ); defer session.allocator.free(provider_venom_path); const endpoint_path = blk: { @@ -133,7 +158,7 @@ pub fn seedBoundNodeVenomNamespaceAt( break :blk try session.venomEndpointPath(provider_dir_id); }; defer if (endpoint_path) |value| session.allocator.free(value); - const invoke_path = try session.deriveVenomInvokePath(provider_node_id, venom_id, provider_dir_id); + const invoke_path = try session.deriveVenomInvokePath(provider_node_id, provider_venom_id, provider_dir_id); defer if (invoke_path) |value| session.allocator.free(value); try session.registerScopedVenomBinding( @@ -201,16 +226,16 @@ pub fn registerBoundVenomAliasOnly( var selected_node_id: ?[]const u8 = null; var selected_venom_dir_id: ?u32 = null; + var selected_actual_venom_id: ?[]u8 = null; + if (preferred_node_id) |selected| { - const preferred_node_dir_id = session.lookupChild(nodes_root, selected); - if (preferred_node_dir_id) |node_dir_id| { - if (session.lookupChild(node_dir_id, "venoms")) |venoms_root_id| { - if (session.lookupChild(venoms_root_id, venom_id)) |venom_dir_id| { - if (isBoundVenomNodeAllowed(session, workspace_id, agent_id, selected)) { - selected_node_id = selected; - selected_venom_dir_id = venom_dir_id; - } - } + if (try resolveNodeVenomDir(session, selected, venom_id)) |resolved| { + if (isBoundVenomNodeAllowed(session, workspace_id, agent_id, selected)) { + selected_node_id = selected; + selected_venom_dir_id = resolved.dir_id; + selected_actual_venom_id = resolved.actual_venom_id; + } else { + session.allocator.free(resolved.actual_venom_id); } } } @@ -220,24 +245,28 @@ pub fn registerBoundVenomAliasOnly( var node_it = nodes_root_node.children.iterator(); while (node_it.next()) |entry| { const node_name = entry.key_ptr.*; - const node_dir_id = entry.value_ptr.*; - const venoms_root_id = session.lookupChild(node_dir_id, "venoms") orelse continue; - const venom_dir_id = session.lookupChild(venoms_root_id, venom_id) orelse continue; - if (!isBoundVenomNodeAllowed(session, workspace_id, agent_id, node_name)) continue; + const resolved = (try resolveNodeVenomDir(session, node_name, venom_id)) orelse continue; + if (!isBoundVenomNodeAllowed(session, workspace_id, agent_id, node_name)) { + session.allocator.free(resolved.actual_venom_id); + continue; + } selected_node_id = node_name; - selected_venom_dir_id = venom_dir_id; + selected_venom_dir_id = resolved.dir_id; + selected_actual_venom_id = resolved.actual_venom_id; break; } } const provider_node_id = selected_node_id orelse return false; const provider_dir_id = selected_venom_dir_id orelse return false; + const provider_venom_id = selected_actual_venom_id orelse return false; + defer session.allocator.free(provider_venom_id); const venom_path = try std.fmt.allocPrint(session.allocator, "{s}/{s}", .{ alias_base_path, venom_id }); defer session.allocator.free(venom_path); const provider_venom_path = try std.fmt.allocPrint( session.allocator, "/nodes/{s}/venoms/{s}", - .{ provider_node_id, venom_id }, + .{ provider_node_id, provider_venom_id }, ); defer session.allocator.free(provider_venom_path); const endpoint_path = blk: { @@ -245,7 +274,7 @@ pub fn registerBoundVenomAliasOnly( break :blk try session.venomEndpointPath(provider_dir_id); }; defer if (endpoint_path) |value| session.allocator.free(value); - const invoke_path = try session.deriveVenomInvokePath(provider_node_id, venom_id, provider_dir_id); + const invoke_path = try session.deriveVenomInvokePath(provider_node_id, provider_venom_id, provider_dir_id); defer if (invoke_path) |value| session.allocator.free(value); try session.registerScopedVenomBinding( diff --git a/src/acheron/session_catalog_bindings.zig b/src/acheron/session_catalog_bindings.zig index a230f176..2c945ee9 100644 --- a/src/acheron/session_catalog_bindings.zig +++ b/src/acheron/session_catalog_bindings.zig @@ -14,9 +14,10 @@ fn resolvePreferredLocalCatalogProviderNodeId(session: anytype, venom_id: []cons } pub fn registerLocalCatalogVenomBinding(session: anytype, venom_id: []const u8, scope: []const u8) !void { - const local_venoms_root = lookupLocalNodeVenomsRoot(session) orelse return; - const venom_dir_id = session.lookupChild(local_venoms_root, venom_id) orelse return; - const venom_path = try std.fmt.allocPrint(session.allocator, "/nodes/local/venoms/{s}", .{venom_id}); + var resolved = (try session.resolveLocalCapabilityVenom(venom_id)) orelse return; + defer resolved.deinit(session.allocator); + const venom_dir_id = resolved.dir_id; + const venom_path = try std.fmt.allocPrint(session.allocator, "/nodes/local/venoms/{s}", .{resolved.venom_id}); defer session.allocator.free(venom_path); const endpoint_path = blk: { if (try session.firstVenomMountPath(venom_dir_id)) |value| break :blk value; @@ -27,7 +28,7 @@ pub fn registerLocalCatalogVenomBinding(session: anytype, venom_id: []const u8, defer if (preferred_provider_node_id) |value| session.allocator.free(value); const provider_node_id = preferred_provider_node_id orelse "local"; const provider_venom_path = if (preferred_provider_node_id) |value| - try std.fmt.allocPrint(session.allocator, "/nodes/{s}/venoms/{s}", .{ value, venom_id }) + try std.fmt.allocPrint(session.allocator, "/nodes/{s}/venoms/{s}", .{ value, resolved.venom_id }) else try session.allocator.dupe(u8, venom_path); defer session.allocator.free(provider_venom_path); diff --git a/src/acheron/session_node_venoms.zig b/src/acheron/session_node_venoms.zig index d245c314..a612db1a 100644 --- a/src/acheron/session_node_venoms.zig +++ b/src/acheron/session_node_venoms.zig @@ -2,6 +2,7 @@ const std = @import("std"); const unified = @import("spider-protocol").unified; const shared_node = @import("spiderweb_node"); const venom_model = @import("../venom_model.zig"); +const terminal_namespace = @import("../runtime/terminal_namespace.zig"); const workspace_policy = @import("../workspaces/policy.zig"); pub const NodeResourceView = struct { @@ -187,6 +188,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor venom.schema_json, venom.invoke_template_json, venom.help_md, + null, ); try view.observe( session.allocator, @@ -256,6 +258,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"filesystem\"}", null, "Workspace node filesystem export.", + null, ); try addNodeVenomEntry( session, @@ -280,6 +283,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"filesystem\"}", null, "Workspace node filesystem export.", + null, ); try view.observe(session.allocator, node.id, "fs", "fs", endpoint, mounts); try session.appendVenomIndexEntry(&services_index, &services_index_first, "fs", "fs", "online", endpoint); @@ -320,6 +324,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"camera\"}", null, "Camera capture namespace.", + null, ); try addNodeVenomEntry( session, @@ -344,6 +349,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"camera\"}", null, "Camera capture namespace.", + null, ); try view.observe(session.allocator, node.id, "camera", "camera", endpoint, mounts); try session.appendVenomIndexEntry(&services_index, &services_index_first, "camera", "camera", "online", endpoint); @@ -384,6 +390,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"screen\"}", null, "Screen capture namespace.", + null, ); try addNodeVenomEntry( session, @@ -408,6 +415,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"screen\"}", null, "Screen capture namespace.", + null, ); try view.observe(session.allocator, node.id, "screen", "screen", endpoint, mounts); try session.appendVenomIndexEntry(&services_index, &services_index_first, "screen", "screen", "online", endpoint); @@ -448,6 +456,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"user\"}", null, "User interaction namespace.", + null, ); try addNodeVenomEntry( session, @@ -472,6 +481,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"user\"}", null, "User interaction namespace.", + null, ); try view.observe(session.allocator, node.id, "user", "user", endpoint, mounts); try session.appendVenomIndexEntry(&services_index, &services_index_first, "user", "user", "online", endpoint); @@ -483,6 +493,8 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor defer session.allocator.free(venom_id); const endpoint = try std.fmt.allocPrint(session.allocator, "/nodes/{s}/terminal/{s}", .{ node.id, terminal_id }); defer session.allocator.free(endpoint); + const namespace_base_path = try std.fmt.allocPrint(session.allocator, "/nodes/{s}/venoms/{s}", .{ node.id, venom_id }); + defer session.allocator.free(namespace_base_path); const escaped_terminal_id = try unified.jsonEscape(session.allocator, terminal_id); defer session.allocator.free(escaped_terminal_id); const caps = try std.fmt.allocPrint( @@ -522,6 +534,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"terminal\"}", null, "Interactive terminal namespace.", + namespace_base_path, ); try addNodeVenomEntry( session, @@ -546,6 +559,7 @@ pub fn addNodeVenoms(session: anytype, node_dir: u32, node: workspace_policy.Wor "{\"model\":\"terminal\"}", null, "Interactive terminal namespace.", + namespace_base_path, ); try view.observe(session.allocator, node.id, "terminal", venom_id, endpoint, mounts); try session.appendVenomIndexEntry(&services_index, &services_index_first, venom_id, "terminal", "online", endpoint); @@ -891,6 +905,7 @@ fn addNodeVenomEntry( schema_json: []const u8, invoke_template_json: ?[]const u8, help_md: ?[]const u8, + namespace_base_path: ?[]const u8, ) !void { const venom_dir = try session.addDir(services_root, venom_id, false); @@ -938,6 +953,11 @@ fn addNodeVenomEntry( const host_json = try renderNodeVenomHostJson(session, runtime_json); defer session.allocator.free(host_json); _ = try session.addFile(venom_dir, "HOST.json", host_json, false, .none); + if (namespace_base_path) |base_path| { + try terminal_namespace.seedNamespaceAt(session, venom_dir, base_path); + return; + } + _ = try session.addFile(venom_dir, "PERMISSIONS.json", permissions_json, false, .none); const escaped_package_id = try unified.jsonEscape(session.allocator, package_id); diff --git a/src/runtime/terminal_namespace.zig b/src/runtime/terminal_namespace.zig index 0a0d9feb..ef9a87e7 100644 --- a/src/runtime/terminal_namespace.zig +++ b/src/runtime/terminal_namespace.zig @@ -74,12 +74,12 @@ pub fn seedNamespaceAt(self: anytype, terminal_dir: u32, base_path: []const u8) const capabilities_json = if (supports_interactive_sessions) try self.allocator.dupe( u8, - "{\"invoke\":true,\"operations\":[\"terminal_session_create\",\"terminal_session_resume\",\"terminal_session_close\",\"terminal_session_write\",\"terminal_session_read\",\"terminal_session_resize\",\"shell_exec\"],\"discoverable\":true,\"interactive\":true,\"sessionized\":true,\"pty\":true}", + "{\"invoke\":true,\"operations\":[\"terminal_session_create\",\"terminal_session_resume\",\"terminal_session_close\",\"terminal_session_write\",\"terminal_session_read\",\"terminal_session_resize\",\"shell_exec\"],\"discoverable\":true,\"interactive\":true,\"sessionized\":true,\"pty\":true,\"shell_modes\":[\"workspace\",\"host\"],\"preferred_shell_mode\":\"workspace\"}", ) else try self.allocator.dupe( u8, - "{\"invoke\":true,\"operations\":[\"shell_exec\"],\"discoverable\":true,\"interactive\":false,\"sessionized\":false,\"pty\":false}", + "{\"invoke\":true,\"operations\":[\"shell_exec\"],\"discoverable\":true,\"interactive\":false,\"sessionized\":false,\"pty\":false,\"shell_modes\":[\"workspace\",\"host\"],\"preferred_shell_mode\":\"workspace\"}", ); defer self.allocator.free(capabilities_json); try self.addDirectoryDescriptors( @@ -90,7 +90,7 @@ pub fn seedNamespaceAt(self: anytype, terminal_dir: u32, base_path: []const u8) if (supports_interactive_sessions) "Sessionized terminal namespace. Create/resume/close PTY sessions and use write/read/resize for interactive workflows." else - "Command execution namespace. Use exec for non-interactive workflows; interactive PTY sessions are currently supported on Linux only.", + "Command execution namespace. Prefer workspace shell for agent and workspace tasks, and host shell for machine-level inspection. Interactive PTY sessions are currently supported on Linux only.", ); const namespace_mode = self.terminalNamespaceMode(); const path_model = self.terminalPathModel(); @@ -126,7 +126,7 @@ pub fn seedNamespaceAt(self: anytype, terminal_dir: u32, base_path: []const u8) ); const status_descriptor_json = try std.fmt.allocPrint( self.allocator, - "{{\"venom_id\":\"terminal\",\"state\":\"namespace\",\"has_invoke\":true,\"interactive\":{s},\"sessionized\":{s},\"pty\":{s},\"namespace_mode\":\"{s}\",\"path_model\":\"{s}\"}}", + "{{\"venom_id\":\"terminal\",\"state\":\"namespace\",\"has_invoke\":true,\"interactive\":{s},\"sessionized\":{s},\"pty\":{s},\"namespace_mode\":\"{s}\",\"path_model\":\"{s}\",\"shell_modes\":[\"workspace\",\"host\"],\"preferred_shell_mode\":\"workspace\"}}", .{ if (supports_interactive_sessions) "true" else "false", if (supports_interactive_sessions) "true" else "false", @@ -145,7 +145,7 @@ pub fn seedNamespaceAt(self: anytype, terminal_dir: u32, base_path: []const u8) ); const status_json = try std.fmt.allocPrint( self.allocator, - "{{\"state\":\"idle\",\"tool\":null,\"session_id\":null,\"updated_at_ms\":0,\"error\":null,\"interactive\":{s},\"sessionized\":{s},\"pty\":{s},\"namespace_mode\":\"{s}\",\"path_model\":\"{s}\"}}", + "{{\"state\":\"idle\",\"tool\":null,\"session_id\":null,\"updated_at_ms\":0,\"error\":null,\"interactive\":{s},\"sessionized\":{s},\"pty\":{s},\"namespace_mode\":\"{s}\",\"path_model\":\"{s}\",\"shell_modes\":[\"workspace\",\"host\"],\"preferred_shell_mode\":\"workspace\"}}", .{ if (supports_interactive_sessions) "true" else "false", if (supports_interactive_sessions) "true" else "false", @@ -191,7 +191,7 @@ pub fn seedNamespaceAt(self: anytype, terminal_dir: u32, base_path: []const u8) if (supports_interactive_sessions) "Use create/resume/close to manage PTY sessions. Use write/read/resize for interactive I/O. exec is a convenience write+read command path. invoke.json accepts op=create|resume|close|write|read|resize|exec envelopes.\n" else - "Use exec.json for non-interactive shell execution. Interactive PTY session controls (create/resume/close/write/read/resize) are currently supported on Linux only. invoke.json accepts op=exec everywhere and the PTY session ops on Linux.\n", + "Use exec.json for non-interactive shell execution. Prefer workspace shell mode for agent and workspace tasks so commands start in the mounted Spiderweb workspace when that root is available. Use host shell mode for machine-level inspection or service debugging. Interactive PTY session controls (create/resume/close/write/read/resize) are currently supported on Linux only. invoke.json accepts op=exec everywhere and the PTY session ops on Linux.\n", false, .none, ); From a06a08a8aea4041082d9adf47eb3328620524c9c Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Wed, 1 Apr 2026 20:32:59 +0300 Subject: [PATCH 2/5] Bump Spiderweb version to 0.5.7 --- CHANGELOG.md | 14 ++++++++++++++ build.zig.zon | 2 +- .../macos/SpiderwebFSKit.xcodeproj/project.pbxproj | 8 ++++---- src/main.zig | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1769f486..d075b153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project are documented in this file. +## 0.5.7 - 2026-04-01 + +### Native macOS SpiderApp Support +- Fixed Spiderweb’s workspace-capability routing so the new native macOS SpiderApp can load packages, binds, and terminal capabilities through the same shared-core backend path as the CLI. +- Corrected local capability binding resolution and stale bind refresh behavior so workspace aliases track the real local venom directories instead of getting stuck on outdated short names. + +### Native Terminal Routing +- Fixed terminal venom namespace seeding and control-file routing so native SpiderApp terminal exec reaches the real local terminal service instead of dead-ending on missing or misrouted control files. +- Added explicit host-versus-workspace shell support in the terminal backend so SpiderApp can steer agents and users into the correct execution root. + +### macOS Runtime Packaging +- Hardened the signed macOS release path around the bundled native SpiderApp/Spiderweb tools, including notarized package validation for the updated suite packaging flow. +- Fixed the macOS terminal runtime to avoid assuming GNU `timeout` is present on user machines. + ## 0.5.6 - 2026-03-27 ### Hosted Registry E2E diff --git a/build.zig.zon b/build.zig.zon index 4e3c0805..80b8fa4e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .ziggy_spiderweb, - .version = "0.5.6", + .version = "0.5.7", .fingerprint = 0xe46867958f0d9203, .minimum_zig_version = "0.13.0", .dependencies = .{ diff --git a/platform/macos/SpiderwebFSKit.xcodeproj/project.pbxproj b/platform/macos/SpiderwebFSKit.xcodeproj/project.pbxproj index ed9510a0..28a23431 100644 --- a/platform/macos/SpiderwebFSKit.xcodeproj/project.pbxproj +++ b/platform/macos/SpiderwebFSKit.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.5.6; + MARKETING_VERSION = 0.5.7; PRODUCT_BUNDLE_IDENTIFIER = com.deanoc.spiderweb.fskit.app; PRODUCT_NAME = Spiderweb; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -439,7 +439,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.5.6; + MARKETING_VERSION = 0.5.7; PRODUCT_BUNDLE_IDENTIFIER = com.deanoc.spiderweb.fskit.app; PRODUCT_NAME = Spiderweb; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -469,7 +469,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.5.6; + MARKETING_VERSION = 0.5.7; PRODUCT_BUNDLE_IDENTIFIER = com.deanoc.spiderweb.fskit.app.extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -500,7 +500,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.5.6; + MARKETING_VERSION = 0.5.7; PRODUCT_BUNDLE_IDENTIFIER = com.deanoc.spiderweb.fskit.app.extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/src/main.zig b/src/main.zig index 952dee97..ac5e2f80 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,7 +21,7 @@ pub fn main() !void { if (requested_help) { const help = - "Spiderweb v0.5.6 - Workspace Host for OpenClaw Protocol\n" ++ + "Spiderweb v0.5.7 - Workspace Host for OpenClaw Protocol\n" ++ "\n" ++ "A WebSocket host that exposes Spiderweb workspaces, nodes, venoms, and the virtual filesystem.\n" ++ "\n" ++ @@ -56,7 +56,7 @@ pub fn main() !void { } try config.normalizeRuntimePathsFromSpiderWebRoot(); - std.log.info("Starting Spiderweb v0.5.6 (Workspace Host)", .{}); + std.log.info("Starting Spiderweb v0.5.7 (Workspace Host)", .{}); std.log.info("Config: {s}", .{config.config_path}); std.log.info("Workspace mount binary: {s}", .{config.runtime.sandbox_fs_mount_bin}); From deaf30497387d63910dfb50ab3c5b9e2259d11ab Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Fri, 3 Apr 2026 09:53:40 +0300 Subject: [PATCH 3/5] Align quickstart copy with visible action --- platform/macos/SpiderwebFSKitApp/ContentView.swift | 4 ++-- platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/platform/macos/SpiderwebFSKitApp/ContentView.swift b/platform/macos/SpiderwebFSKitApp/ContentView.swift index a2f8d052..a06c7687 100644 --- a/platform/macos/SpiderwebFSKitApp/ContentView.swift +++ b/platform/macos/SpiderwebFSKitApp/ContentView.swift @@ -203,8 +203,8 @@ struct ContentView: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { HeroFactCard( title: "Default Path", - value: "Just Try It", - detail: "The fastest way to see Spiderweb as a drive on this Mac." + value: "Start Local Workspace", + detail: "The fastest way to create a local workspace and see it as a drive on this Mac." ) HeroFactCard( title: "Drive Location", diff --git a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift index bc16555f..09243010 100644 --- a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift +++ b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift @@ -234,7 +234,7 @@ enum QuickstartPreset: String, Codable, CaseIterable, Identifiable { var title: String { switch self { - case .justTryIt: return "Just Try It" + case .justTryIt: return "Start Local Workspace" case .connectMachines: return "Connect Machines" case .agentLab: return "Agent Lab" } From a21b1d0cbf95dba7e4d32238ae6dee972b273012 Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Fri, 3 Apr 2026 09:59:32 +0300 Subject: [PATCH 4/5] Fix quickstart regression copy expectation --- platform/macos/scripts/quickstart-regression.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/macos/scripts/quickstart-regression.sh b/platform/macos/scripts/quickstart-regression.sh index cc9923ec..b169b65b 100755 --- a/platform/macos/scripts/quickstart-regression.sh +++ b/platform/macos/scripts/quickstart-regression.sh @@ -144,7 +144,7 @@ struct QuickstartRegression { expect(resumed.currentStep == .ensureWorkspace, "same-preset resume should keep the current step") expect(resumed.blockedReason == nil, "same-preset resume should clear the blocked reason") expect(resumed.mountpoint == SpiderwebAppController.quickstartMountpoint(for: "My Workspace"), "same-preset resume should fill a missing mountpoint") - expect(resumed.lastMessage == "Continuing just try it...", "same-preset resume should use the continuing status message") + expect(resumed.lastMessage == "Continuing start local workspace...", "same-preset resume should use the continuing status message") let encoder = JSONEncoder() let persisted = QuickstartState( From 8a15e645ed2d5373a2a7e815e3f8f90545cbbed5 Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Fri, 3 Apr 2026 10:11:40 +0300 Subject: [PATCH 5/5] Fix namespace routed terminal binding precedence --- src/acheron/session.zig | 45 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/acheron/session.zig b/src/acheron/session.zig index 2f8d0f10..d7defc20 100644 --- a/src/acheron/session.zig +++ b/src/acheron/session.zig @@ -2311,18 +2311,18 @@ pub const Session = struct { pub fn resolveManagedCapabilityVenomTargetPath(self: *Session, venom_id: []const u8) !?[]u8 { if (!venom_model.isCapabilityVenomId(venom_id)) return null; - if (try self.resolveLocalCapabilityVenom(venom_id)) |resolved_value| { - var resolved = resolved_value; - defer resolved.deinit(self.allocator); - return try std.fmt.allocPrint(self.allocator, "/nodes/local/venoms/{s}", .{resolved.venom_id}); - } - const canonical_path = try std.fmt.allocPrint(self.allocator, "/.spiderweb/venoms/{s}", .{venom_id}); defer self.allocator.free(canonical_path); if (try self.resolveBoundPathOnce(canonical_path)) |target_path| { return target_path; } + if (try self.resolveLocalCapabilityVenom(venom_id)) |resolved_value| { + var resolved = resolved_value; + defer resolved.deinit(self.allocator); + return try std.fmt.allocPrint(self.allocator, "/nodes/local/venoms/{s}", .{resolved.venom_id}); + } + const global_path = try std.fmt.allocPrint(self.allocator, "/.spiderweb/_compat/global/{s}", .{venom_id}); defer self.allocator.free(global_path); if (self.resolveAbsolutePathNoBinds(global_path) != null) { @@ -6315,12 +6315,10 @@ pub const Session = struct { fn buildNamespaceRoutedNodeFsUrl(self: *Session, node_id: []const u8) !?[]u8 { const namespace_mount_url = self.namespace_mount_url orelse return null; const namespace_url = parseWsUrlParts(namespace_mount_url) orelse return null; - const routed_path = try joinControlPath(self.allocator, namespace_url.path, "/v2/fs/node"); - defer self.allocator.free(routed_path); const routed = try std.fmt.allocPrint( self.allocator, - "{s}://{s}{s}/{s}", - .{ namespace_url.scheme, namespace_url.authority, routed_path, node_id }, + "{s}://{s}/fs/node/{s}", + .{ namespace_url.scheme, namespace_url.authority, node_id }, ); return routed; } @@ -6494,6 +6492,33 @@ pub const Session = struct { try std.testing.expectEqualStrings("wss://namespace.example.test:4443/fs/node/node-3", routed); } + test "acheron_session: buildNamespaceRoutedNodeFsUrl ignores namespace path prefixes" { + const allocator = std.testing.allocator; + const runtime_handle = try runtime_handle_mod.RuntimeHandle.createUnavailable( + allocator, + "execution_failed", + "runtime unavailable", + ); + defer runtime_handle.destroy(); + + var session = try Session.initWithOptions( + allocator, + runtime_handle, + "codex", + .{ + .project_id = "proj-1", + .namespace_mount_url = "wss://namespace.example.test:4443/v2/node", + .agents_dir = ".does-not-exist", + .projects_dir = ".does-not-exist", + }, + ); + defer session.deinit(); + + const routed = (try session.buildNamespaceRoutedNodeFsUrl("node-3")) orelse return error.TestExpectedResponse; + defer allocator.free(routed); + try std.testing.expectEqualStrings("wss://namespace.example.test:4443/fs/node/node-3", routed); + } + fn appendMountGraphAncestorChain( self: *Session, nodes: *std.ArrayListUnmanaged(MountGraphNodeRecord),