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
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Show Xcode version
run: xcodebuild -version

- name: Verify dependency lock
run: node scripts/verify-dependency-lock.mjs

- name: Resolve ghostty SHA
id: ghostty
run: |
Expand Down Expand Up @@ -51,6 +54,8 @@ jobs:
-scheme Glint \
-configuration Debug \
-derivedDataPath build \
-disableAutomaticPackageResolution \
-onlyUsePackageVersionsFromResolvedFile \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
Expand All @@ -63,11 +68,15 @@ jobs:
- name: Test bundled CLI
run: python3 scripts/test-cli.py build/Build/Products/Debug/Glint.app/Contents/Helpers/glint

- name: Test Web Remote browser input
- name: Test Web Remote browser input and outbound queues
run: node --test scripts/test-web-remote-*.mjs

- name: Test Git and persistence regressions
run: node --test scripts/test-git-review.mjs scripts/test-persistence-recovery.mjs
- name: Test native logic regressions
run: >-
node --test scripts/test-git-review.mjs scripts/test-git-untracked.mjs
scripts/test-persistence-recovery.mjs scripts/test-workspace-lifecycle.mjs
scripts/test-control-lifecycle.mjs scripts/test-usage-refresh.mjs
scripts/test-http-lifecycle.mjs scripts/test-web-file-admission.mjs

- name: Test Glint
run: |
Expand All @@ -77,6 +86,8 @@ jobs:
-scheme GlintTests \
-configuration Debug \
-derivedDataPath build \
-disableAutomaticPackageResolution \
-onlyUsePackageVersionsFromResolvedFile \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Show Xcode version
run: xcodebuild -version

- name: Verify dependency lock
run: node scripts/verify-dependency-lock.mjs

- name: Resolve ghostty SHA
id: ghostty
run: |
Expand Down Expand Up @@ -85,6 +88,8 @@ jobs:
run: |
set -euo pipefail
xcodebuild \
-disableAutomaticPackageResolution \
-onlyUsePackageVersionsFromResolvedFile \
-project Glint.xcodeproj \
-scheme Glint \
-configuration Release \
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ xcuserdata/
# Swift Package Manager
.build/
Package.resolved
!Glint.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
.swiftpm/

# Build output of scripts/setup-ghosttykit.sh (zig builds it from the
Expand Down
4 changes: 2 additions & 2 deletions Glint.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1134,8 +1134,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.4;
kind = exactVersion;
version = 2.6.4;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 76 additions & 26 deletions Glint/Agent/ControlBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,23 @@ final class ControlBridge {
private(set) var socketPath: String = ""
private(set) var tokenPath: String = ""
private var token: String = ""
private let lifecycleLock = NSLock()
private var acceptedClients: Set<Int32> = []
private var listenFD: Int32 = -1
private var acceptSource: DispatchSourceRead?
private let queue = DispatchQueue(label: "glint.control.bridge", qos: .utility)
/// Whether the listener is currently bound. Guards double-start and makes
/// stop() a no-op when already off. Mutated only on the main thread (from
/// the externalControlEnabled toggle / app launch).
private(set) var isRunning = false
/// Lifecycle mutations run on main; accept/serve readers share this lock.
var isRunning: Bool {
lifecycleLock.lock()
defer { lifecycleLock.unlock() }
return sessionID != nil
}

private let runDirectory: URL
private let isEnabled: @MainActor () -> Bool
private var sessionID = UUID()
private var sessionID: UUID?

/// Configure private socket storage and the main-thread access gate; returns a stopped bridge.
init(runDirectory: URL? = nil,
isEnabled: @escaping @MainActor () -> Bool = { WorkspaceStore.current?.externalControlEnabled ?? false }) {
self.runDirectory = runDirectory ?? FileManager.default.homeDirectoryForCurrentUser
Expand Down Expand Up @@ -81,7 +86,9 @@ final class ControlBridge {
tokenPath = tokPath

// Fresh token every launch; the old one dies with the process.
lifecycleLock.lock()
token = Self.generateToken()
lifecycleLock.unlock()
writeToken(to: tokPath)

unlink(path)
Expand Down Expand Up @@ -120,31 +127,36 @@ final class ControlBridge {
return
}

listenFD = fd
let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
// The listener is nonblocking so a cancelled source cannot hang in a
// queued accept handler while stop waits for the lifecycle lock.
_ = fcntl(fd, F_SETFL, O_NONBLOCK)
let sessionID = UUID()
lifecycleLock.lock()
listenFD = fd
self.sessionID = sessionID
lifecycleLock.unlock()
let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
src.setEventHandler { [weak self] in self?.acceptOne(listenFD: fd, sessionID: sessionID) }
src.setCancelHandler { close(fd) }
src.resume()
acceptSource = src
isRunning = true
NSLog("[glint] control bridge listening on \(path)")
}

/// Stop listening and remove the socket + token so the
/// externalControlEnabled toggle can revoke access live — no app restart.
/// Safe when already stopped. Previously accepted connections can only
/// receive disabled errors, including after a subsequent restart.
/// No inputs or return value. Shutdown accepted sockets now; their serving
/// tasks retain close ownership to prevent descriptor reuse races.
func stop() {
guard isRunning else { return }
isRunning = false
lifecycleLock.lock()
guard sessionID != nil else { lifecycleLock.unlock(); return }
sessionID = nil
token = ""
listenFD = -1
for fd in acceptedClients { _ = shutdown(fd, SHUT_RDWR) }
lifecycleLock.unlock()
acceptSource?.cancel()
acceptSource = nil
// The cancel handler closes the captured listener after queued accept
// handlers finish, so a restart cannot reuse its fd underneath them.
listenFD = -1
token = ""
if !socketPath.isEmpty { unlink(socketPath) }
// Drop the token too — a fresh one is minted on the next start(), so a
// stale on-disk token can't authorize anything while we're off.
Expand Down Expand Up @@ -182,11 +194,12 @@ final class ControlBridge {
chmod(path, 0o600)
}

/// Constant-time compare — no early return on first mismatch.
private func tokenMatches(_ provided: String?) -> Bool {
guard let provided else { return false }
/// Compare supplied and captured live credentials; inputs are token strings;
/// returns whether they match without early byte exits.
private static func tokenMatches(_ provided: String?, expected: String) -> Bool {
guard let provided, !expected.isEmpty else { return false }
let a = Array(provided.utf8)
let b = Array(token.utf8)
let b = Array(expected.utf8)
var diff = UInt8(a.count == b.count ? 0 : 1)
let n = max(a.count, b.count)
for i in 0..<n {
Expand All @@ -197,24 +210,52 @@ final class ControlBridge {
return diff == 0
}

/// Check a command against a consistent session/token snapshot; inputs are
/// command, credential and accepting session; returns a wire error or nil.
private func authorizationError(command: String, token provided: String?, sessionID: UUID) -> String? {
lifecycleLock.lock()
let active = self.sessionID == sessionID
let expected = token
lifecycleLock.unlock()
guard active else { return "disabled" }
guard !Self.requiresToken(for: command) || Self.tokenMatches(provided, expected: expected) else {
return "unauthorized"
}
return nil
}

// MARK: accept / serve

/// Accept a socket for the captured listener/session; returns no value.
private func acceptOne(listenFD: Int32, sessionID: UUID) {
lifecycleLock.lock()
guard self.sessionID == sessionID, self.listenFD == listenFD else {
lifecycleLock.unlock(); return
}
let client = accept(listenFD, nil, nil)
guard client >= 0 else { return }
guard client >= 0 else { lifecycleLock.unlock(); return }
_ = fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK)
acceptedClients.insert(client)
lifecycleLock.unlock()
var noSigPipe: Int32 = 1
_ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
var tv = timeval(tv_sec: 5, tv_usec: 0)
_ = setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
_ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
DispatchQueue.global(qos: .utility).async { [weak self] in self?.serve(fd: client, sessionID: sessionID) }
DispatchQueue.global(qos: .utility).async { self.serve(fd: client, sessionID: sessionID) }
}

/// Runs off the accept queue. Each request line is parsed here, dispatched
/// onto the main queue (where the store + surfaces live), and the response
/// line written straight back on this connection.
/// Inputs: owned socket and accepting session. Returns nothing and closes the socket on all exits.
private func serve(fd: Int32, sessionID: UUID) {
defer { close(fd) }
defer {
lifecycleLock.lock()
acceptedClients.remove(fd)
close(fd)
lifecycleLock.unlock()
}
var buf = Data()
var tmp = [UInt8](repeating: 0, count: 4096)
while true {
Expand Down Expand Up @@ -252,12 +293,20 @@ final class ControlBridge {
}
}

/// Handle request bytes for an accepting session; returns JSON after the execution-time gate.
private func handle(line: Data, sessionID: UUID) -> Data {
guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any],
let cmd = obj["cmd"] as? String else {
return Self.errorResponse("bad-request")
}

// Invalid credentials must not wait behind modal UI or layout work.
// The same snapshot check runs again on main to order revocation before
// a request that passed this preflight but was queued during stop().
if let error = authorizationError(command: cmd, token: obj["token"] as? String, sessionID: sessionID) {
return Self.errorResponse(error)
}

var response = Self.errorResponse("unknown-cmd")
var readGrid: (pane: String, data: Data, scrollback: Bool, maxLines: Int)?
DispatchQueue.main.sync {
Expand All @@ -267,11 +316,12 @@ final class ControlBridge {
}
// Check on the main queue alongside start/stop: disabling access
// also revokes commands on already accepted connections.
guard self.isEnabled(), self.isRunning, self.sessionID == sessionID else {
guard self.isEnabled() else {
response = Self.errorResponse("disabled"); return
}
if Self.requiresToken(for: cmd) && !self.tokenMatches(obj["token"] as? String) {
response = Self.errorResponse("unauthorized"); return
if let error = self.authorizationError(command: cmd, token: obj["token"] as? String,
sessionID: sessionID) {
response = Self.errorResponse(error); return
}
switch cmd {
case "list":
Expand Down
Loading
Loading