Skip to content

Commit e1a4bec

Browse files
committed
Improve CLI env delivery and updater flow
1 parent 7525139 commit e1a4bec

19 files changed

Lines changed: 382 additions & 39 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file is for coding agents and maintainers working in this repository. Keep
88

99
The macOS engineering skills used by this repository are vendored in `.agents/skills/` and are tracked as project files. When the runtime lists those skills with the `r7` root, expand `r7` to this repository's `.agents/skills` directory. Do not look for these project-local skills under Codex plugin cache paths such as `~/.codex/plugins/cache/openai-primary-runtime`.
1010

11-
Current release metadata: `0.1.0 alpha`. This is a pre-stable product; breaking changes to local install state, registry format, CLI UX, and trust policy are acceptable when they improve security or clarity.
11+
Current release metadata: `0.1.3`. This is a pre-stable product; breaking changes to local install state, registry format, CLI UX, and trust policy are acceptable when they improve security or clarity.
1212

1313
## Product Boundary
1414

Docs/OPERATIONS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Install from the current checkout:
3737

3838
Update by running the install command again from the desired commit. The script rebuilds, ad-hoc signs, validates, stops any running Agentic Secrets UI, copies the app bundle, refreshes command symlinks, rewrites the install manifest, restarts the broker daemon when it was already loaded or `--load` is passed, waits for broker IPC health after restart, and opens the installed app when using the default user-local prefix or when the UI was running before the update. Use `--no-open` for scripted updates.
3939

40+
The app update button first asks whether to open the release page or update now. **Update Now** downloads the GitHub release source archive into a per-run temporary directory, validates that `version.env` matches the selected release version, and runs the downloaded `scripts/install_local.sh` with `--load`. The updater passes `--cleanup-path` so the installer removes the downloaded temporary directory after a successful install, even though the running app may close during replacement. It intentionally does not pass `--configure-shell`; update installs should not append duplicate PATH blocks to shell startup files. Use the guided local install or repair flow when shell configuration needs to be changed.
41+
4042
Smoke-test installed IPC:
4143

4244
```sh
@@ -138,6 +140,14 @@ Use `--quiet` before `--` for scripts that do not want AgenticSecrets diagnostic
138140
"$PREFIX/bin/agentic-secrets" cli run supabase --quiet -- db pull
139141
```
140142

143+
Pass non-secret per-run environment values before `--`:
144+
145+
```sh
146+
"$PREFIX/bin/agentic-secrets" cli run supabase --env FOO=bar -- projects list
147+
```
148+
149+
`cli run` builds a fresh child environment. Inherited secret-like variables are scrubbed, explicit `--env NAME=VALUE` entries override inherited values, and registered secret deliveries override both for their bound environment names. This means an ambient `SUPABASE_*` value does not block approved delivery; the broker rewrites that variable for the child process only.
150+
141151
Unregister the CLI app and delete its local secret records:
142152

143153
```sh
@@ -146,7 +156,7 @@ Unregister the CLI app and delete its local secret records:
146156

147157
Registration stores non-secret metadata in `var/agentic-secrets/cli-registry.json` and encrypted secret records under `var/agentic-secrets/secrets/`. Registry files are owner-only and must not contain plaintext token material. The registry is also paired with `var/agentic-secrets/cli-registry.integrity.json`; that sidecar is signed with an HMAC-SHA256 key stored in the user's macOS Keychain using `WhenUnlockedThisDeviceOnly` accessibility. If the registry or sidecar is edited outside Agentic Secrets, `cli run` fails before local authentication and before any secret-store read.
148158

149-
During `cli run`, the front-end CLI still does not resolve the secret; `agentic-secrets-brokerd` resolves it after local authentication, scrubs inherited secret-like environment variables, and injects the registered environment variables only into the child process.
159+
During `cli run`, the front-end CLI still does not resolve the secret; `agentic-secrets-brokerd` resolves it after local authentication, scrubs inherited secret-like environment variables, and injects the registered environment variables only into the child process. Registered non-generic CLIs may receive env delivery and reuse approval according to the configured authorization mode even when no versioned command adapter classifies the exact command. Generic runners such as shells, package managers, and language runtimes still deny raw env secret delivery by default.
150160

151161
After a successful local authentication prompt, Secret Broker writes an HMAC-signed CLI authorization grant under Agentic Secrets state. The default mode is `always`, which does not expire. `remember-24h` expires after 24 hours, `short` uses the 300 second default TTL with a 900 second maximum, and `once` disables reuse. Grants contain no secret material. Persistent grants are signed with a device-local macOS Keychain key and scoped to CLI name, target identity, workspace hash, config context, untrusted origin hint, provenance confidence, delivery mode, and secret alias. Short grants additionally bind action class, command digest, and risk. Matching runs reuse the grant and skip the LocalAuthentication prompt; non-matching runs prompt again. Each command is still policy-checked before secret delivery, and destructive commands require fresh approval.
152162

ROADMAP.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ narrow, approved, bounded, auditable, and fail-closed.
1515
3. Native workflows for OpenClaw, Hermes Agent, and other autonomous agents.
1616
4. Rework API Sessions proxy delivery with a clear UX and verified end-to-end tests.
1717
5. Finish Bitwarden Secrets preview with guided setup and tested provider flows.
18+
6. Replace the self-build source-archive updater with a signed Sparkle-based
19+
production updater when Developer ID distribution is ready.
1820

1921
## Direction 1: Cross-Platform Support With a Reusable Core
2022

@@ -259,6 +261,38 @@ Acceptance criteria:
259261
- UI smoke and contract tests cover happy path and major failure paths before
260262
Bitwarden is presented as a stable feature.
261263

264+
## Direction 6: Sparkle-Based Production Updates
265+
266+
Goal: replace the current self-build source-archive update flow with a
267+
production-grade macOS updater based on
268+
[sparkle-project/Sparkle](https://github.com/sparkle-project/Sparkle) when
269+
Developer ID signing and notarized downloadable releases enter scope.
270+
271+
The current update button is intentionally a self-build track: it downloads a
272+
GitHub release source archive, verifies `version.env`, and runs the local
273+
installer. That is acceptable for source-built alpha users, but it should not be
274+
presented as the final binary distribution updater.
275+
276+
Future Sparkle work should:
277+
278+
1. Add Sparkle 2 with signed appcasts and EdDSA update signatures.
279+
2. Publish notarized Developer ID-signed `.zip`, `.dmg`, or package artifacts.
280+
3. Preserve current explicit update UX: open release notes, update now, or
281+
cancel.
282+
4. Keep local state, secret material, LaunchAgent state, helper symlinks, and
283+
install manifest behavior compatible with the current self-build installer.
284+
5. Add tests for update verification failures, interrupted installs, rollback
285+
prevention, and post-update broker IPC health.
286+
287+
Acceptance criteria:
288+
289+
- Update artifacts are cryptographically signed and served over HTTPS.
290+
- Gatekeeper, code signing, notarization, and Sparkle signature checks are all
291+
part of release evidence.
292+
- The app can update without duplicating shell PATH blocks or weakening local
293+
secret-store protections.
294+
- A failed update leaves the previous installed app and broker state usable.
295+
262296
## Roadmap Principles
263297

264298
- Keep the secret authority small and platform-explicit.

Sources/App/App/AgenticSecretsApp.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,12 @@ struct AgenticSecretsApp: App {
6868

6969
Button(store.availableUpdate == nil ? "Check for Updates" : store.updateMenuTitle) {
7070
if let update = store.availableUpdate {
71-
ExternalURLOpener.open(update.htmlURL, label: "Agentic Secrets update", store: store)
71+
UpdatePromptPresenter.handle(update: update, store: store)
7272
} else {
7373
Task { await store.checkForUpdates(manual: true) }
7474
}
7575
}
76-
.disabled(store.isCheckingForUpdates)
76+
.disabled(store.isCheckingForUpdates || store.isInstallingUpdate)
7777

7878
Button("Open Diagnostics") {
7979
openDiagnostics(store: store)
@@ -348,12 +348,14 @@ private struct MenuBarActions: View {
348348
Text(store.menuBarSummary)
349349
if let update = store.availableUpdate {
350350
Button(store.updateMenuTitle) {
351-
ExternalURLOpener.open(update.htmlURL, label: "Agentic Secrets update", store: store)
351+
UpdatePromptPresenter.handle(update: update, store: store)
352352
}
353+
.disabled(store.isInstallingUpdate)
353354
if !update.critical {
354355
Button("Ignore Update") {
355356
store.ignoreAvailableUpdate()
356357
}
358+
.disabled(store.isInstallingUpdate)
357359
}
358360
Divider()
359361
}

Sources/App/Services/AppUpdateChecker.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,20 @@ struct AppUpdateRelease: Codable, Equatable, Identifiable, Sendable {
3434
var name: String
3535
var prerelease: Bool
3636
var htmlURL: URL
37+
var sourceArchiveURL: URL?
3738
var body: String
3839

3940
var id: String { htmlURL.absoluteString }
4041

42+
init(tagName: String, name: String, prerelease: Bool, htmlURL: URL, sourceArchiveURL: URL? = nil, body: String) {
43+
self.tagName = tagName
44+
self.name = name
45+
self.prerelease = prerelease
46+
self.htmlURL = htmlURL
47+
self.sourceArchiveURL = sourceArchiveURL
48+
self.body = body
49+
}
50+
4151
var version: AppSemanticVersion {
4252
AppSemanticVersion(tagName.isEmpty ? name : tagName)
4353
}
@@ -132,6 +142,7 @@ private struct GitHubReleaseDTO: Decodable {
132142
var prerelease: Bool
133143
var draft: Bool
134144
var htmlURL: URL
145+
var zipballURL: URL?
135146
var body: String?
136147

137148
enum CodingKeys: String, CodingKey {
@@ -140,6 +151,7 @@ private struct GitHubReleaseDTO: Decodable {
140151
case prerelease
141152
case draft
142153
case htmlURL = "html_url"
154+
case zipballURL = "zipball_url"
143155
case body
144156
}
145157
}
@@ -151,6 +163,7 @@ private extension AppUpdateRelease {
151163
name: release.name ?? release.tagName,
152164
prerelease: release.prerelease || release.draft,
153165
htmlURL: release.htmlURL,
166+
sourceArchiveURL: release.zipballURL,
154167
body: release.body ?? ""
155168
)
156169
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import Foundation
2+
3+
protocol AppUpdateInstalling: Sendable {
4+
func install(update: AppUpdateRelease) async throws
5+
}
6+
7+
struct SourceArchiveAppUpdateInstaller: AppUpdateInstalling {
8+
var session: URLSession = .shared
9+
10+
func install(update: AppUpdateRelease) async throws {
11+
guard let archiveURL = update.sourceArchiveURL else {
12+
throw AppUpdateInstallError.missingArchive
13+
}
14+
15+
let fileManager = FileManager.default
16+
let workDirectory = fileManager.temporaryDirectory
17+
.appendingPathComponent("agentic-secrets-update-\(UUID().uuidString)", isDirectory: true)
18+
let archive = workDirectory.appendingPathComponent("source.zip")
19+
let extractDirectory = workDirectory.appendingPathComponent("source", isDirectory: true)
20+
let logURL = workDirectory.appendingPathComponent("install.log")
21+
22+
try fileManager.createDirectory(at: extractDirectory, withIntermediateDirectories: true)
23+
do {
24+
let (downloadedURL, response) = try await session.download(from: archiveURL)
25+
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
26+
throw AppUpdateInstallError.downloadHTTPStatus(http.statusCode)
27+
}
28+
try fileManager.moveItem(at: downloadedURL, to: archive)
29+
try await runProcess(
30+
executable: URL(fileURLWithPath: "/usr/bin/ditto"),
31+
arguments: ["-x", "-k", archive.path, extractDirectory.path],
32+
currentDirectory: nil,
33+
logURL: logURL
34+
)
35+
let sourceRoot = try findSourceRoot(in: extractDirectory)
36+
try validateVersion(update: update, sourceRoot: sourceRoot)
37+
try await runProcess(
38+
executable: sourceRoot.appendingPathComponent("scripts/install_local.sh"),
39+
arguments: ["--load", "--cleanup-path", workDirectory.path],
40+
currentDirectory: sourceRoot,
41+
logURL: logURL
42+
)
43+
try? fileManager.removeItem(at: workDirectory)
44+
} catch {
45+
throw AppUpdateInstallError.failed(error, logURL: logURL)
46+
}
47+
}
48+
49+
private func findSourceRoot(in directory: URL) throws -> URL {
50+
let fileManager = FileManager.default
51+
let contents = try fileManager.contentsOfDirectory(
52+
at: directory,
53+
includingPropertiesForKeys: [.isDirectoryKey],
54+
options: [.skipsHiddenFiles]
55+
)
56+
for candidate in contents {
57+
let script = candidate.appendingPathComponent("scripts/install_local.sh")
58+
let version = candidate.appendingPathComponent("version.env")
59+
if fileManager.isExecutableFile(atPath: script.path),
60+
fileManager.fileExists(atPath: version.path) {
61+
return candidate
62+
}
63+
}
64+
throw AppUpdateInstallError.sourceRootNotFound
65+
}
66+
67+
private func validateVersion(update: AppUpdateRelease, sourceRoot: URL) throws {
68+
let versionFile = sourceRoot.appendingPathComponent("version.env")
69+
let text = try String(contentsOf: versionFile, encoding: .utf8)
70+
let expected = update.versionLabel
71+
guard text.components(separatedBy: .newlines).contains("MARKETING_VERSION=\(expected)") else {
72+
throw AppUpdateInstallError.versionMismatch(expected: expected)
73+
}
74+
}
75+
76+
private func runProcess(executable: URL, arguments: [String], currentDirectory: URL?, logURL: URL) async throws {
77+
try await Task.detached(priority: .userInitiated) {
78+
FileManager.default.createFile(atPath: logURL.path, contents: nil)
79+
let logHandle = try FileHandle(forWritingTo: logURL)
80+
defer { try? logHandle.close() }
81+
82+
let process = Process()
83+
process.executableURL = executable
84+
process.arguments = arguments
85+
process.currentDirectoryURL = currentDirectory
86+
process.standardOutput = logHandle
87+
process.standardError = logHandle
88+
try process.run()
89+
process.waitUntilExit()
90+
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
91+
throw AppUpdateInstallError.processFailed(
92+
executable.lastPathComponent,
93+
status: process.terminationStatus
94+
)
95+
}
96+
}.value
97+
}
98+
}
99+
100+
enum AppUpdateInstallError: Error, CustomStringConvertible {
101+
case missingArchive
102+
case downloadHTTPStatus(Int)
103+
case sourceRootNotFound
104+
case versionMismatch(expected: String)
105+
case processFailed(String, status: Int32)
106+
case failed(Error, logURL: URL)
107+
108+
var description: String {
109+
switch self {
110+
case .missingArchive:
111+
"This release does not expose a downloadable source archive."
112+
case .downloadHTTPStatus(let status):
113+
"Update download failed with HTTP \(status)."
114+
case .sourceRootNotFound:
115+
"Downloaded archive does not contain the Agentic Secrets installer."
116+
case .versionMismatch(let expected):
117+
"Downloaded source does not match expected version \(expected)."
118+
case .processFailed(let command, let status):
119+
"\(command) exited with status \(status)."
120+
case .failed(let error, let logURL):
121+
"\(error). Installer log: \(logURL.path)"
122+
}
123+
}
124+
}

Sources/App/Services/UISmokeRunner.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,13 +1048,18 @@ enum UISmokeRunner {
10481048
client: SequenceControlPlaneClient(snapshots: [emptySnapshot()]),
10491049
brokerController: StubBrokerStatusController(statusValue: healthyBrokerStatus()),
10501050
updateChecker: StubAppUpdateChecker(update: latest),
1051+
updateInstaller: StubAppUpdateInstaller(),
10511052
updateIgnoreDefaults: ignoredDefaults
10521053
)
10531054
await store.refresh()
10541055
await store.checkForUpdates(manual: true)
10551056
try expect(store.availableUpdate == latest, "update checker stores available release")
10561057
try expect(store.updateMenuTitle == "Update 9.0.0", "update menu title includes latest version")
10571058
try expect(store.successMessage == "Agentic Secrets 9.0.0 is available", "manual update check reports available release")
1059+
await store.installUpdate(latest)
1060+
try expect(store.availableUpdate == nil, "successful update install clears available release")
1061+
try expect(store.successMessage == "Agentic Secrets 9.0.0 installed", "successful update install reports installed release")
1062+
store.availableUpdate = latest
10581063
try verifyHostingLayout(
10591064
DetailView(store: store),
10601065
width: 1180,
@@ -1075,6 +1080,7 @@ enum UISmokeRunner {
10751080
client: SequenceControlPlaneClient(snapshots: [emptySnapshot()]),
10761081
brokerController: StubBrokerStatusController(statusValue: healthyBrokerStatus()),
10771082
updateChecker: StubAppUpdateChecker(update: critical),
1083+
updateInstaller: StubAppUpdateInstaller(),
10781084
updateIgnoreDefaults: ignoredDefaults
10791085
)
10801086
await criticalStore.checkForUpdates(manual: false)
@@ -1543,6 +1549,10 @@ private struct StubAppUpdateChecker: AppUpdateChecking {
15431549
}
15441550
}
15451551

1552+
private struct StubAppUpdateInstaller: AppUpdateInstalling {
1553+
func install(update: AppUpdateRelease) async throws {}
1554+
}
1555+
15461556
private enum UISmokeRunnerSnapshotFactory {
15471557
static func empty() -> ControlPlaneSnapshot {
15481558
ControlPlaneSnapshot(

0 commit comments

Comments
 (0)