Skip to content
Open
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
39 changes: 36 additions & 3 deletions app/Roster/Data/ProcessLiveness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int32>.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<Int32>.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
Comment on lines +98 to +101
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..<stop], as: UTF8.self)
}
guard let execPath = nextString() else { return nil }
while index < end, buffer[index] == 0 { index += 1 } // skip NUL padding
var parts = [execPath]
var remaining = max(0, argc)
while remaining > 0, let argument = nextString() {
parts.append(argument)
remaining -= 1
}
return parts.joined(separator: " ")
}
}
55 changes: 55 additions & 0 deletions app/RosterTests/ProcessLivenessTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
1 change: 1 addition & 0 deletions app/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down