Skip to content

Commit 19568ab

Browse files
committed
Fix agent shim PATH ordering
1 parent 0a60281 commit 19568ab

12 files changed

Lines changed: 180 additions & 54 deletions

Docs/INSTALLATION.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ The default prefix is `~/Library/Application Support/AgenticSecrets/LocalInstall
6060
./scripts/install_local.sh --load --configure-shell
6161
```
6262

63-
This recommended command installs the app at `~/Applications/AgenticSecrets.app`, keeps runtime files under the local install prefix, loads the LaunchAgent, waits for the broker daemon IPC health check, opens the installed app copy, and appends a guarded PATH block to your user shell config so future shell sessions can run `agentic-secrets` directly. Open a new terminal after installation, or run `source "$HOME/.zshrc"` in the current one.
63+
This recommended command installs the app at `~/Applications/AgenticSecrets.app`, keeps runtime files under the local install prefix, loads the LaunchAgent, waits for the broker daemon IPC health check, opens the installed app copy, and appends guarded PATH blocks to your user shell startup files so future shell sessions can run `agentic-secrets` directly. For zsh, the installer updates `.zshenv` for non-interactive tool runners, `.zprofile` for login shells, and `.zshrc` for interactive shells. Open a new terminal after installation, or source the relevant startup file in the current one.
6464

6565
For automation or CI where shell startup files must not be touched and the UI should not open, omit `--configure-shell` and pass `--no-open`:
6666

@@ -82,17 +82,17 @@ To add the installed commands to your current shell:
8282
export PATH="$HOME/Library/Application Support/AgenticSecrets/LocalInstall/bin:$PATH"
8383
```
8484

85-
To make the command available in future zsh sessions, add the same directory to your user shell config:
85+
To make the command available in future zsh sessions, add the same directory to your user shell startup files. Non-interactive tool runners read `.zshenv`; login shells read `.zprofile`; interactive terminal shells read `.zshrc`:
8686

8787
```sh
88-
cat >> "$HOME/.zshrc" <<'EOF'
88+
for file in "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.zshrc"; do
89+
cat >> "$file" <<EOF
8990
9091
# Agentic Secrets PATH
91-
case ":$PATH:" in
92-
*":$HOME/Library/Application Support/AgenticSecrets/LocalInstall/bin:"*) ;;
93-
*) export PATH="$HOME/Library/Application Support/AgenticSecrets/LocalInstall/bin:$PATH" ;;
94-
esac
92+
agentic_secrets_path_dir="$HOME/Library/Application Support/AgenticSecrets/LocalInstall/bin"
93+
export PATH="\$agentic_secrets_path_dir:\$PATH"
9594
EOF
95+
done
9696
```
9797

9898
The installer prints these next steps after every install. It opens the installed app by default for the standard user-local prefix; pass `--no-open` to suppress that, or `--open` to force opening when installing to an explicit custom prefix. It only edits shell startup files when `--configure-shell` is passed, and it appends without reading existing shell rc contents. It does not write to `/etc/paths.d`; this keeps the self-build installer user-local, reviewable, and reversible.
@@ -221,7 +221,7 @@ If you want the normal `hcloud ...` command to route through Agentic Secrets, in
221221
agentic-secrets cli shim install hcloud --configure-shell
222222
```
223223

224-
Open a new terminal so the shell picks up the shim PATH block, then verify command resolution:
224+
Open a new terminal or restart the tool runner so the shell picks up the shim PATH block, then verify command resolution. If Codex, Claude Code, or another agent app was already running during registration, restart that agent app before using `hcloud` from it.
225225

226226
```sh
227227
command -v hcloud

Sources/App/Services/BrokerStatusController.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,7 @@ struct LocalBrokerStatusController: BrokerStatusControlling {
598598
}
599599

600600
private static func managedShellConfigPaths() -> [URL] {
601-
let home = FileManager.default.homeDirectoryForCurrentUser
602-
return [".zshrc", ".bashrc", ".profile"].map { home.appendingPathComponent($0) }
601+
ShellStartupFilePolicy.managedConfigurationFiles()
603602
}
604603

605604
private static let installExecutables = [
@@ -683,7 +682,11 @@ enum ShellConfigurationCleaner {
683682
caseIndex = index + 1
684683
}
685684
guard caseIndex < lines.count,
686-
lines[caseIndex].trimmingCharacters(in: .whitespaces) == #"case ":$PATH:" in"# else { return nil }
685+
lines[caseIndex].trimmingCharacters(in: .whitespaces) == #"case ":$PATH:" in"# ||
686+
lines[caseIndex].trimmingCharacters(in: .whitespaces).hasPrefix("export PATH=") else { return nil }
687+
if lines[caseIndex].trimmingCharacters(in: .whitespaces).hasPrefix("export PATH=") {
688+
return caseIndex
689+
}
687690
var cursor = caseIndex + 1
688691
while cursor < lines.count {
689692
if lines[cursor].trimmingCharacters(in: .whitespaces) == "esac" {

Sources/App/Services/UISmokeRunner.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ enum UISmokeRunner {
2626
try await testEmptyState()
2727
try await testSettingsLayout()
2828
try await testRegisterWizardValidation()
29+
try await testSuccessfulCLIRegistrationGuidance()
2930
try testCLIShimInstallerEnvironment()
31+
try testShellStartupFilePolicy()
3032
try testCLIShimStatusPresentation()
3133
try testManagementEditorValidation()
3234
try testPasteboardCopy()
@@ -49,6 +51,20 @@ enum UISmokeRunner {
4951
try await testMenuBarStatusReflectsDaemonHealth()
5052
}
5153

54+
private static func testShellStartupFilePolicy() throws {
55+
let home = URL(fileURLWithPath: "/tmp/agentic-secrets-ui-shell-policy", isDirectory: true)
56+
let zshFiles = ShellStartupFilePolicy.defaultConfigurationFiles(
57+
homeDirectory: home,
58+
shellPath: "/bin/zsh"
59+
).map(\.lastPathComponent)
60+
try expect(zshFiles == [".zshenv", ".zprofile", ".zshrc"], "zsh shell configuration includes non-interactive, login, and interactive startup files")
61+
62+
let managedFiles = ShellStartupFilePolicy.managedConfigurationFiles(homeDirectory: home).map(\.lastPathComponent)
63+
try expect(managedFiles.contains(".zshenv"), "managed shell cleanup includes zsh non-interactive startup file")
64+
try expect(managedFiles.contains(".zprofile"), "managed shell cleanup includes zsh login startup file")
65+
try expect(managedFiles.contains(".bash_profile"), "managed shell cleanup includes bash login startup file")
66+
}
67+
5268
@MainActor
5369
private static func testSettingsLayout() async throws {
5470
let store = ControlPlaneStore(
@@ -148,6 +164,9 @@ enum UISmokeRunner {
148164

149165
private static func testRegisterWizardValidation() async throws {
150166
try expect(RegisterCLIFormDefaults.installShim, "register CLI defaults to installing a command shim")
167+
let restartNotice = AgentRestartNotice.afterCLIRegistration(cliName: "hcloud", shimInstalled: true)
168+
try expect(restartNotice.contains("Restart Codex"), "register success notice names the agent restart action")
169+
try expect(AgentRestartNotice.requiresManualDismiss(restartNotice), "agent restart notice stays visible until the user dismisses it")
151170
try expect(
152171
ExecutablePathSelection.inferredCLIName(from: URL(fileURLWithPath: "/opt/homebrew/bin/hcloud")) == "hcloud",
153172
"register CLI infers a CLI name from the selected executable path"
@@ -202,6 +221,23 @@ enum UISmokeRunner {
202221
]).isEmpty, "whitespace-only secret values are omitted from registration payload")
203222
}
204223

224+
@MainActor
225+
private static func testSuccessfulCLIRegistrationGuidance() async throws {
226+
let store = ControlPlaneStore(
227+
client: SequenceControlPlaneClient(snapshots: [snapshot(cliNames: ["hcloud"])]),
228+
brokerController: StubBrokerStatusController(statusValue: healthyBrokerStatus())
229+
)
230+
await store.refresh()
231+
let registered = await store.registerCLI(
232+
name: "hcloud",
233+
targetPath: "/bin/echo",
234+
environmentSecrets: ["HCLOUD_TOKEN": "synthetic-secret"],
235+
installShim: false
236+
)
237+
try expect(registered, "valid direct register submit succeeds")
238+
try expect(store.successMessage == AgentRestartNotice.afterCLIRegistration(cliName: "hcloud", shimInstalled: false), "successful registration tells the user to restart already-running agent apps")
239+
}
240+
205241
private static func testCLIShimInstallerEnvironment() throws {
206242
let prefix = URL(fileURLWithPath: "/tmp/agentic-secrets-custom-prefix", isDirectory: true)
207243
let environment = CLIShimInstaller.subprocessEnvironment(
@@ -785,6 +821,14 @@ enum UISmokeRunner {
785821
*) export PATH="$agentic_secrets_path_dir:$PATH" ;;
786822
esac
787823
824+
# Agentic Secrets PATH
825+
agentic_secrets_path_dir='\(binDir)'
826+
export PATH="$agentic_secrets_path_dir:$PATH"
827+
828+
# AgenticSecrets CLI shims
829+
agentic_secrets_path_dir='\(shimDir)'
830+
export PATH="$agentic_secrets_path_dir:$PATH"
831+
788832
# AgenticSecrets CLI shims
789833
case ":$PATH:" in
790834
*":\(shimDir):"*) ;;

Sources/App/Stores/ControlPlaneStore.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,14 +558,14 @@ final class ControlPlaneStore {
558558
if installShim {
559559
do {
560560
_ = try CLIShimInstaller.install(name: summary.name)
561-
successMessage = "CLI registered and shim installed"
561+
successMessage = AgentRestartNotice.afterCLIRegistration(cliName: summary.name, shimInstalled: true)
562562
errorMessage = nil
563563
} catch {
564564
successMessage = nil
565565
errorMessage = "CLI registered, but the command shim could not be installed: \(userFacingError(error))"
566566
}
567567
} else {
568-
successMessage = "CLI registered"
568+
successMessage = AgentRestartNotice.afterCLIRegistration(cliName: summary.name, shimInstalled: false)
569569
errorMessage = nil
570570
}
571571
do {

Sources/App/Views/ContentView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ private struct FeedbackBanner: View {
497497
Image(systemName: store.errorMessage == nil ? "checkmark.circle" : "exclamationmark.triangle")
498498
.foregroundStyle(store.errorMessage == nil ? .green : .orange)
499499
Text(store.errorMessage ?? store.successMessage ?? "")
500-
.lineLimit(2)
500+
.lineLimit(4)
501501
Button {
502502
store.clearFeedback()
503503
} label: {
@@ -514,6 +514,7 @@ private struct FeedbackBanner: View {
514514
.accessibilityElement(children: .contain)
515515
.task(id: store.successMessage) {
516516
guard let message = store.successMessage, store.errorMessage == nil else { return }
517+
guard !AgentRestartNotice.requiresManualDismiss(message) else { return }
517518
try? await Task.sleep(nanoseconds: 3_500_000_000)
518519
await MainActor.run {
519520
store.clearSuccessIfCurrent(message)

Sources/App/Views/EditorViews.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ struct RegisterCLIView: View {
109109
Text("The shim is installed in the local Agentic Secrets shims folder. Shell PATH configuration remains a separate explicit install action.")
110110
.foregroundStyle(.secondary)
111111
}
112+
Label("After registration and shim PATH setup, restart Codex or any other already-running agent app before using this CLI from that app.", systemImage: "arrow.clockwise")
113+
.foregroundStyle(.secondary)
112114
}
113115
}
114116
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
3+
public enum AgentRestartNotice {
4+
public static func afterCLIRegistration(cliName: String, shimInstalled: Bool) -> String {
5+
if shimInstalled {
6+
return "CLI registered and shim installed. Make sure the shims folder is on PATH. Restart Codex or any other already-running agent app before using normal \(cliName) commands from that app so it reloads shell startup files and current Agentic Secrets state."
7+
}
8+
return "CLI registered. Restart Codex or any other already-running agent app before using \(cliName) from that app so it refreshes current Agentic Secrets state."
9+
}
10+
11+
public static func afterShimPathConfiguration(cliName: String) -> String {
12+
"Shell PATH configured for future sessions. Restart Codex or any other already-running agent app before using \(cliName) from that app so it reloads shell startup files and sees the Agentic Secrets shim."
13+
}
14+
15+
public static func afterManualPathChange(cliName: String) -> String {
16+
"After changing PATH, restart Codex or any other already-running agent app before using \(cliName) from that app."
17+
}
18+
19+
public static func requiresManualDismiss(_ successMessage: String) -> Bool {
20+
successMessage.contains("Restart Codex")
21+
|| successMessage.contains("already-running agent app")
22+
}
23+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
public enum ShellStartupFilePolicy {
4+
public static func defaultConfigurationFiles(
5+
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
6+
shellPath: String? = ProcessInfo.processInfo.environment["SHELL"]
7+
) -> [URL] {
8+
let shellName = URL(fileURLWithPath: shellPath ?? "zsh").lastPathComponent
9+
switch shellName {
10+
case "zsh":
11+
return [".zshenv", ".zprofile", ".zshrc"].map { homeDirectory.appendingPathComponent($0) }
12+
case "bash":
13+
return [".bash_profile", ".bashrc"].map { homeDirectory.appendingPathComponent($0) }
14+
default:
15+
return [homeDirectory.appendingPathComponent(".profile")]
16+
}
17+
}
18+
19+
public static func managedConfigurationFiles(
20+
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
21+
) -> [URL] {
22+
[".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map {
23+
homeDirectory.appendingPathComponent($0)
24+
}
25+
}
26+
}

Sources/CLI/main.swift

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ struct AgenticSecretsCLI {
307307
\(name) version
308308
""")
309309
if configureShell {
310-
print("Shell PATH configured for future sessions.")
310+
print(AgentRestartNotice.afterShimPathConfiguration(cliName: name))
311311
} else {
312312
print("""
313313
@@ -316,6 +316,8 @@ struct AgenticSecretsCLI {
316316
317317
For future sessions:
318318
agentic-secrets cli shim install \(name) --configure-shell
319+
320+
\(AgentRestartNotice.afterManualPathChange(cliName: name))
319321
""")
320322
}
321323
}
@@ -496,40 +498,32 @@ struct AgenticSecretsCLI {
496498
return value
497499
}
498500

499-
private static func defaultShellConfig() -> URL {
500-
let shell = URL(fileURLWithPath: ProcessInfo.processInfo.environment["SHELL"] ?? "zsh").lastPathComponent
501-
switch shell {
502-
case "bash":
503-
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".bashrc")
504-
case "zsh":
505-
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".zshrc")
506-
default:
507-
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile")
508-
}
501+
private static func defaultShellConfigs() -> [URL] {
502+
ShellStartupFilePolicy.defaultConfigurationFiles()
509503
}
510504

511505
private static func configureShellPath(directory: URL, label: String) throws {
512-
let target = defaultShellConfig()
513506
let quotedDirectory = try shellSingleQuotedPath(directory.path)
514-
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
515-
if !FileManager.default.fileExists(atPath: target.path) {
516-
FileManager.default.createFile(atPath: target.path, contents: nil)
517-
}
518507
let block = """
519508
520509
# \(label)
521510
agentic_secrets_path_dir=\(quotedDirectory)
522-
case ":$PATH:" in
523-
*":$agentic_secrets_path_dir:"*) ;;
524-
*) export PATH="$agentic_secrets_path_dir:$PATH" ;;
525-
esac
511+
export PATH="$agentic_secrets_path_dir:$PATH"
526512
"""
527-
let handle = try FileHandle(forWritingTo: target)
528-
defer { try? handle.close() }
529-
try handle.seekToEnd()
530-
try handle.write(contentsOf: Data(block.utf8))
531-
try handle.write(contentsOf: Data([10]))
532-
print("Configured shell PATH in \(target.path)")
513+
for target in defaultShellConfigs() {
514+
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
515+
if !FileManager.default.fileExists(atPath: target.path) {
516+
_ = FileManager.default.createFile(atPath: target.path, contents: nil)
517+
}
518+
do {
519+
let handle = try FileHandle(forWritingTo: target)
520+
defer { try? handle.close() }
521+
try handle.seekToEnd()
522+
try handle.write(contentsOf: Data(block.utf8))
523+
try handle.write(contentsOf: Data([10]))
524+
}
525+
print("Configured shell PATH in \(target.path)")
526+
}
533527
}
534528

535529
private static func shellSingleQuotedPath(_ path: String) throws -> String {

Sources/ContractTests/main.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ func signedPack(payload: CommandPolicyPackPayload, key: P256.Signing.PrivateKey,
9292
}
9393

9494
func runContracts() throws {
95+
let shellPolicyHome = URL(fileURLWithPath: "/tmp/agentic-secrets-shell-policy", isDirectory: true)
96+
let zshStartupFiles = ShellStartupFilePolicy.defaultConfigurationFiles(
97+
homeDirectory: shellPolicyHome,
98+
shellPath: "/bin/zsh"
99+
).map(\.lastPathComponent)
100+
try expect(zshStartupFiles == [".zshenv", ".zprofile", ".zshrc"], "zsh configuration must cover non-interactive Codex shells, login shells, and interactive terminals")
101+
let bashStartupFiles = ShellStartupFilePolicy.defaultConfigurationFiles(
102+
homeDirectory: shellPolicyHome,
103+
shellPath: "/bin/bash"
104+
).map(\.lastPathComponent)
105+
try expect(bashStartupFiles == [".bash_profile", ".bashrc"], "bash configuration must cover login and interactive shells")
106+
let managedStartupFiles = ShellStartupFilePolicy.managedConfigurationFiles(homeDirectory: shellPolicyHome).map(\.lastPathComponent)
107+
try expect(managedStartupFiles.contains(".zshenv"), "cleanup must remove managed zsh non-interactive shell PATH blocks")
108+
try expect(managedStartupFiles.contains(".zprofile"), "cleanup must remove managed zsh login shell PATH blocks")
109+
let restartNotice = AgentRestartNotice.afterCLIRegistration(cliName: "hcloud", shimInstalled: true)
110+
try expect(restartNotice.contains("Restart Codex"), "CLI registration success notice must tell agent users to restart")
111+
try expect(AgentRestartNotice.requiresManualDismiss(restartNotice), "agent restart notices must remain visible until dismissed")
112+
95113
let classifier = CommandClassifier()
96114
let hcloudRead = classifier.classify(executableName: "hcloud", arguments: ["server", "list"], observedVersion: "1.52.0")
97115
try expect(hcloudRead.risk == .unknown, "hcloud server list has no built-in policy pack by default")

0 commit comments

Comments
 (0)