Skip to content

Commit 4610365

Browse files
committed
Improve CLI registration management flow
1 parent 8101250 commit 4610365

10 files changed

Lines changed: 357 additions & 131 deletions

File tree

Docs/INSTALLATION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ PREFIX="$HOME/Library/Application Support/AgenticSecrets/LocalInstall"
127127
SOCKET="/tmp/agentic-secrets-core-smoke.sock"
128128
"$HOME/Applications/AgenticSecrets.app/Contents/MacOS/agentic-secrets-brokerd" serve-once \
129129
--socket "$SOCKET" \
130-
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json" &
130+
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json" \
131+
--state-dir "$PREFIX/var/agentic-secrets" &
131132
"$PREFIX/bin/agentic-secrets-shim" --ipc-health \
132133
--socket "$SOCKET" \
133134
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json"

Docs/OPERATIONS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ PREFIX="$HOME/Library/Application Support/AgenticSecrets/LocalInstall"
4444
SOCKET="/tmp/agentic-secrets-core-smoke.sock"
4545
"$HOME/Applications/AgenticSecrets.app/Contents/MacOS/agentic-secrets-brokerd" serve-once \
4646
--socket "$SOCKET" \
47-
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json" &
47+
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json" \
48+
--state-dir "$PREFIX/var/agentic-secrets" &
4849
"$PREFIX/bin/agentic-secrets-shim" --ipc-health \
4950
--socket "$SOCKET" \
5051
--manifest "$PREFIX/var/agentic-secrets/install-manifest.json"

Sources/App/Services/BrokerStatusController.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ struct LocalBrokerStatusController: BrokerStatusControlling {
344344
)
345345
}
346346

347-
try launchAgentPlist(plan: plan).write(to: launchAgent, atomically: true, encoding: .utf8)
347+
try Self.launchAgentPlist(plan: plan).write(to: launchAgent, atomically: true, encoding: .utf8)
348348
try writeManifest(appDestination: appDestination, manifestURL: manifest)
349349

350350
_ = runProcess(executable: "/bin/launchctl", arguments: ["bootout", "gui/\(getuid())", launchAgent.path])
@@ -452,10 +452,11 @@ struct LocalBrokerStatusController: BrokerStatusControlling {
452452
}
453453
}
454454

455-
private func launchAgentPlist(plan: BrokerInstallPlan) -> String {
455+
static func launchAgentPlist(plan: BrokerInstallPlan) -> String {
456456
let daemonPath = "\(plan.appDestinationPath)/Contents/MacOS/agentic-secrets-brokerd".xmlEscaped
457457
let socketPath = plan.socketPath.xmlEscaped
458458
let manifestPath = plan.manifestPath.xmlEscaped
459+
let stateDirectoryPath = plan.stateDirectoryPath.xmlEscaped
459460
let stdoutPath = "\(plan.runDirectoryPath)/core.stdout.log".xmlEscaped
460461
let stderrPath = "\(plan.runDirectoryPath)/core.stderr.log".xmlEscaped
461462
return """
@@ -473,6 +474,8 @@ struct LocalBrokerStatusController: BrokerStatusControlling {
473474
<string>\(socketPath)</string>
474475
<string>--manifest</string>
475476
<string>\(manifestPath)</string>
477+
<string>--state-dir</string>
478+
<string>\(stateDirectoryPath)</string>
476479
</array>
477480
<key>RunAtLoad</key>
478481
<true/>

Sources/App/Services/UISmokeRunner.swift

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ enum UISmokeRunner {
2222

2323
@MainActor
2424
private static func run() async throws {
25+
cleanSmokeInstallPaths()
2526
try await testEmptyState()
2627
try await testSettingsLayout()
2728
try await testRegisterWizardValidation()
@@ -38,6 +39,7 @@ enum UISmokeRunner {
3839
try testManagedShellConfigurationCleanup()
3940
try await testContextActions()
4041
try await testManagementActions()
42+
try await testHealthyDaemonRetriesTransientSnapshotLoad()
4143
try await testUpdateChecking()
4244
try testAuditRelatedItemRouting()
4345
try await testActivationRefreshLoadsMissingSnapshot()
@@ -352,6 +354,7 @@ enum UISmokeRunner {
352354

353355
@MainActor
354356
private static func testDaemonUnavailableState() async throws {
357+
cleanSmokeInstallPaths()
355358
let store = ControlPlaneStore(
356359
client: ThrowingControlPlaneClient(),
357360
brokerController: StubBrokerStatusController(statusValue: unavailableBrokerStatus())
@@ -640,7 +643,11 @@ enum UISmokeRunner {
640643
@MainActor
641644
private static func testBrokerInstallPlanState() async throws {
642645
let plan = smokeInstallPlan(supported: true, missingExecutables: [])
643-
try? FileManager.default.removeItem(atPath: plan.prefixPath)
646+
let launchAgentPlist = LocalBrokerStatusController.launchAgentPlist(plan: plan)
647+
try expect(launchAgentPlist.contains("<string>--state-dir</string>"), "LaunchAgent passes explicit state directory to broker daemon")
648+
try expect(launchAgentPlist.contains("<string>\(plan.stateDirectoryPath)</string>"), "LaunchAgent state directory matches install plan")
649+
cleanSmokeInstallPaths()
650+
defer { cleanSmokeInstallPaths() }
644651
let installed = BrokerStatus(
645652
state: .unavailable,
646653
socketPath: plan.socketPath,
@@ -663,7 +670,6 @@ enum UISmokeRunner {
663670
try expect(!store.canOpenInstalledApp, "installed app command is unavailable before the app copy exists")
664671
await store.installOrRepairDaemon()
665672
try FileManager.default.createDirectory(atPath: plan.appDestinationPath, withIntermediateDirectories: true)
666-
defer { try? FileManager.default.removeItem(atPath: plan.prefixPath) }
667673
try expect(store.brokerStatus.message.contains("Open the installed copy"), "install result explains installed-copy handoff")
668674
try expect(store.bestDaemonAction == .openInstalledApp, "installed-copy handoff highlights open installed copy as the next action")
669675
try expect(store.canOpenInstalledApp, "installed app command becomes available when the app copy exists")
@@ -912,15 +918,16 @@ enum UISmokeRunner {
912918
updateChecker: StubAppUpdateChecker(update: latest),
913919
updateIgnoreDefaults: ignoredDefaults
914920
)
921+
await store.refresh()
915922
await store.checkForUpdates(manual: true)
916923
try expect(store.availableUpdate == latest, "update checker stores available release")
917924
try expect(store.updateMenuTitle == "Update 9.0.0", "update menu title includes latest version")
918925
try expect(store.successMessage == "Agentic Secrets 9.0.0 is available", "manual update check reports available release")
919926
try verifyHostingLayout(
920-
ContentView(store: store),
927+
DetailView(store: store),
921928
width: 1180,
922929
height: 760,
923-
label: "content view with update button"
930+
label: "detail view with update feedback"
924931
)
925932
store.ignoreAvailableUpdate()
926933
try expect(store.availableUpdate == nil, "noncritical update can be ignored")
@@ -1023,6 +1030,21 @@ enum UISmokeRunner {
10231030
try expect(store.brokerStatus.state == .healthy, "activation refresh keeps daemon status current after snapshot load")
10241031
}
10251032

1033+
@MainActor
1034+
private static func testHealthyDaemonRetriesTransientSnapshotLoad() async throws {
1035+
let store = ControlPlaneStore(
1036+
client: FlakySnapshotControlPlaneClient(
1037+
transientFailuresBeforeSuccess: 2,
1038+
snapshot: snapshot(cliNames: ["hcloud"])
1039+
),
1040+
brokerController: StubBrokerStatusController(statusValue: healthyBrokerStatus())
1041+
)
1042+
await store.refresh()
1043+
try expect(store.brokerStatus.state == .healthy, "transient snapshot retry starts from a healthy daemon")
1044+
try expect(store.snapshot?.cliRegistrations.first?.name == "hcloud", "healthy daemon refresh retries transient snapshot IPC failures")
1045+
try expect(store.errorMessage == nil, "transient snapshot IPC recovery does not leave a stale error")
1046+
}
1047+
10261048
@MainActor
10271049
private static func testSelectionSurvivesRefresh() async throws {
10281050
let first = snapshot(cliNames: ["hcloud", "gh"])
@@ -1196,6 +1218,11 @@ enum UISmokeRunner {
11961218
currentAppIsInstalledCopy: false
11971219
)
11981220
}
1221+
1222+
private static func cleanSmokeInstallPaths() {
1223+
try? FileManager.default.removeItem(atPath: "/tmp/agentic-secrets-ui-smoke")
1224+
try? FileManager.default.removeItem(atPath: "/tmp/agentic-secrets-ui-smoke-home")
1225+
}
11991226
}
12001227

12011228
private enum SmokeError: Error, CustomStringConvertible {
@@ -1335,6 +1362,44 @@ private struct ThrowingControlPlaneClient: ControlPlaneClient {
13351362
func exportRedactedAuditJSON() async throws -> String { throw SmokeError.failed("unexpected audit") }
13361363
}
13371364

1365+
private actor FlakySnapshotControlPlaneClient: ControlPlaneClient {
1366+
private var remainingFailures: Int
1367+
private let snapshot: ControlPlaneSnapshot
1368+
1369+
init(transientFailuresBeforeSuccess: Int, snapshot: ControlPlaneSnapshot) {
1370+
self.remainingFailures = transientFailuresBeforeSuccess
1371+
self.snapshot = snapshot
1372+
}
1373+
1374+
func health() async throws {}
1375+
1376+
func loadSnapshot() async throws -> ControlPlaneSnapshot {
1377+
if remainingFailures > 0 {
1378+
remainingFailures -= 1
1379+
throw SmokeError.failed("socket(\"connect: No such file or directory\")")
1380+
}
1381+
return snapshot
1382+
}
1383+
1384+
func registerCLI(_ request: ControlPlaneCommandLineToolRegistrationRequest) async throws -> CLIRegistrationSummary { throw SmokeError.failed("unexpected register") }
1385+
func unregisterCLI(_ request: ControlPlaneNameRequest) async throws -> CLIRegistrationSummary { throw SmokeError.failed("unexpected unregister") }
1386+
func refreshCLITrust(_ request: ControlPlaneNameRequest) async throws -> CLIRegistrationSummary { throw SmokeError.failed("unexpected refresh trust") }
1387+
func replaceSecret(_ request: ControlPlaneSecretReplacementRequest) async throws -> ManagedSecretSummary { throw SmokeError.failed("unexpected replace") }
1388+
func deleteSecret(_ request: ControlPlaneSecretDeletionRequest) async throws { throw SmokeError.failed("unexpected delete") }
1389+
func upsertAPISessionProfile(_ profile: APISessionProfile) async throws -> APISessionProfileSummary { throw SmokeError.failed("unexpected proxy") }
1390+
func deleteAPISessionProfile(_ request: ControlPlaneNameRequest) async throws { throw SmokeError.failed("unexpected proxy delete") }
1391+
func upsertMCPProfile(_ profile: MCPUpstreamProfile) async throws -> MCPProfileSummary { throw SmokeError.failed("unexpected mcp") }
1392+
func deleteMCPProfile(_ request: ControlPlaneNameRequest) async throws { throw SmokeError.failed("unexpected mcp delete") }
1393+
func upsertBitwardenBinding(_ binding: BitwardenSecretBinding) async throws -> BitwardenBindingSummary { throw SmokeError.failed("unexpected bws") }
1394+
func deleteBitwardenBinding(_ request: ControlPlaneNameRequest) async throws { throw SmokeError.failed("unexpected bws delete") }
1395+
func installAdapter(_ payload: CommandPolicyPackPayload) async throws -> PolicyPackSummary { throw SmokeError.failed("unexpected adapter install") }
1396+
func revokeAdapter(_ request: ControlPlaneNameRequest) async throws { throw SmokeError.failed("unexpected command policy pack revoke") }
1397+
func updateCommandPolicy(_ request: ControlPlaneCommandPolicyUpdateRequest) async throws -> CommandPolicySummary { throw SmokeError.failed("unexpected command policy") }
1398+
func createAPISession(_ request: ControlPlaneAPISessionRequest) async throws -> ControlPlaneAPISessionResponse { throw SmokeError.failed("unexpected API session") }
1399+
func clearDeliveryGrants() async throws { throw SmokeError.failed("unexpected grants") }
1400+
func exportRedactedAuditJSON() async throws -> String { throw SmokeError.failed("unexpected audit") }
1401+
}
1402+
13381403
private struct StubAppUpdateChecker: AppUpdateChecking {
13391404
var update: AppUpdateRelease?
13401405

Sources/App/Stores/ControlPlaneStore.swift

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ final class ControlPlaneStore {
123123
private let brokerController: any BrokerStatusControlling
124124
private let updateChecker: any AppUpdateChecking
125125
private let updateIgnoreDefaults: UserDefaults
126+
private static let snapshotLoadRetryDelays: [Duration] = [
127+
.milliseconds(120),
128+
.milliseconds(250),
129+
.milliseconds(500)
130+
]
126131
@ObservationIgnored private var updateCheckTask: Task<Void, Never>?
127132

128133
init(
@@ -418,10 +423,9 @@ final class ControlPlaneStore {
418423
} catch {
419424
if brokerStatus.state == .unavailable || brokerStatus.state == .installing || brokerStatus.state == .repairing || brokerStatus.state == .uninstalling {
420425
await recoverFromStartupRaceIfPossible()
421-
} else if isDaemonReachabilityError(error) {
422-
await recoverFromTransientDaemonRefreshError()
423426
} else {
424-
errorMessage = userFacingError(error)
427+
snapshot = nil
428+
errorMessage = localStateLoadError(error)
425429
}
426430
}
427431
}
@@ -869,20 +873,17 @@ final class ControlPlaneStore {
869873
return description
870874
}
871875

872-
private func isDaemonReachabilityError(_ error: Error) -> Bool {
876+
private func localStateLoadError(_ error: Error) -> String {
873877
let description = String(describing: error)
874-
return description.contains("socket(") || description.contains("connect:")
878+
if isDaemonReachabilityError(error) {
879+
return "Daemon health check passed, but local state snapshot IPC failed after retrying. Restart or repair the local daemon. Last error: \(description)"
880+
}
881+
return "Local state could not be loaded: \(description)"
875882
}
876883

877-
private func recoverFromTransientDaemonRefreshError() async {
878-
errorMessage = nil
879-
brokerStatus = await brokerController.status()
880-
guard brokerStatus.state == .healthy else { return }
881-
do {
882-
try await loadSnapshotAfterHealthyStatus()
883-
} catch {
884-
errorMessage = isDaemonReachabilityError(error) ? nil : userFacingError(error)
885-
}
884+
private func isDaemonReachabilityError(_ error: Error) -> Bool {
885+
let description = String(describing: error)
886+
return description.contains("socket(") || description.contains("connect:")
886887
}
887888

888889
private func recoverFromStartupRaceIfPossible() async {
@@ -892,13 +893,15 @@ final class ControlPlaneStore {
892893
guard brokerStatus.state == .healthy else { return }
893894
do {
894895
try await loadSnapshotAfterHealthyStatus()
896+
errorMessage = nil
895897
} catch {
896-
errorMessage = isDaemonReachabilityError(error) ? nil : userFacingError(error)
898+
snapshot = nil
899+
errorMessage = localStateLoadError(error)
897900
}
898901
}
899902

900903
private func loadSnapshotAfterHealthyStatus() async throws {
901-
snapshot = try await client.loadSnapshot()
904+
snapshot = try await loadSnapshotWithTransientRetry()
902905
if brokerStatus.state != .healthy {
903906
brokerStatus = await brokerController.status()
904907
}
@@ -908,6 +911,26 @@ final class ControlPlaneStore {
908911
maintainSelections()
909912
}
910913

914+
private func loadSnapshotWithTransientRetry() async throws -> ControlPlaneSnapshot {
915+
var lastError: Error?
916+
let maxAttempts = Self.snapshotLoadRetryDelays.count + 1
917+
for attempt in 0..<maxAttempts {
918+
do {
919+
return try await client.loadSnapshot()
920+
} catch {
921+
guard isDaemonReachabilityError(error) else {
922+
throw error
923+
}
924+
lastError = error
925+
guard attempt < Self.snapshotLoadRetryDelays.count else {
926+
break
927+
}
928+
try? await Task.sleep(for: Self.snapshotLoadRetryDelays[attempt])
929+
}
930+
}
931+
throw lastError ?? StoreError.localStateSnapshotUnavailable
932+
}
933+
911934
private func commaList(_ value: String) -> [String] {
912935
value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
913936
}
@@ -1035,4 +1058,15 @@ final class ControlPlaneStore {
10351058
}
10361059
}
10371060
}
1061+
1062+
enum StoreError: Error, CustomStringConvertible {
1063+
case localStateSnapshotUnavailable
1064+
1065+
var description: String {
1066+
switch self {
1067+
case .localStateSnapshotUnavailable:
1068+
"Local state snapshot is unavailable."
1069+
}
1070+
}
1071+
}
10381072
}

0 commit comments

Comments
 (0)