Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Sources/DevScope/Stores/ProcessStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,12 @@ final class ProcessStore: ObservableObject {
}

func familySummary(for process: DevProcess) -> ProcessFamilySummary {
ProcessPresentation.familySummary(for: process, in: processes)
let live = ProcessPresentation.liveScopedTreeInputs(
processes: processes,
classifiedProcesses: classifiedProcesses,
liveProcessIDs: liveProcessIDs
)
return ProcessPresentation.familySummary(for: process, in: live.processes)
}

func isProcessLive(pid: Int32) -> Bool {
Expand Down Expand Up @@ -412,10 +417,15 @@ final class ProcessStore: ObservableObject {
func terminateTree(root item: ClassifiedDevProcess) {
guard allowSignal(item) else { return }
do {
let targets = try killer.terminateTree(
root: item,
let live = ProcessPresentation.liveScopedTreeInputs(
processes: processes,
classifiedProcesses: classifiedProcesses,
liveProcessIDs: liveProcessIDs
)
let targets = try killer.terminateTree(
root: item,
processes: live.processes,
classifiedProcesses: live.classifiedProcesses,
currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier)
)
statusMessage = "Sent TERM to \(targets.count) processes"
Expand All @@ -434,10 +444,15 @@ final class ProcessStore: ObservableObject {
func forceTerminateTree(root item: ClassifiedDevProcess) {
guard allowSignal(item) else { return }
do {
let targets = try killer.forceTerminateTree(
root: item,
let live = ProcessPresentation.liveScopedTreeInputs(
processes: processes,
classifiedProcesses: classifiedProcesses,
liveProcessIDs: liveProcessIDs
)
let targets = try killer.forceTerminateTree(
root: item,
processes: live.processes,
classifiedProcesses: live.classifiedProcesses,
currentProcessID: Int32(ProcessInfo.processInfo.processIdentifier)
)
statusMessage = "Sent KILL to \(targets.count) processes"
Expand Down
10 changes: 9 additions & 1 deletion Sources/DevScopeCore/AutomationCapabilityPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ public enum AutomationPathAuthorization {
? destination
: destination.deletingLastPathComponent().standardizedFileURL
return !metadataIsSymbolicLink
&& destination.path.hasPrefix(root.path + "/")
&& isPath(destination.path, underApprovedRoot: root.path)
&& verifiedMetadataURL.path == expectedMetadataURL.path
}

/// Fail closed for filesystem root: `"/"` + `"/"` must not become `"//"`.
static func isPath(_ destinationPath: String, underApprovedRoot rootPath: String) -> Bool {
if rootPath == "/" {
return destinationPath.hasPrefix("/") && destinationPath != "/"
}
return destinationPath.hasPrefix(rootPath + "/")
}
}

public struct AutomationCapabilityDecision: Equatable, Sendable {
Expand Down
13 changes: 13 additions & 0 deletions Sources/DevScopeCore/ProcessPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,19 @@ public enum ProcessPresentation {
)
}

/// Snapshot stabilizers keep exited PIDs briefly for UI continuity. Tree signals and
/// family counts must use live rows only, or ProcessKiller fails closed on ghosts.
public static func liveScopedTreeInputs(
processes: [DevProcess],
classifiedProcesses: [ClassifiedDevProcess],
liveProcessIDs: Set<Int32>
) -> (processes: [DevProcess], classifiedProcesses: [ClassifiedDevProcess]) {
(
processes.filter { liveProcessIDs.contains($0.pid) },
classifiedProcesses.filter { liveProcessIDs.contains($0.process.pid) }
)
}

public static func searchableText(for item: ClassifiedDevProcess) -> String {
[
item.classification.displayName,
Expand Down
22 changes: 22 additions & 0 deletions Tests/DevScopeCoreTests/AutomationCapabilityPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ final class AutomationCapabilityPolicyTests: XCTestCase {
))
}

func testFilesystemRootApprovedPathDoesNotUseDoubleSlashPrefix() {
let root = URL(fileURLWithPath: "/")
let destination = URL(fileURLWithPath: "/Users/test/Library/LaunchAgents/com.example.plist")

XCTAssertTrue(AutomationPathAuthorization.isApprovedDestination(
destination,
approvedRoot: root,
destinationExists: true,
verifiedMetadataURL: destination,
metadataIsSymbolicLink: false
))
XCTAssertFalse(AutomationPathAuthorization.isApprovedDestination(
root,
approvedRoot: root,
destinationExists: true,
verifiedMetadataURL: root,
metadataIsSymbolicLink: false
))
XCTAssertTrue(AutomationPathAuthorization.isPath("/tmp/a", underApprovedRoot: "/"))
XCTAssertFalse(AutomationPathAuthorization.isPath("/", underApprovedRoot: "/"))
}

func testCurrentUserLaunchAgentReceivesFullCapabilities() {
let decision = AutomationCapabilityPolicy.decision(
for: Fixtures.userAgent,
Expand Down
48 changes: 48 additions & 0 deletions Tests/DevScopeCoreTests/ProcessPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,54 @@ final class ProcessPresentationTests: XCTestCase {
XCTAssertEqual(family.descendantCount, 2)
}

func testLiveScopedTreeInputsDropGracePeriodGhosts() {
let liveRoot = DevProcess(
pid: 10,
parentPID: 1,
executable: "npm",
command: "npm run dev",
birthToken: ProcessBirthToken(seconds: 100, microseconds: 0)
)
let liveChild = DevProcess(
pid: 11,
parentPID: 10,
executable: "node",
command: "node next",
birthToken: ProcessBirthToken(seconds: 101, microseconds: 0)
)
let ghostChild = DevProcess(
pid: 12,
parentPID: 10,
executable: "node",
command: "node worker",
birthToken: ProcessBirthToken(seconds: 102, microseconds: 0)
)
let processes = [liveRoot, liveChild, ghostChild]
let classified = processes.map { process in
ClassifiedDevProcess(
process: process,
classification: DevProcessClassification(
kind: .javascript,
displayName: process.executableName,
projectHint: nil,
tags: []
)
)
}

let live = ProcessPresentation.liveScopedTreeInputs(
processes: processes,
classifiedProcesses: classified,
liveProcessIDs: [10, 11]
)
let family = ProcessPresentation.familySummary(for: liveRoot, in: live.processes)

XCTAssertEqual(live.processes.map(\.pid), [10, 11])
XCTAssertEqual(live.classifiedProcesses.map(\.process.pid), [10, 11])
XCTAssertEqual(family.childCount, 1)
XCTAssertEqual(family.descendantCount, 1)
}

func testBuildsDashboardStatsForVisibleProcesses() {
let totalItems = [
classified(
Expand Down