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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- The `programa codex install-hooks` and `uninstall-hooks` legacy aliases. Use `programa codex install-integration` and `uninstall-integration`.

### Fixed
- Launching a second build of Programa no longer cuts the running app's command line tool and agent hooks off. The new copy used to delete the running app's socket and take it over.
- Dragging the sidebar edge resizes the sidebar again instead of moving the whole window.
- The Window menu's Fill, Center, Move & Resize, Full Screen Tile and Move to Display items, and their shortcuts, work again. The main window had been marked not movable, which makes macOS disable all of them.
- The notifications popover now opens in minimal mode, the default layout. Show Notifications (⌘I), the sidebar bell and the menu bar icon's Show Notifications did nothing there.
Expand Down
20 changes: 18 additions & 2 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,19 @@ class TerminalController {
if let errnoCode = ensureSocketParentDirectoryExists(path: path) {
return .failure(path: path, stage: "create_directory", errnoCode: errnoCode)
}
// Another running app (a second build, a bench, a tagged app on the default path) may be
// serving this path; unlinking it would silently cut that app's CLI and agent hooks off.
// Only a path proven stale or missing is unlinked. An inconclusive probe (EMFILE, EACCES,
// a listener owned by another uid) fails as a bind error so the default path falls back.
switch UnixDomainFDPassing.probeConnection(to: path) {
case .stale, .missing:
break
case .live(let probeFD):
close(probeFD)
return .failure(path: path, stage: "bind", errnoCode: EADDRINUSE)
case .indeterminate(let probeErrno):
return .failure(path: path, stage: "bind", errnoCode: probeErrno)
}
if unlink(path) != 0, errno != ENOENT {
return .failure(path: path, stage: "unlink", errnoCode: errno)
}
Expand Down Expand Up @@ -1001,8 +1014,11 @@ class TerminalController {
case .pathTooLong:
close(newServerSocket)
return
case .failure:
NSLog("TerminalController: Failed to bind socket")
case .failure(let failedPath, let failedStage, let failedErrnoCode):
NSLog(
"TerminalController: Failed to bind socket path=%@ stage=%@ errno=%d",
failedPath, failedStage, failedErrnoCode
)
close(newServerSocket)
return
}
Expand Down
38 changes: 38 additions & 0 deletions programaTests/TerminalControllerSocketSecurityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,44 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
)
}

func testStartLeavesALiveListenerFromAnotherProcessInPlace() throws {
let path = makeSocketPath("live-owner")
defer { try? FileManager.default.removeItem(atPath: path) }

// Another app is serving the path.
let foreignListener = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertGreaterThanOrEqual(foreignListener, 0)
defer { Darwin.close(foreignListener) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
strncpy(UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), ptr, maxLength - 1)
}
}
let bound = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(foreignListener, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
XCTAssertEqual(bound, 0)
XCTAssertEqual(listen(foreignListener, 4), 0)
let foreignInode = try XCTUnwrap(
FileManager.default.attributesOfItem(atPath: path)[.systemFileNumber] as? UInt64
)

TerminalController.shared.start(tabManager: TabManager(), socketPath: path, accessMode: .allowAll)
defer { TerminalController.shared.stop() }

XCTAssertEqual(
try FileManager.default.attributesOfItem(atPath: path)[.systemFileNumber] as? UInt64,
foreignInode,
"start must not unlink and rebind a path another process is listening on"
)
XCTAssertTrue(SocketConnectProbe.canConnect(at: path, timeout: 0.5))
}

#if DEBUG
func testProgramaOnlyRejectsSameUserWhenPeerProcessCannotBeVerified() throws {
TerminalController.shared.setSocketPeerPIDProviderForTesting { _ in nil }
Expand Down
Loading