browserctl 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2662c63f1ab585b29689e8e09ac2c8f7c3214539c440c51da82efc954fc0493
4
- data.tar.gz: 3e8f6790cdb3a3a79382f68c3a1fb6235805e6acc96658270d06c4d91ee1098b
3
+ metadata.gz: e85e284d37a585d84857db9f02a93bc7d9aacbf5c026da8ab06559fdff121f16
4
+ data.tar.gz: 76dbe3a44dee51d1c520dc0bd4d282790309efab1ecc5567fcee585b064660f1
5
5
  SHA512:
6
- metadata.gz: d729193bb86f061227588d46981d7fc5aaf98ef45956a4f4c5e59ca30c95f02f2a06d0d2e9227e671e0f6fb8a3ffcdd2ec4347cc20bd30bb40b2bef54f8b25c4
7
- data.tar.gz: 48170f604f692e980da8956c41f74255e0f16c6a43a13f3ae090377550bc14967a632a8ac01ebe85c7cabde1ff4226638e92fd40329cde0bcb2539c0c4a5c69a
6
+ metadata.gz: e66d36468423be0212176d8d40df94c5e10ba3d5f23663041b502c0d481580f94f12f308deaffac18b25fef1cc0bd644dac110e4d98ce365574ef6aa53dc4784
7
+ data.tar.gz: b147ca301c15e728b9bf6e3682bcf975f7f105adadd77d62db9a6a68fa8a05cd73533562f198b0825abc99f45ee73c04cf50aa2205d840d0181f3b599e9f7093
data/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.8.1](https://github.com/patrick204nqh/browserctl/compare/v0.8.0...v0.8.1) (2026-04-29)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * fill test gaps and tighten DX for v0.8.1 encryption ([#59](https://github.com/patrick204nqh/browserctl/issues/59)) ([80ae8b3](https://github.com/patrick204nqh/browserctl/commit/80ae8b38620f182db02a971516cfb5295cfb4945))
19
+ * session encryption at rest and passphrase-protected export ([#57](https://github.com/patrick204nqh/browserctl/issues/57)) ([e3185d9](https://github.com/patrick204nqh/browserctl/commit/e3185d95dad3dd373659ce3c1e5781d9a06ed7f1))
20
+
21
+ ## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
22
+
23
+
24
+ ### Features
25
+
26
+ * **v0.8:** secret resolver plugin system + load_session fallback ([#54](https://github.com/patrick204nqh/browserctl/issues/54)) ([69737bf](https://github.com/patrick204nqh/browserctl/commit/69737bf10528ad691a31abf916953325637af597))
27
+
13
28
  ## [0.7.0](https://github.com/patrick204nqh/browserctl/compare/v0.6.0...v0.7.0) (2026-04-28)
14
29
 
15
30
 
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "io/console"
5
+ require "json"
6
+ require "open3"
7
+ require "openssl"
8
+ require "securerandom"
9
+ require "tmpdir"
3
10
  require_relative "cli_output"
4
11
 
5
12
  module Browserctl
@@ -9,6 +16,13 @@ module Browserctl
9
16
 
10
17
  USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
11
18
 
19
+ PBKDF2_ITERATIONS = 100_000
20
+ PBKDF2_KEY_LEN = 32
21
+ SALT_LEN = 16
22
+ NONCE_LEN = 12
23
+
24
+ SENSITIVE_BASENAMES = %w[cookies.json local_storage.json session_storage.json].freeze
25
+
12
26
  def self.run(client, args)
13
27
  sub = args.shift or abort USAGE
14
28
  case sub
@@ -23,8 +37,9 @@ module Browserctl
23
37
  end
24
38
 
25
39
  def self.run_save(client, args)
26
- name = args.shift or abort "usage: browserctl session save <name>"
27
- print_result(client.session_save(name))
40
+ encrypt = args.delete("--encrypt")
41
+ name = args.shift or abort "usage: browserctl session save <name> [--encrypt]"
42
+ print_result(client.session_save(name, encrypt: !!encrypt))
28
43
  end
29
44
 
30
45
  def self.run_load(client, args)
@@ -42,14 +57,23 @@ module Browserctl
42
57
  end
43
58
 
44
59
  def self.run_export(args)
45
- name = args.shift or abort "usage: browserctl session export <name> <path>"
46
- dest = args.shift or abort "usage: browserctl session export <name> <path>"
60
+ encrypt = args.delete("--encrypt")
61
+ name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
62
+ dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
63
+
47
64
  session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
48
65
  abort "session '#{name}' not found" unless Dir.exist?(session_dir)
49
66
 
50
67
  dest = File.expand_path(dest)
51
- pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
52
- Process.wait(pid)
68
+
69
+ if encrypt
70
+ passphrase = prompt_passphrase(confirm: true)
71
+ encrypt_export(session_dir, name, dest, passphrase)
72
+ else
73
+ pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
74
+ Process.wait(pid)
75
+ end
76
+
53
77
  puts({ ok: true, path: dest }.to_json)
54
78
  end
55
79
 
@@ -60,10 +84,154 @@ module Browserctl
60
84
 
61
85
  sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
62
86
  FileUtils.mkdir_p(sessions_dir)
63
- pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
64
- Process.wait(pid)
87
+
88
+ if encrypted_zip?(zip_path)
89
+ passphrase = prompt_passphrase
90
+ decrypt_import(zip_path, sessions_dir, passphrase)
91
+ else
92
+ pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
93
+ Process.wait(pid)
94
+ end
95
+
65
96
  puts({ ok: true }.to_json)
66
97
  end
98
+
99
+ # --- private helpers ---
100
+
101
+ def self.prompt_passphrase(confirm: false)
102
+ return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
103
+
104
+ $stderr.print "Passphrase: "
105
+ pass = $stdin.noecho(&:gets).to_s.chomp
106
+ $stderr.puts
107
+
108
+ if confirm
109
+ $stderr.print "Confirm passphrase: "
110
+ confirm_pass = $stdin.noecho(&:gets).to_s.chomp
111
+ $stderr.puts
112
+ abort "Passphrases do not match." unless pass == confirm_pass
113
+ end
114
+
115
+ pass
116
+ end
117
+ private_class_method :prompt_passphrase
118
+
119
+ def self.derive_key(passphrase, salt)
120
+ OpenSSL::PKCS5.pbkdf2_hmac(
121
+ passphrase,
122
+ salt,
123
+ PBKDF2_ITERATIONS,
124
+ PBKDF2_KEY_LEN,
125
+ OpenSSL::Digest.new("SHA256")
126
+ )
127
+ end
128
+ private_class_method :derive_key
129
+
130
+ def self.encrypt_blob(plaintext, key)
131
+ nonce = SecureRandom.bytes(NONCE_LEN)
132
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
133
+ cipher.encrypt
134
+ cipher.key = key
135
+ cipher.iv = nonce
136
+ ct = cipher.update(plaintext) + cipher.final
137
+ tag = cipher.auth_tag
138
+ nonce + ct + tag
139
+ end
140
+ private_class_method :encrypt_blob
141
+
142
+ def self.decrypt_blob(blob, key)
143
+ nonce = blob.byteslice(0, NONCE_LEN)
144
+ tag = blob.byteslice(-16, 16)
145
+ ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
146
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
147
+ cipher.decrypt
148
+ cipher.key = key
149
+ cipher.iv = nonce
150
+ cipher.auth_tag = tag
151
+ cipher.update(ciphertext) + cipher.final
152
+ rescue OpenSSL::Cipher::CipherError => e
153
+ raise Browserctl::Error, "export decryption failed: #{e.message}"
154
+ end
155
+ private_class_method :decrypt_blob
156
+
157
+ # Build an encrypted zip using the system zip command.
158
+ # Plaintext files are staged in a tmpdir; sensitive ones are encrypted
159
+ # before staging. An _encryption_manifest.json is added at the zip root.
160
+ def self.encrypt_export(session_dir, session_name, dest, passphrase)
161
+ salt = SecureRandom.bytes(SALT_LEN)
162
+ key = derive_key(passphrase, salt)
163
+ manifest = JSON.generate({
164
+ encrypted: true, kdf: "pbkdf2-sha256",
165
+ iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
166
+ })
167
+ Dir.mktmpdir do |tmpdir|
168
+ File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
169
+ stage_session_files(session_dir, session_name, tmpdir, key)
170
+ pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
171
+ Process.wait(pid)
172
+ end
173
+ end
174
+ private_class_method :encrypt_export
175
+
176
+ def self.stage_session_files(session_dir, session_name, tmpdir, key)
177
+ staged_session = File.join(tmpdir, session_name)
178
+ FileUtils.mkdir_p(staged_session)
179
+ Dir[File.join(session_dir, "**", "*")].each do |file|
180
+ next if File.directory?(file)
181
+
182
+ relative = file.delete_prefix("#{session_dir}/")
183
+ staged_path = File.join(staged_session, relative)
184
+ FileUtils.mkdir_p(File.dirname(staged_path))
185
+ raw = File.binread(file)
186
+ if SENSITIVE_BASENAMES.include?(File.basename(file))
187
+ File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
188
+ else
189
+ File.binwrite(staged_path, raw)
190
+ end
191
+ end
192
+ end
193
+ private_class_method :stage_session_files
194
+
195
+ # Returns true if the zip contains _encryption_manifest.json.
196
+ def self.encrypted_zip?(zip_path)
197
+ out, status = Open3.capture2("unzip", "-l", zip_path)
198
+ status.success? && out.include?("_encryption_manifest.json")
199
+ end
200
+ private_class_method :encrypted_zip?
201
+
202
+ # Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
203
+ def self.decrypt_import(zip_path, sessions_dir, passphrase)
204
+ Dir.mktmpdir do |tmpdir|
205
+ pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
206
+ Process.wait(pid)
207
+ manifest_path = File.join(tmpdir, "_encryption_manifest.json")
208
+ raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
209
+
210
+ manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
211
+ key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
212
+ copy_decrypted_files(tmpdir, sessions_dir, key)
213
+ end
214
+ end
215
+ private_class_method :decrypt_import
216
+
217
+ def self.copy_decrypted_files(tmpdir, sessions_dir, key)
218
+ Dir[File.join(tmpdir, "**", "*")].each do |file|
219
+ next if File.directory?(file)
220
+ next if File.basename(file) == "_encryption_manifest.json"
221
+
222
+ relative = file.delete_prefix("#{tmpdir}/")
223
+ dest_path = File.join(sessions_dir, relative)
224
+ FileUtils.mkdir_p(File.dirname(dest_path))
225
+ if file.end_with?(".enc")
226
+ File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
227
+ f.write(decrypt_blob(File.binread(file), key))
228
+ end
229
+ else
230
+ FileUtils.cp(file, dest_path)
231
+ end
232
+ end
233
+ end
234
+ private_class_method :copy_decrypted_files
67
235
  end
68
236
  end
69
237
  end
@@ -22,4 +22,7 @@ module Browserctl
22
22
  class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
23
  class TimeoutError < Error; def self.default_code = "timeout" end
24
24
  class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+
26
+ class WorkflowError < StandardError; end
27
+ class SecretResolverError < WorkflowError; end
25
28
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Browserctl
6
+ class SecretResolverRegistry
7
+ @mutex = Mutex.new
8
+ @registry = {}
9
+
10
+ def self.register(resolver_class)
11
+ instance = resolver_class.new
12
+ @mutex.synchronize { @registry[resolver_class.scheme] = instance }
13
+ end
14
+
15
+ def self.resolve(secret_ref)
16
+ scheme, reference = secret_ref.split("://", 2)
17
+ resolver = @mutex.synchronize { @registry[scheme] }
18
+ raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
19
+ unless resolver.available?
20
+ raise SecretResolverError,
21
+ "'#{scheme}://' resolver is not available in this environment"
22
+ end
23
+
24
+ resolver.resolve(reference)
25
+ rescue SecretResolverError
26
+ raise
27
+ rescue StandardError => e
28
+ raise SecretResolverError, "secret resolution failed for #{secret_ref.inspect}: #{e.message}"
29
+ end
30
+
31
+ def self.registered?(scheme)
32
+ @mutex.synchronize { @registry.key?(scheme) }
33
+ end
34
+
35
+ def self.reset!
36
+ @mutex.synchronize { @registry.clear }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module SecretResolvers
5
+ class Base
6
+ def self.scheme
7
+ raise NotImplementedError, "#{name}.scheme not implemented"
8
+ end
9
+
10
+ def available? = true
11
+
12
+ def resolve(_reference)
13
+ raise NotImplementedError, "#{self.class.name}#resolve not implemented"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module SecretResolvers
5
+ class Env < Base
6
+ def self.scheme = "env"
7
+
8
+ def resolve(reference)
9
+ ENV.fetch(reference) { raise SecretResolverError, "env var '#{reference}' is not set" }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Browserctl
6
+ module SecretResolvers
7
+ class MacOSKeychain < Base
8
+ def self.scheme = "keychain"
9
+
10
+ def available?
11
+ RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
12
+ end
13
+
14
+ def resolve(reference)
15
+ service, account = reference.split("/", 2)
16
+ if account.nil?
17
+ raise SecretResolverError,
18
+ "keychain reference must be 'service/account', got: #{reference.inspect}"
19
+ end
20
+
21
+ result, status = Open3.capture2("security", "find-generic-password",
22
+ "-a", account, "-s", service, "-w")
23
+ raise SecretResolverError, "keychain item not found: #{reference}" unless status.success?
24
+
25
+ result.chomp
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Browserctl
6
+ module SecretResolvers
7
+ class OnePassword < Base
8
+ def self.scheme = "op"
9
+
10
+ def available?
11
+ system("which op > /dev/null 2>&1")
12
+ end
13
+
14
+ def resolve(reference)
15
+ result, status = Open3.capture2("op", "read", "op://#{reference}")
16
+ raise SecretResolverError, "1Password item not found: op://#{reference}" unless status.success?
17
+
18
+ result.chomp
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "secret_resolver_registry"
4
+ require_relative "secret_resolvers/base"
5
+ require_relative "secret_resolvers/env"
6
+ require_relative "secret_resolvers/macos_keychain"
7
+ require_relative "secret_resolvers/one_password"
8
+
9
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::Env)
10
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::MacOSKeychain)
11
+ Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::OnePassword)
12
+
13
+ user_resolvers = File.expand_path("~/.browserctl/resolvers.rb")
14
+ load user_resolvers if File.exist?(user_resolvers)
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "open3"
5
8
  require_relative "constants"
6
9
 
7
10
  module Browserctl
@@ -10,6 +13,8 @@ module Browserctl
10
13
 
11
14
  SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
12
15
 
16
+ SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
17
+
13
18
  def self.path(name) = File.join(BASE_DIR, name)
14
19
  def self.exist?(name) = Dir.exist?(path(name))
15
20
 
@@ -22,29 +27,65 @@ module Browserctl
22
27
  Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
23
28
  end
24
29
 
25
- def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:)
30
+ def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
26
31
  validate_name!(session_name)
32
+ key, metadata = prepare_encryption(session_name, metadata, encrypt)
27
33
  dir = path(session_name)
28
34
  FileUtils.mkdir_p(dir)
29
- write_json(File.join(dir, "metadata.json"), metadata)
30
- write_secret(File.join(dir, "cookies.json"), cookies)
31
- write_secret(File.join(dir, "local_storage.json"), local_storage)
32
- return if session_storage.empty?
35
+ write_json(File.join(dir, "metadata.json"), metadata)
36
+ write_session_files(dir, cookies, local_storage, session_storage, key)
37
+ end
38
+
39
+ def self.prepare_encryption(session_name, metadata, encrypt)
40
+ return [nil, metadata] unless encrypt
33
41
 
34
- write_secret(File.join(dir, "session_storage.json"), session_storage)
42
+ raise Browserctl::Error, "session encryption requires macOS Keychain (darwin only)" unless keychain_available?
43
+
44
+ key = SecureRandom.bytes(32)
45
+ keychain_store(session_name, key)
46
+ [key, metadata.merge(encrypted: true)]
47
+ end
48
+ private_class_method :prepare_encryption
49
+
50
+ def self.write_session_files(dir, cookies, local_storage, session_storage, key)
51
+ if key
52
+ write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
53
+ write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
54
+ unless session_storage.empty?
55
+ write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
56
+ key)
57
+ end
58
+ else
59
+ write_secret(File.join(dir, "cookies.json"), cookies)
60
+ write_secret(File.join(dir, "local_storage.json"), local_storage)
61
+ write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
62
+ end
35
63
  end
64
+ private_class_method :write_session_files
36
65
 
37
66
  def self.load(session_name)
38
67
  validate_name!(session_name)
39
68
  dir = path(session_name)
40
69
  raise "session '#{session_name}' not found" unless Dir.exist?(dir)
41
70
 
42
- {
43
- metadata: JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true),
44
- cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
45
- local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
46
- session_storage: load_session_storage(dir)
47
- }
71
+ meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
72
+
73
+ if meta[:encrypted]
74
+ key = keychain_fetch(session_name)
75
+ {
76
+ metadata: meta,
77
+ cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
78
+ local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
79
+ session_storage: load_session_storage_encrypted(dir, key)
80
+ }
81
+ else
82
+ {
83
+ metadata: meta,
84
+ cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
85
+ local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
86
+ session_storage: load_session_storage(dir)
87
+ }
88
+ end
48
89
  end
49
90
 
50
91
  def self.load_meta(path)
@@ -59,12 +100,87 @@ module Browserctl
59
100
  raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
60
101
  end
61
102
 
103
+ # Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
104
+ def self.encrypt_file(data, key)
105
+ nonce = SecureRandom.bytes(12)
106
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
107
+ cipher.encrypt
108
+ cipher.key = key
109
+ cipher.iv = nonce
110
+ ciphertext = cipher.update(data) + cipher.final
111
+ tag = cipher.auth_tag
112
+ nonce + ciphertext + tag
113
+ end
114
+ private_class_method :encrypt_file
115
+
116
+ # Decrypt binary blob produced by encrypt_file; returns original plaintext string.
117
+ def self.decrypt_file(blob, key)
118
+ nonce = blob.byteslice(0, 12)
119
+ tag = blob.byteslice(-16, 16)
120
+ ciphertext = blob.byteslice(12, blob.bytesize - 28)
121
+
122
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
123
+ cipher.decrypt
124
+ cipher.key = key
125
+ cipher.iv = nonce
126
+ cipher.auth_tag = tag
127
+ cipher.update(ciphertext) + cipher.final
128
+ rescue OpenSSL::Cipher::CipherError => e
129
+ raise Browserctl::Error, "decryption failed: #{e.message}"
130
+ end
131
+ private_class_method :decrypt_file
132
+
133
+ def self.keychain_store(name, key)
134
+ hex_key = key.unpack1("H*")
135
+ out, status = Open3.capture2(
136
+ "security", "add-generic-password",
137
+ "-a", name,
138
+ "-s", "browserctl",
139
+ "-w", hex_key,
140
+ "-U"
141
+ )
142
+ raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
143
+ end
144
+ private_class_method :keychain_store
145
+
146
+ def self.keychain_fetch(name)
147
+ hex_key, status = Open3.capture2(
148
+ "security", "find-generic-password",
149
+ "-a", name,
150
+ "-s", "browserctl",
151
+ "-w"
152
+ )
153
+ raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
154
+
155
+ [hex_key.strip].pack("H*")
156
+ end
157
+ private_class_method :keychain_fetch
158
+
159
+ def self.keychain_available?
160
+ RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
161
+ end
162
+ private_class_method :keychain_available?
163
+
62
164
  def self.load_session_storage(dir)
63
165
  ss_path = File.join(dir, "session_storage.json")
64
166
  File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
65
167
  end
66
168
  private_class_method :load_session_storage
67
169
 
170
+ def self.load_session_storage_encrypted(dir, key)
171
+ ss_path = File.join(dir, "session_storage.json.enc")
172
+ return {} unless File.exist?(ss_path)
173
+
174
+ decrypt_json(ss_path, key, symbolize_names: false)
175
+ end
176
+ private_class_method :load_session_storage_encrypted
177
+
178
+ def self.decrypt_json(path, key, symbolize_names:)
179
+ blob = File.binread(path)
180
+ JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
181
+ end
182
+ private_class_method :decrypt_json
183
+
68
184
  def self.write_json(path, data)
69
185
  File.write(path, JSON.generate(data))
70
186
  end
@@ -75,5 +191,11 @@ module Browserctl
75
191
  File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
76
192
  end
77
193
  private_class_method :write_secret
194
+
195
+ def self.write_encrypted_secret(path, data, key)
196
+ blob = encrypt_file(JSON.generate(data), key)
197
+ File.open(path, "wb", 0o600) { |f| f.write(blob) }
198
+ end
199
+ private_class_method :write_encrypted_secret
78
200
  end
79
201
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "timeout"
4
4
  require_relative "client"
5
+ require_relative "errors"
6
+ require_relative "secret_resolvers"
5
7
 
6
8
  module Browserctl
7
- class WorkflowError < StandardError; end
8
-
9
- ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
9
+ ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
10
10
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
11
11
  StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
12
12
 
@@ -60,18 +60,27 @@ module Browserctl
60
60
  res
61
61
  end
62
62
 
63
- def save_session(session_name)
64
- res = @client.session_save(session_name)
63
+ def save_session(session_name, encrypt: false)
64
+ res = @client.session_save(session_name, encrypt: encrypt)
65
65
  raise WorkflowError, res[:error] if res[:error]
66
66
 
67
67
  res
68
68
  end
69
69
 
70
- def load_session(session_name)
70
+ def load_session(session_name, fallback: nil)
71
71
  res = @client.session_load(session_name)
72
- raise WorkflowError, res[:error] if res[:error]
72
+ return res unless res[:error]
73
73
 
74
- res
74
+ raise WorkflowError, res[:error] unless fallback
75
+
76
+ invoke(fallback.to_s)
77
+ res2 = @client.session_load(session_name)
78
+ if res2[:error]
79
+ raise WorkflowError,
80
+ "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
81
+ end
82
+
83
+ res2
75
84
  end
76
85
 
77
86
  def list_sessions
@@ -172,8 +181,10 @@ module Browserctl
172
181
  @description = text
173
182
  end
174
183
 
175
- def param(name, required: false, secret: false, default: nil)
176
- @param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
184
+ def param(name, required: false, secret: false, default: nil, secret_ref: nil)
185
+ secret = true if secret_ref
186
+ @param_defs[name] =
187
+ ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
177
188
  end
178
189
 
179
190
  def step(label, retry_count: 0, timeout: nil, &block)
@@ -223,7 +234,11 @@ module Browserctl
223
234
 
224
235
  def resolve_params(provided)
225
236
  @param_defs.each_with_object({}) do |(name, defn), out|
226
- val = provided[name] || defn.default
237
+ val = if defn.secret_ref
238
+ SecretResolverRegistry.resolve(defn.secret_ref)
239
+ else
240
+ provided[name] || defn.default
241
+ end
227
242
  raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
228
243
 
229
244
  out[name] = val
data/lib/browserctl.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "browserctl/version"
4
4
  require_relative "browserctl/constants"
5
5
  require_relative "browserctl/errors"
6
+ require_relative "browserctl/secret_resolvers"
6
7
  require_relative "browserctl/workflow"
7
8
  require_relative "browserctl/runner"
8
9
  require_relative "browserctl/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-28 00:00:00.000000000 Z
11
+ date: 2026-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -139,8 +139,6 @@ files:
139
139
  - bin/browserd
140
140
  - bin/setup
141
141
  - examples/cloudflare_hitl.rb
142
- - examples/smoke/params_file.rb
143
- - examples/smoke/store_fetch.rb
144
142
  - examples/test_automation_practices/advanced/ab_testing.rb
145
143
  - examples/test_automation_practices/advanced/broken_images.rb
146
144
  - examples/test_automation_practices/advanced/file_download.rb
@@ -191,6 +189,12 @@ files:
191
189
  - lib/browserctl/policy.rb
192
190
  - lib/browserctl/recording.rb
193
191
  - lib/browserctl/runner.rb
192
+ - lib/browserctl/secret_resolver_registry.rb
193
+ - lib/browserctl/secret_resolvers.rb
194
+ - lib/browserctl/secret_resolvers/base.rb
195
+ - lib/browserctl/secret_resolvers/env.rb
196
+ - lib/browserctl/secret_resolvers/macos_keychain.rb
197
+ - lib/browserctl/secret_resolvers/one_password.rb
194
198
  - lib/browserctl/server.rb
195
199
  - lib/browserctl/server/command_dispatcher.rb
196
200
  - lib/browserctl/server/handlers/cookies.rb
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- #
4
- # Smoke test for --params file loading (Task 7.5).
5
- #
6
- # Run with:
7
- # browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
8
- #
9
- # The workflow logs in using credentials from the params file and asserts
10
- # the secure area is reached — proving the params were loaded and available.
11
-
12
- Browserctl.workflow "smoke/params_file" do
13
- desc "Smoke: load credentials from a --params file and use them in a workflow"
14
-
15
- param :username, required: true
16
- param :password, required: true, secret: true
17
- param :base_url, default: "https://the-internet.herokuapp.com"
18
-
19
- step "open login page" do
20
- open_page(:main, url: "#{base_url}/login")
21
- end
22
-
23
- step "fill credentials from params file" do
24
- puts " [params] username = #{username.inspect}"
25
- puts " [params] password = (#{password.length} chars, secret)"
26
- page(:main).fill("input#username", username)
27
- page(:main).fill("input#password", password)
28
- page(:main).click("button[type=submit]")
29
- end
30
-
31
- step "assert login succeeded" do
32
- page(:main).wait(".flash.success", timeout: 10)
33
- assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
34
- puts " [ok] reached secure area — params file loaded correctly"
35
- end
36
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- #
4
- # Smoke test for WorkflowContext#store / #fetch (Task 7.3).
5
- #
6
- # Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
7
- # capture the text in step 1, assert it is still accessible in step 2 via fetch.
8
-
9
- Browserctl.workflow "smoke/store_fetch" do
10
- desc "Smoke: store a value in one step and retrieve it in a later step"
11
-
12
- param :base_url, default: "https://the-internet.herokuapp.com"
13
-
14
- step "open dynamic loading page" do
15
- open_page(:main, url: "#{base_url}/dynamic_loading/1")
16
- end
17
-
18
- step "click start and capture loaded text" do
19
- page(:main).click("div#start button")
20
- page(:main).wait("div#finish", timeout: 10)
21
- text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
22
- assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
23
- store(:loaded_text, text)
24
- puts " [store] loaded_text = #{text.inspect}"
25
- end
26
-
27
- step "fetch value from previous step and assert" do
28
- text = fetch(:loaded_text)
29
- puts " [fetch] loaded_text = #{text.inspect}"
30
- assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
31
- end
32
-
33
- step "confirm fetch raises for unknown key" do
34
- fetch(:nonexistent_key)
35
- assert false, "expected WorkflowError was not raised"
36
- rescue Browserctl::WorkflowError => e
37
- puts " [ok] WorkflowError raised as expected: #{e.message}"
38
- end
39
- end