Skip to content

Commit 77a70b2

Browse files
groundwaterclaude
andcommitted
Fix HTTP body parsing bug in HostAPIService and use dynamic bundle IDs
This commit fixes a critical bug where remote exec and other POST endpoints were failing, and makes GhostTools more resilient to bundle ID changes. **HTTP Body Parsing Bug Fix (HostAPIService.swift)**: - Fixed parseHTTPRequest to search for \r\n\r\n delimiter in raw bytes instead of UTF-8 string - Previous code used string character offsets as byte offsets into rawData, causing misalignment that included header data in the body - This caused JSON parsing to fail with errors like "Need command" or "Need action" even when valid JSON was sent - Fix: Use Data.range(of:) to search directly in rawData bytes **Dynamic Bundle ID (GhostTools)**: - Added bundleId computed property using Bundle.main.bundleIdentifier - Replaced all hardcoded bundle ID references with dynamic property - Affects: launch agent paths, launchctl commands, dispatch queue labels - Makes code resilient to future bundle ID changes **Enhanced Launch Agent Cleanup (GhostTools)**: - Added -w flag to launchctl unload to persist disabled state - Capture and log stderr from unload command - Better error handling with try/catch for plist removal - More detailed migration logging Verified with: vmctl remote exec /bin/ls /Applications Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
1 parent 5c679f8 commit 77a70b2

File tree

3 files changed

+36
-26
lines changed

3 files changed

+36
-26
lines changed

GhostTools/Sources/GhostTools/App.swift

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3838
private var updateTimer: Timer?
3939
private var lockFileHandle: FileHandle?
4040
// Use bundle ID from Info.plist, fallback to hardcoded for compatibility
41+
private var bundleId: String {
42+
Bundle.main.bundleIdentifier ?? "com.yellowgreenfruit.com.ghostvm.guest-tools"
43+
}
44+
4145
private lazy var lockFilePath: String = {
42-
let bundleId = Bundle.main.bundleIdentifier ?? "com.yellowgreenfruit.com.ghostvm.guest-tools"
4346
return NSTemporaryDirectory() + bundleId + ".lock"
4447
}()
4548

@@ -507,7 +510,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
507510
print("[GhostTools] Checking launch agent installation...")
508511
let launchAgentsDir = FileManager.default.homeDirectoryForCurrentUser
509512
.appendingPathComponent("Library/LaunchAgents")
510-
let plistPath = launchAgentsDir.appendingPathComponent("com.yellowgreenfruit.com.ghostvm.guest-tools.plist")
513+
let plistPath = launchAgentsDir.appendingPathComponent("\(bundleId).plist")
511514

512515
// MIGRATION: Remove old launch agents with old bundle IDs
513516
let oldPlistPaths = [
@@ -517,19 +520,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
517520

518521
for oldPlistPath in oldPlistPaths {
519522
if FileManager.default.fileExists(atPath: oldPlistPath.path) {
520-
print("[GhostTools] Found old launch agent at \(oldPlistPath.lastPathComponent), removing...")
523+
print("[GhostTools] === MIGRATION: Removing old launch agent ===")
524+
print("[GhostTools] Old plist: \(oldPlistPath.path)")
521525

522-
// Unload old launch agent
526+
// Unload from launchctl (ignore errors - may not be loaded)
523527
let unloadOld = Process()
524528
unloadOld.executableURL = URL(fileURLWithPath: "/bin/launchctl")
525-
unloadOld.arguments = ["unload", oldPlistPath.path]
529+
unloadOld.arguments = ["unload", "-w", oldPlistPath.path] // Add -w to write disabled state
530+
531+
let errorPipe = Pipe()
532+
unloadOld.standardError = errorPipe
533+
526534
try? unloadOld.run()
527535
unloadOld.waitUntilExit()
528-
print("[GhostTools] Unloaded old launch agent (exit status: \(unloadOld.terminationStatus))")
529536

530-
// Remove old plist
531-
try? FileManager.default.removeItem(at: oldPlistPath)
532-
print("[GhostTools] Removed old launch agent plist")
537+
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
538+
let errorStr = String(data: errorData, encoding: .utf8) ?? ""
539+
540+
print("[GhostTools] launchctl unload exit status: \(unloadOld.terminationStatus)")
541+
if !errorStr.isEmpty {
542+
print("[GhostTools] launchctl unload stderr: \(errorStr)")
543+
}
544+
545+
// Force remove the file
546+
do {
547+
try FileManager.default.removeItem(at: oldPlistPath)
548+
print("[GhostTools] Removed old plist successfully")
549+
} catch {
550+
print("[GhostTools] Failed to remove old plist: \(error)")
551+
}
533552
}
534553
}
535554

@@ -547,7 +566,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
547566
// Verify it's loaded in launchd
548567
let launchctlList = Process()
549568
launchctlList.executableURL = URL(fileURLWithPath: "/bin/launchctl")
550-
launchctlList.arguments = ["list", "com.yellowgreenfruit.com.ghostvm.guest-tools"]
569+
launchctlList.arguments = ["list", bundleId]
551570

552571
let outputPipe = Pipe()
553572
launchctlList.standardOutput = outputPipe
@@ -582,7 +601,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
582601

583602
// Create the plist content - always point to /Applications
584603
let plistContent: [String: Any] = [
585-
"Label": "com.yellowgreenfruit.com.ghostvm.guest-tools",
604+
"Label": bundleId,
586605
"ProgramArguments": [executablePath],
587606
"RunAtLoad": true,
588607
"KeepAlive": false

GhostTools/Sources/GhostTools/Server/EventPushServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ final class EventPushServer: @unchecked Sendable {
6262
private let clientLock = NSLock()
6363

6464
/// Serial queue for writes (preserves NDJSON line ordering)
65-
private let writeQueue = DispatchQueue(label: "com.yellowgreenfruit.com.ghostvm.guest-tools.eventpush.write")
65+
private let writeQueue = DispatchQueue(label: "\(Bundle.main.bundleIdentifier ?? "com.yellowgreenfruit.com.ghostvm.guest-tools").eventpush.write")
6666

6767
/// Called on main thread when a new host client connects.
6868
var onClientConnected: (() -> Void)?

GhostVMHelper/HostAPIService.swift

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,12 @@ final class HostAPIService {
181181
let path = parts.count > 1 ? parts[1] : "/"
182182

183183
// Extract body after \r\n\r\n
184-
if let range = str.range(of: "\r\n\r\n") {
185-
let headerEndIndex = str.distance(from: str.startIndex, to: range.upperBound)
184+
// IMPORTANT: Search in raw bytes, not the UTF-8 string, to avoid character/byte offset misalignment
185+
let delimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) // \r\n\r\n
186+
if let delimiterRange = rawData.range(of: delimiter) {
187+
let headerEndIndex = delimiterRange.upperBound
186188
if rawData.count > headerEndIndex {
187-
return (method, path, rawData[headerEndIndex...])
189+
return (method, path, Data(rawData[headerEndIndex...]))
188190
}
189191
}
190192
return (method, path, nil)
@@ -449,20 +451,9 @@ final class HostAPIService {
449451

450452
// Exec
451453
if cleanPath == "/api/v1/exec" && method == "POST" {
452-
// Debug logging
453-
if let body = body {
454-
print("[HostAPIService] Exec body size: \(body.count) bytes")
455-
if let bodyStr = String(data: body, encoding: .utf8) {
456-
print("[HostAPIService] Exec body: \(bodyStr)")
457-
}
458-
} else {
459-
print("[HostAPIService] Exec body is nil!")
460-
}
461-
462454
guard let body = body,
463455
let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
464456
let command = json["command"] as? String else {
465-
print("[HostAPIService] Failed to parse exec command")
466457
return .error(400, message: "Need command")
467458
}
468459
let resp = try await client.exec(

0 commit comments

Comments
 (0)