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
7 changes: 7 additions & 0 deletions docs/NATIVE_ONLY_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ Deliverables:
- `BrokerCoreTests` exercises the broker outcome paths via
`StubCredentialBroker` for `success` / `denied` / `canceled` /
`invalidResponse`, and asserts the broker error code mapping.
- `BrokerAutomation` exposes a narrow Shortcuts / AppleScript request surface
for `login` and `fill`. Automation builds the same broker envelope as the CLI
path and still routes credential material through the user-mediated broker.

**Phase 3 exit blockers still open**:

Expand All @@ -239,12 +242,16 @@ Deliverables:
- Map `pw get <domain>` to the new login flow where appropriate
- Remove unsupported commands from primary docs
- Preserve a migration guide for operators moving from parity APW to native-only
- Expose only mediated automation entrypoints for `login` and `fill` so
scripts can initiate broker requests without bypassing credential approval

Deliverables:

- clear CLI help text
- explicit migration notices
- browser/runtime code marked legacy
- Shortcuts actions and an AppleScript dictionary for the supported broker
operations

### Phase 5: packaging and release

Expand Down
4 changes: 4 additions & 0 deletions docs/SECURITY_POSTURE_AND_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Release reference version: `v2.0.0`
- the v2 app broker uses a same-user local UNIX socket under `~/.apw/native-app/`
- `status --json` exposes app/broker readiness while retaining legacy daemon diagnostics
- requests and responses use typed JSON envelopes with bounded payload sizes
- Shortcuts and AppleScript automation entrypoints build the same broker
request envelope as CLI `apw login` / `apw fill`; they do not read credential
material directly or bypass the broker's user-mediated response path
- bootstrap credentials are read from a local runtime file for the supported
demo domain only; the app does not create that plaintext file on default
launch
Expand Down Expand Up @@ -124,6 +127,7 @@ The Rust test suite covers:
- native app socket timeout handling
- native app diagnostics and `APW_DEMO=1` bootstrap credential file initialization
- end-to-end v2 app install, launch, status, doctor, and login flows
- Shortcuts / AppleScript automation envelope parity for `login` and `fill`
- direct-exec fallback, unsupported-domain handling, denial handling, and malformed broker response mapping
- a manual notarized-hardware validation contract for the Phase 3
AuthenticationServices broker flow
Expand Down
16 changes: 16 additions & 0 deletions native-app/Resources/APW.sdef
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
<dictionary title="APW Terminology">
<suite name="APW Automation" code="APWa" description="Mediated APW broker automation commands.">
<command name="request login" code="APWalgon" description="Request a login credential through the APW broker. The user still approves the credential picker before credential material is returned.">
<cocoa class="APWRequestLoginCommand"/>
<direct-parameter type="text" description="The HTTPS URL passed to APWRequestLoginCommand."/>
<result type="text" description="A JSON APW broker response envelope."/>
</command>
<command name="request fill" code="APWafill" description="Request a fill credential through the APW broker. The user still approves the credential picker before credential material is returned.">
<cocoa class="APWRequestFillCommand"/>
<direct-parameter type="text" description="The HTTPS URL passed to APWRequestFillCommand."/>
<result type="text" description="A JSON APW broker response envelope."/>
</command>
</suite>
</dictionary>
85 changes: 85 additions & 0 deletions native-app/Sources/APW/APWAppleScriptCommands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import AppKit
import Foundation
import NativeAppLib

enum APWAppleScriptCommandBridge {
static func perform(
operation: BrokerAutomationOperation,
command: NSScriptCommand
) -> Any? {
guard let url = command.directParameter as? String, !url.isEmpty else {
command.scriptErrorNumber = 1
command.scriptErrorString = "APW \(operation.rawValue) requires an HTTPS URL direct parameter."
return nil
}

let request = {
try BrokerAutomation.performResponseData(
operation: operation,
url: url,
requestId: "applescript-\(operation.rawValue)"
)
}

do {
let data: Data
if Thread.isMainThread {
data = try performOffMainThreadWhilePumpingRunLoop(request)
} else {
data = try request()
}
return String(decoding: data, as: UTF8.self)
} catch {
command.scriptErrorNumber = 1
command.scriptErrorString = "\(error)"
return nil
}
}

private static func performOffMainThreadWhilePumpingRunLoop(
_ request: @escaping () throws -> Data
) throws -> Data {
let result = AppleScriptCommandResultBox()

DispatchQueue.global(qos: .userInitiated).async {
result.set(Result(catching: request))
}

while result.value == nil {
RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01))
}

return try result.value!.get()
}
}

private final class AppleScriptCommandResultBox {
private let lock = NSLock()
private var storage: Result<Data, Error>?

var value: Result<Data, Error>? {
lock.lock()
defer { lock.unlock() }
return storage
}

func set(_ value: Result<Data, Error>) {
lock.lock()
storage = value
lock.unlock()
}
}

@objc(APWRequestLoginCommand)
final class APWRequestLoginCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
APWAppleScriptCommandBridge.perform(operation: .login, command: self)
}
}

@objc(APWRequestFillCommand)
final class APWRequestFillCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
APWAppleScriptCommandBridge.perform(operation: .fill, command: self)
}
}
83 changes: 83 additions & 0 deletions native-app/Sources/APW/APWAutomationIntents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#if canImport(AppIntents)
import AppIntents
import Foundation
import NativeAppLib

@available(macOS 13.0, *)
struct APWLoginIntent: AppIntent {
static var title: LocalizedStringResource = "Request APW Login"
static var description = IntentDescription(
"Requests a login credential through the APW broker. APW still requires user mediation before returning credential material."
)
static var openAppWhenRun = true

@Parameter(title: "URL")
var url: String

init() {}

init(url: String) {
self.url = url
}

func perform() async throws -> some IntentResult & ReturnsValue<String> {
let data = try await BrokerAutomation.performResponseDataAsync(
operation: .login,
url: url,
requestId: "shortcuts-login"
)
return .result(value: String(decoding: data, as: UTF8.self))
}
}

@available(macOS 13.0, *)
struct APWFillIntent: AppIntent {
static var title: LocalizedStringResource = "Request APW Fill"
static var description = IntentDescription(
"Requests a fill credential through the APW broker. APW still requires user mediation before returning credential material."
)
static var openAppWhenRun = true

@Parameter(title: "URL")
var url: String

init() {}

init(url: String) {
self.url = url
}

func perform() async throws -> some IntentResult & ReturnsValue<String> {
let data = try await BrokerAutomation.performResponseDataAsync(
operation: .fill,
url: url,
requestId: "shortcuts-fill"
)
return .result(value: String(decoding: data, as: UTF8.self))
}
}

@available(macOS 13.0, *)
struct APWShortcutsProvider: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: APWLoginIntent(),
phrases: [
"Request APW login with \(.applicationName)",
"Get APW login credential with \(.applicationName)",
],
shortTitle: "APW Login",
systemImageName: "key.fill"
)
AppShortcut(
intent: APWFillIntent(),
phrases: [
"Request APW fill with \(.applicationName)",
"Fill with APW using \(.applicationName)",
],
shortTitle: "APW Fill",
systemImageName: "text.cursor"
)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,7 @@ public protocol CredentialBroker {
let semaphore = DispatchSemaphore(value: 0)
var captured: CredentialBrokerResult = .failure(.unknown, "broker did not complete")

// ASAuthorizationController must be driven on the main thread. The
// broker server runs accept() on a worker thread, so we hop to
// main and block on a semaphore until the delegate fires.
DispatchQueue.main.async {
let startRequest = {
let request = ASAuthorizationPasswordProvider().createRequest()
let controller = ASAuthorizationController(authorizationRequests: [request])

Expand All @@ -111,9 +108,22 @@ public protocol CredentialBroker {
// Bound the sync wait so a hung credential picker cannot hold the
// broker forever. `brokerRequestTimeoutMs` is the same constant
// used for IPC. (issue #2)
let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs)
if semaphore.wait(timeout: timeout) == .timedOut {
if Thread.isMainThread {
startRequest()
let deadline = Date().addingTimeInterval(TimeInterval(brokerRequestTimeoutMs) / 1000.0)
while Date() < deadline {
if semaphore.wait(timeout: .now()) == .success {
return captured
}
RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05))
}
return .failure(.failed, "AuthenticationServices request timed out.")
} else {
DispatchQueue.main.async(execute: startRequest)
let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs)
if semaphore.wait(timeout: timeout) == .timedOut {
return .failure(.failed, "AuthenticationServices request timed out.")
}
}
return captured
}
Expand Down
Loading
Loading