diff --git a/app/Roster/Data/ProcessLiveness.swift b/app/Roster/Data/ProcessLiveness.swift index 93b1b6f..4daf6d5 100644 --- a/app/Roster/Data/ProcessLiveness.swift +++ b/app/Roster/Data/ProcessLiveness.swift @@ -80,8 +80,41 @@ enum ProcessLiveness { guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return nil } var buffer = [UInt8](repeating: 0, count: size) guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil } - let strings = buffer.dropFirst(MemoryLayout.size) - .map { $0 == 0 ? UInt8(ascii: " ") : $0 } - return String(decoding: strings, as: UTF8.self) + return parseArguments(from: buffer) + } + + /// Exec path + argv from a KERN_PROCARGS2 buffer — stopping BEFORE the + /// environment. The layout is `argc` (Int32), the exec path, NUL padding, + /// then exactly `argc` argv strings, and only *then* the environment block. + /// The environment must not be part of the searchable string: a shell that + /// merely inherited `PATH=…/.claude/…` (near-universal once Claude Code is + /// installed) is not a `claude` process, and matching its env would keep + /// dead desks alive and resurrect just-quit sessions — the exact "hearsay" + /// this type exists to rule out. The previous version mapped every NUL to a + /// space and returned the whole remainder, so it searched envp too. + static func parseArguments(from buffer: [UInt8]) -> String? { + let headerSize = MemoryLayout.size + guard buffer.count > headerSize else { return nil } + // argc is a little-endian Int32 (every Apple platform is little-endian). + let argc = Int(buffer[0]) | Int(buffer[1]) << 8 + | Int(buffer[2]) << 16 | Int(buffer[3]) << 24 + var index = headerSize + let end = buffer.count + func nextString() -> String? { + guard index < end else { return nil } + var stop = index + while stop < end, buffer[stop] != 0 { stop += 1 } + defer { index = stop + 1 } + return String(decoding: buffer[index.. 0, let argument = nextString() { + parts.append(argument) + remaining -= 1 + } + return parts.joined(separator: " ") } } diff --git a/app/RosterTests/ProcessLivenessTests.swift b/app/RosterTests/ProcessLivenessTests.swift new file mode 100644 index 0000000..6d4c948 --- /dev/null +++ b/app/RosterTests/ProcessLivenessTests.swift @@ -0,0 +1,55 @@ +import XCTest + +/// The pure parse of a KERN_PROCARGS2 buffer. The live sysctl/libproc path is +/// not exercised — only the argc / exec-path / argv / env splitting, which is +/// where the searchable command-line string is built. +final class ProcessLivenessTests: XCTestCase { + + /// Builds a KERN_PROCARGS2-shaped buffer: `argc` (little-endian Int32), the + /// exec path, NUL padding, `argv.count` argv strings, then the environment. + private func procArgs( + execPath: String, argv: [String], env: [String] + ) -> [UInt8] { + var bytes: [UInt8] = [] + withUnsafeBytes(of: Int32(argv.count).littleEndian) { + bytes.append(contentsOf: $0) + } + bytes.append(contentsOf: Array(execPath.utf8)); bytes.append(0) + bytes.append(contentsOf: [0, 0, 0]) // padding + for arg in argv { bytes.append(contentsOf: Array(arg.utf8)); bytes.append(0) } + for entry in env { bytes.append(contentsOf: Array(entry.utf8)); bytes.append(0) } + return bytes + } + + func testParseArgumentsStopsBeforeTheEnvironment() { + // A plain login shell whose inherited PATH mentions ~/.claude — the + // near-universal case on a Claude Code user's machine, with no `claude` + // process actually running. + let buffer = procArgs( + execPath: "/bin/zsh", + argv: ["-zsh"], + env: ["PATH=/Users/me/.claude/local/bin:/usr/bin", "PWD=/repo"] + ) + let parsed = ProcessLiveness.parseArguments(from: buffer) + XCTAssertEqual(parsed, "/bin/zsh -zsh") + // The environment must not leak into the searchable string, or a shell + // that merely inherited a claude-flavoured PATH would look like a live + // `claude` CLI and keep a dead desk alive. + XCTAssertFalse( + parsed?.contains("claude") ?? true, + "the environment block must not be searched" + ) + } + + func testParseArgumentsStillFindsARealCLI() { + let buffer = procArgs( + execPath: "/Users/me/.claude/local/claude", + argv: ["claude", "--resume"], + env: ["PWD=/repo"] + ) + XCTAssertTrue( + ProcessLiveness.parseArguments(from: buffer)?.contains("claude") ?? false, + "a real claude process is still detected" + ) + } +} diff --git a/app/project.yml b/app/project.yml index dc3df9c..afff2c7 100644 --- a/app/project.yml +++ b/app/project.yml @@ -138,6 +138,7 @@ targets: - path: Roster/Data/ClaudeConfigRoots.swift - path: Roster/Data/CursorTranscripts.swift - path: Roster/Data/WorkspaceActions.swift + - path: Roster/Data/ProcessLiveness.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.lndev.roster.tests