browserctl 0.10.0 → 0.12.0

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. metadata +44 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff8250901d49ad1038c686f51d303f2139fe673c708746804cdb59e1239890fe
4
- data.tar.gz: b47c1e8dbf9093ffe43001d8cacf02c490c887fb5e58336449f1b7aedebfda0b
3
+ metadata.gz: 2a7eb052c2bbfc4e1f5afc24ba08c5b2b0d471a85d2acf6ea61a7f160cbe201b
4
+ data.tar.gz: 2d4259da9b4a13a50ad80e68d404f11eda89b6249e5315c58247c8d80514d21f
5
5
  SHA512:
6
- metadata.gz: e11c9a8300e7ec744e70ad5f33b645c9afc59e4e5212696045e899f4f2da017d1e4589f750db65b004d8e8208092d6a031724474a2a8feb48f249ed1daa83262
7
- data.tar.gz: 1a48cb05e7d92ab6dfd2de777abd0d8904a30b27c8d03fdaee2c00d1b5b734a9f8499be90ea30db686022a5c0d9ad7532ea1c1f148f0e1047a0583a954048dd8
6
+ metadata.gz: ca34ca3125de0686417b06919a8cb0eaf3311eaedd7ebe6538c1f159941d737ca549f3d0c3d8a10da73146bd23602cb145ed010539a48de97c3d8552d4f317c9
7
+ data.tar.gz: 61c89f8a4b2e61076ddd5fa603710e226ebfe1e176914ffb37f47a79ee35a2161b3700406a35c878d6fdc6cb129f051f00943d080b636076f6d1452b6ee290b3
data/CHANGELOG.md CHANGED
@@ -10,6 +10,72 @@ 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.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
14
+
15
+
16
+ ### ⚠ BREAKING CHANGES
17
+
18
+ * Scripts that hard-coded `exit 7 = AUTH_REQUIRED` must update — AUTH_REQUIRED is now exit 3, and 7 is STATE_EXPIRED. Scripts treating any non-zero status as a generic failure are unaffected. Prior to this change the CLI exited 1 for everything except AUTH_REQUIRED (which already used 7); typed errors now surface their dedicated codes.
19
+
20
+ ### Features
21
+
22
+ * benchmark harness + bundle_codec target ([#154](https://github.com/patrick204nqh/browserctl/issues/154)) ([f4b815a](https://github.com/patrick204nqh/browserctl/commit/f4b815a617d4a7d22473e6028ce2a0f90467fa87))
23
+ * bundle format version ([#132](https://github.com/patrick204nqh/browserctl/issues/132)) ([b52d325](https://github.com/patrick204nqh/browserctl/commit/b52d32546d1b9ec3915fac014fecd8f929161ddb))
24
+ * crash reports ([#135](https://github.com/patrick204nqh/browserctl/issues/135)) ([b02adf4](https://github.com/patrick204nqh/browserctl/commit/b02adf4d41295dbe547d2965d838168d48efb8bf))
25
+ * error code enum ([#128](https://github.com/patrick204nqh/browserctl/issues/128)) ([feb0522](https://github.com/patrick204nqh/browserctl/commit/feb0522887935737fd96ad8a8b47d1407a5f585e))
26
+ * exit code map ([#140](https://github.com/patrick204nqh/browserctl/issues/140)) ([1a1d2b2](https://github.com/patrick204nqh/browserctl/commit/1a1d2b2f50693863cb508b5354e5546f340a2771))
27
+ * format version header convention ([#129](https://github.com/patrick204nqh/browserctl/issues/129)) ([405774d](https://github.com/patrick204nqh/browserctl/commit/405774dd6c99cc96f62f0c092010b30e7e710d41))
28
+ * migration helpers ([#143](https://github.com/patrick204nqh/browserctl/issues/143)) ([1c1762e](https://github.com/patrick204nqh/browserctl/commit/1c1762e755f83445c7cdd0df75fc5324a820fa19))
29
+ * recording format version ([#136](https://github.com/patrick204nqh/browserctl/issues/136)) ([4f1bc46](https://github.com/patrick204nqh/browserctl/commit/4f1bc467e653ecc1f508a85b3c329adf048b783d))
30
+ * structured error payload ([#137](https://github.com/patrick204nqh/browserctl/issues/137)) ([f028410](https://github.com/patrick204nqh/browserctl/commit/f02841069f68de749a76ba9a98a38b0c20836d76))
31
+ * structured JSONL logs ([#131](https://github.com/patrick204nqh/browserctl/issues/131)) ([c6daff4](https://github.com/patrick204nqh/browserctl/commit/c6daff488460606659c0641dd8cee6c23561be05))
32
+ * trace --redact ([#139](https://github.com/patrick204nqh/browserctl/issues/139)) ([19b4cec](https://github.com/patrick204nqh/browserctl/commit/19b4cec37daf95a06b0513780d991d0522069593))
33
+ * trace command ([#133](https://github.com/patrick204nqh/browserctl/issues/133)) ([156f304](https://github.com/patrick204nqh/browserctl/commit/156f304246d24fa6c3e06f8d941ac971629ea77c))
34
+ * workflow format version ([#138](https://github.com/patrick204nqh/browserctl/issues/138)) ([963776b](https://github.com/patrick204nqh/browserctl/commit/963776be785583fc3a3b0c04c97510e16c73ca9a))
35
+
36
+
37
+ ### Miscellaneous Chores
38
+
39
+ * target next release as 0.12.0 ([#156](https://github.com/patrick204nqh/browserctl/issues/156)) ([9797fcc](https://github.com/patrick204nqh/browserctl/commit/9797fcc0bae1a47bb72eec9cec5fda105e698406))
40
+
41
+ ## [0.11.0](https://github.com/patrick204nqh/browserctl/compare/v0.10.0...v0.11.0) (2026-05-10)
42
+
43
+
44
+ ### Features
45
+
46
+ * .bctl bundle codec ([#94](https://github.com/patrick204nqh/browserctl/issues/94)) ([75a4370](https://github.com/patrick204nqh/browserctl/commit/75a43704abf9cf766435fff35409333514fd3c73))
47
+ * auth_required detector ([#99](https://github.com/patrick204nqh/browserctl/issues/99)) ([6d9554f](https://github.com/patrick204nqh/browserctl/commit/6d9554f7cbe9b8e93d22c42b03ff2ce59e594f28))
48
+ * AUTH_REQUIRED structured error code ([#100](https://github.com/patrick204nqh/browserctl/issues/100)) ([9928a4e](https://github.com/patrick204nqh/browserctl/commit/9928a4e7d456d0382b2244096fb38ebeee55013d))
49
+ * browserctl flow run/list/describe CLI ([#89](https://github.com/patrick204nqh/browserctl/issues/89)) ([5016fe5](https://github.com/patrick204nqh/browserctl/commit/5016fe54ee1414b967025f450ded79565ef9bcfb))
50
+ * drift telemetry (WS-2.4) ([#114](https://github.com/patrick204nqh/browserctl/issues/114)) ([712da07](https://github.com/patrick204nqh/browserctl/commit/712da075e623a2aa27a779958b94c0f6e92384c8))
51
+ * element fingerprint (WS-1.2) ([#107](https://github.com/patrick204nqh/browserctl/issues/107)) ([8a8b6b4](https://github.com/patrick204nqh/browserctl/commit/8a8b6b45121cb179919fae8c3f87a4584d4b4bc1))
52
+ * enriched recording log (WS-3.1) ([#115](https://github.com/patrick204nqh/browserctl/issues/115)) ([c00787d](https://github.com/patrick204nqh/browserctl/commit/c00787dc5f208cf69cd4a0326d4df19ed31e3bfc))
53
+ * fingerprint matcher (WS-2.1) ([#111](https://github.com/patrick204nqh/browserctl/issues/111)) ([1d99d20](https://github.com/patrick204nqh/browserctl/commit/1d99d208f5d3815c8ef1e59e3d549d15c3719712))
54
+ * flow registry ([#86](https://github.com/patrick204nqh/browserctl/issues/86)) ([31579dd](https://github.com/patrick204nqh/browserctl/commit/31579dd20156a700ace15137b34b120d16c86b6f))
55
+ * inferred waits (WS-3.3) ([#117](https://github.com/patrick204nqh/browserctl/issues/117)) ([9332754](https://github.com/patrick204nqh/browserctl/commit/93327545b1d21e00c16037c6730f019b574bc572))
56
+ * invoke flows from workflow DSL ([#88](https://github.com/patrick204nqh/browserctl/issues/88)) ([61fdc0a](https://github.com/patrick204nqh/browserctl/commit/61fdc0a6c98fdaf29c5034b80ccc54fdfb447e3a))
57
+ * load_state auto re-run + on_auth_required hook ([#101](https://github.com/patrick204nqh/browserctl/issues/101)) ([8328d13](https://github.com/patrick204nqh/browserctl/commit/8328d1348d2be8ec685ece9dec3cafb0eb26eb90))
58
+ * postcondition extraction (WS-3.4) ([#118](https://github.com/patrick204nqh/browserctl/issues/118)) ([56e76d5](https://github.com/patrick204nqh/browserctl/commit/56e76d50814d9c9a539ea56b20e6936d62509fcf))
59
+ * replay fallback in PageProxy (WS-2.2) ([#112](https://github.com/patrick204nqh/browserctl/issues/112)) ([50714fc](https://github.com/patrick204nqh/browserctl/commit/50714fcd2ff16614af0d18c8b390084e3f32511e))
60
+ * stable ref derivation (v0.11 WS-1.1) ([#106](https://github.com/patrick204nqh/browserctl/issues/106)) ([86df66a](https://github.com/patrick204nqh/browserctl/commit/86df66aaa9fc0b6c7a3a285c572ba2941b301e55))
61
+ * state export/import with file/s3/op transports ([#96](https://github.com/patrick204nqh/browserctl/issues/96)) ([f1dd97b](https://github.com/patrick204nqh/browserctl/commit/f1dd97b895503ee19e60f14bbb9df81dcb321bd1))
62
+ * state rotate ([#97](https://github.com/patrick204nqh/browserctl/issues/97)) ([78bb091](https://github.com/patrick204nqh/browserctl/commit/78bb091194e12e12867d7be0f566d426bdcb3b14))
63
+ * state save/load/list/info/delete ([#95](https://github.com/patrick204nqh/browserctl/issues/95)) ([4502cd1](https://github.com/patrick204nqh/browserctl/commit/4502cd1e8992f486b34ce2288a34a9ef78808617))
64
+ * stdlib flow — cloudflare_solve ([#93](https://github.com/patrick204nqh/browserctl/issues/93)) ([b4411b9](https://github.com/patrick204nqh/browserctl/commit/b4411b94af669d435346006c5f9304258456ba7b))
65
+ * stdlib flow — totp_2fa ([#90](https://github.com/patrick204nqh/browserctl/issues/90)) ([c23e574](https://github.com/patrick204nqh/browserctl/commit/c23e574887e1ef3324e41e988a8f8047079ac998))
66
+ * stdlib flows — basic_auth, magic_link_email ([#91](https://github.com/patrick204nqh/browserctl/issues/91)) ([e401d60](https://github.com/patrick204nqh/browserctl/commit/e401d60b4cf5b4b9f2b4138c45a9b19275a089e7))
67
+ * stdlib flows — oauth_google, oauth_github ([#92](https://github.com/patrick204nqh/browserctl/issues/92)) ([04c9586](https://github.com/patrick204nqh/browserctl/commit/04c9586ff83503b7d94a2c73fe9c3360f01dc4ca))
68
+ * workflow generate (WS-3.2) ([#116](https://github.com/patrick204nqh/browserctl/issues/116)) ([90dca3b](https://github.com/patrick204nqh/browserctl/commit/90dca3bada9b53f91e9be39e3d94d0208dcfa181))
69
+ * workflow promote --as-flow (WS-3.7) ([#121](https://github.com/patrick204nqh/browserctl/issues/121)) ([a5e4b30](https://github.com/patrick204nqh/browserctl/commit/a5e4b30a30559e8fa6b3f4ff3df7189dc3d6bd1e))
70
+ * workflow promote (WS-3.6) ([#120](https://github.com/patrick204nqh/browserctl/issues/120)) ([4355353](https://github.com/patrick204nqh/browserctl/commit/4355353213586dedf9f348f0fd211d1f0d3d72c6))
71
+ * workflow run --check + drift report (WS-2.3) ([#113](https://github.com/patrick204nqh/browserctl/issues/113)) ([7cb4bc7](https://github.com/patrick204nqh/browserctl/commit/7cb4bc7d967f4045e5709eacabb0408edf21f9f1))
72
+ * workflow run --check snapshot-diff (WS-3.5) ([#119](https://github.com/patrick204nqh/browserctl/issues/119)) ([1d131d8](https://github.com/patrick204nqh/browserctl/commit/1d131d85ea6e7948e9e734de6531c2d8dd194388))
73
+
74
+
75
+ ### Bug Fixes
76
+
77
+ * pass first open page to flow during load_state auto-rotate ([#105](https://github.com/patrick204nqh/browserctl/issues/105)) ([2c67733](https://github.com/patrick204nqh/browserctl/commit/2c67733fc4f879fc40833194e9e71e32cd3096a8))
78
+
13
79
  ## [0.10.0](https://github.com/patrick204nqh/browserctl/compare/v0.9.0...v0.10.0) (2026-05-09)
14
80
 
15
81
 
data/README.md CHANGED
@@ -196,8 +196,9 @@ The daemon shuts itself down after 30 minutes of inactivity.
196
196
  |---|---|
197
197
  | [Getting Started](docs/getting-started.md) | Install, first session, first snapshot |
198
198
  | [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
199
- | [Concepts](docs/concepts/) | Sessions, snapshots, human-in-the-loop |
199
+ | [Concepts](docs/concepts/) | Sessions, snapshots, [state](docs/concepts/state.md), [flows](docs/concepts/flows.md), human-in-the-loop |
200
200
  | [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
201
+ | [Debugging](docs/guides/debugging.md) | Read traces, redaction, crash reports, filing a good issue |
201
202
  | [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
202
203
  | [Command Reference](docs/reference/commands.md) | Every command and flag |
203
204
  | [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
data/bin/browserctl CHANGED
@@ -25,17 +25,35 @@ require "browserctl/commands/page"
25
25
  require "browserctl/commands/cookie"
26
26
  require "browserctl/commands/storage"
27
27
  require "browserctl/commands/session"
28
+ require "browserctl/commands/state"
28
29
  require "browserctl/commands/daemon"
29
30
  require "browserctl/commands/workflow"
31
+ require "browserctl/commands/flow"
30
32
  require "browserctl/commands/dialog"
31
33
  require "browserctl/commands/ask"
34
+ require "browserctl/commands/trace"
35
+ require "browserctl/commands/migrate"
36
+
37
+ def structured_stderr_payload(res, message)
38
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
39
+ context = res[:context] || res["context"] || {}
40
+ action = res[:suggested_action] || res["suggested_action"] ||
41
+ Browserctl::Error::SuggestedActions.for(code)
42
+ [code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
43
+ end
32
44
 
33
45
  def print_result(res)
34
- if res.is_a?(Hash) && res[:error]
35
- warn "Error: #{res[:error]}"
36
- exit 1
46
+ unless res.is_a?(Hash) && (res[:error] || res["error"])
47
+ puts res.to_json
48
+ return
37
49
  end
50
+
51
+ message = res[:error] || res["error"]
52
+ code, payload = structured_stderr_payload(res, message)
53
+ warn "Error: #{message}"
54
+ warn payload
38
55
  puts res.to_json
56
+ exit Browserctl::Error::ExitCodes.for(code)
39
57
  end
40
58
 
41
59
  def usage
@@ -95,15 +113,45 @@ def usage
95
113
  session export <name> <path>
96
114
  session import <path>
97
115
 
116
+ Auth detection:
117
+ auth-check <page> [--cookies] [--state NAME] [--flow NAME]
118
+ # Exits 7 with code: AUTH_REQUIRED when the page needs login.
119
+
120
+ State (.bctl bundles — cookies + storage + flow binding):
121
+ state save <name> [--encrypt] [--origins a,b] [--flow NAME]
122
+ state load <name>
123
+ state list
124
+ state info <name>
125
+ state delete <name>
126
+ state rotate <name> [--page NAME] [--params FILE] [--key value ...]
127
+ state export <name> <destination> # file path, s3://..., op://Vault/Item
128
+ state import <source> [--name NAME]
129
+
98
130
  Recording:
99
131
  record start <name>
100
132
  record stop [--out PATH]
101
133
  record status
102
134
 
103
135
  Workflow:
104
- workflow run <name|file> [--params file] [--key value ...]
136
+ workflow run <name|file> [--check] [--params file] [--key value ...]
105
137
  workflow list
106
138
  workflow describe <name>
139
+ workflow generate <recording> [--out PATH]
140
+ workflow promote <name> [--force] [--threshold N] [--as-flow]
141
+
142
+ Flow:
143
+ flow run <name|file> [--page NAME] [--params file] [--key value ...]
144
+ flow list
145
+ flow describe <name>
146
+
147
+ Trace:
148
+ trace [<session>] Pretty timeline of CLI + daemon log events.
149
+
150
+ Migrate:
151
+ migrate <path> [--to-version N] [--dry-run]
152
+ # Upgrades a persisted artifact (.bctl / .jsonl recording / workflow .rb)
153
+ # using registered migrations. Empty registry in v0.12 — first real
154
+ # migration ships post-1.0 when a format actually changes.
107
155
 
108
156
  Daemon:
109
157
  daemon start [--headed] [--name NAME]
@@ -125,6 +173,17 @@ daemon_name = if daemon_idx
125
173
  ARGV.delete_at(daemon_idx)
126
174
  end
127
175
 
176
+ log_level_idx = ARGV.index("--log-level") || ARGV.index("-l")
177
+ log_level = if log_level_idx
178
+ ARGV.delete_at(log_level_idx)
179
+ ARGV.delete_at(log_level_idx)
180
+ end || ENV["BROWSERCTL_LOG_LEVEL"] || "info"
181
+
182
+ # CLI invocations write structured JSON Lines to ~/.browserctl/logs/cli.log
183
+ # alongside the existing stderr behaviour. Stderr stays human-readable so
184
+ # scripted callers see no change.
185
+ Browserctl.logger = Browserctl.build_logger(log_level, component: "cli")
186
+
128
187
  cmd = ARGV.shift
129
188
  args = ARGV.dup
130
189
 
@@ -132,82 +191,113 @@ usage if cmd.nil? || %w[-h --help help].include?(cmd)
132
191
 
133
192
  runner = Browserctl::Runner.new
134
193
 
135
- case cmd
136
- when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
137
- when "record" then Browserctl::Commands::Record.run(args)
138
- when "init" then Browserctl::Commands::Init.run(args)
139
- when "ask" then Browserctl::Commands::Ask.run(args)
194
+ begin
195
+ case cmd
196
+ when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
197
+ when "record" then Browserctl::Commands::Record.run(args)
198
+ when "init" then Browserctl::Commands::Init.run(args)
199
+ when "ask" then Browserctl::Commands::Ask.run(args)
200
+ when "trace" then Browserctl::Commands::Trace.run(args)
201
+ when "migrate" then Browserctl::Commands::Migrate.run(args)
140
202
 
141
- else
142
- client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
203
+ else
204
+ client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
143
205
 
144
- case cmd
145
- when "page" then Browserctl::Commands::Page.run(client, args)
146
- when "cookie" then Browserctl::Commands::Cookie.run(client, args)
147
- when "storage" then Browserctl::Commands::Storage.run(client, args)
148
- when "session" then Browserctl::Commands::Session.run(client, args)
149
- when "daemon" then Browserctl::Commands::Daemon.run(client, args)
150
- when "navigate" then print_result(client.navigate(args[0], args[1]))
151
- when "fill" then Browserctl::Commands::Fill.run(client, args)
152
- when "click" then Browserctl::Commands::Click.run(client, args)
153
- when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
154
- when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
155
- when "evaluate" then print_result(client.evaluate(args[0], args[1]))
156
- when "url" then print_result(client.url(args[0]))
157
- when "wait"
158
- opts = Optimist.options(args) do
159
- opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
206
+ case cmd
207
+ when "flow" then Browserctl::Commands::Flow.run(client, args)
208
+ when "page" then Browserctl::Commands::Page.run(client, args)
209
+ when "cookie" then Browserctl::Commands::Cookie.run(client, args)
210
+ when "storage" then Browserctl::Commands::Storage.run(client, args)
211
+ when "session" then Browserctl::Commands::Session.run(client, args)
212
+ when "state" then Browserctl::Commands::State.run(client, args)
213
+ when "daemon" then Browserctl::Commands::Daemon.run(client, args)
214
+ when "auth-check"
215
+ page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
216
+ include_cookies = args.delete("--cookies") ? true : false
217
+ state_idx = args.index("--state")
218
+ state_arg = if state_idx
219
+ (args.delete_at(state_idx)
220
+ args.delete_at(state_idx))
221
+ end
222
+ flow_idx = args.index("--flow")
223
+ flow_arg = if flow_idx
224
+ (args.delete_at(flow_idx)
225
+ args.delete_at(flow_idx))
226
+ end
227
+ print_result(client.auth_check(page_name, include_cookies: include_cookies,
228
+ state: state_arg, suggested_flow: flow_arg))
229
+ when "navigate" then print_result(client.navigate(args[0], args[1]))
230
+ when "fill" then Browserctl::Commands::Fill.run(client, args)
231
+ when "click" then Browserctl::Commands::Click.run(client, args)
232
+ when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
233
+ when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
234
+ when "evaluate" then print_result(client.evaluate(args[0], args[1]))
235
+ when "url" then print_result(client.url(args[0]))
236
+ when "wait"
237
+ opts = Optimist.options(args) do
238
+ opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
239
+ end
240
+ name = args.shift
241
+ selector = args.shift
242
+ abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
243
+ print_result(client.wait(name, selector, timeout: opts[:timeout]))
244
+ when "pause"
245
+ opts = Optimist.options(args) do
246
+ opt :message, "Message shown to human", type: :string, short: "-m"
247
+ end
248
+ name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
249
+ res = client.pause(name, message: opts[:message])
250
+ if res[:error]
251
+ warn "Error: #{res[:error]}"
252
+ exit 1
253
+ end
254
+ puts "Page '#{name}' paused. Browser is live — interact freely."
255
+ puts "(#{opts[:message]})" if opts[:message]
256
+ puts "When done: browserctl resume #{name}"
257
+ when "resume" then Browserctl::Commands::Resume.run(client, args)
258
+ when "press"
259
+ name = args.shift or abort "usage: browserctl press <page> <key>"
260
+ key = args.shift or abort "usage: browserctl press <page> <key>"
261
+ print_result(client.press(name, key))
262
+ when "hover"
263
+ name = args.shift or abort "usage: browserctl hover <page> <selector>"
264
+ selector = args.shift or abort "usage: browserctl hover <page> <selector>"
265
+ print_result(client.hover(name, selector))
266
+ when "upload"
267
+ name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
268
+ selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
269
+ path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
270
+ print_result(client.upload(name, selector, path))
271
+ when "select"
272
+ name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
273
+ selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
274
+ value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
275
+ print_result(client.select(name, selector, value))
276
+ when "dialog" then Browserctl::Commands::Dialog.run(client, args)
277
+ when "devtools"
278
+ name = args.shift or abort "usage: browserctl devtools <page>"
279
+ res = client.devtools(name)
280
+ if res[:error]
281
+ warn "Error: #{res[:error]}"
282
+ exit 1
283
+ end
284
+ url = res[:devtools_url]
285
+ puts "Opening DevTools for '#{name}':"
286
+ puts " #{url}"
287
+ opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
288
+ system(opener, url)
289
+ else
290
+ abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
160
291
  end
161
- name = args.shift
162
- selector = args.shift
163
- abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
164
- print_result(client.wait(name, selector, timeout: opts[:timeout]))
165
- when "pause"
166
- opts = Optimist.options(args) do
167
- opt :message, "Message shown to human", type: :string, short: "-m"
168
- end
169
- name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
170
- res = client.pause(name, message: opts[:message])
171
- if res[:error]
172
- warn "Error: #{res[:error]}"
173
- exit 1
174
- end
175
- puts "Page '#{name}' paused. Browser is live — interact freely."
176
- puts "(#{opts[:message]})" if opts[:message]
177
- puts "When done: browserctl resume #{name}"
178
- when "resume" then Browserctl::Commands::Resume.run(client, args)
179
- when "press"
180
- name = args.shift or abort "usage: browserctl press <page> <key>"
181
- key = args.shift or abort "usage: browserctl press <page> <key>"
182
- print_result(client.press(name, key))
183
- when "hover"
184
- name = args.shift or abort "usage: browserctl hover <page> <selector>"
185
- selector = args.shift or abort "usage: browserctl hover <page> <selector>"
186
- print_result(client.hover(name, selector))
187
- when "upload"
188
- name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
189
- selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
190
- path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
191
- print_result(client.upload(name, selector, path))
192
- when "select"
193
- name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
194
- selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
195
- value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
196
- print_result(client.select(name, selector, value))
197
- when "dialog" then Browserctl::Commands::Dialog.run(client, args)
198
- when "devtools"
199
- name = args.shift or abort "usage: browserctl devtools <page>"
200
- res = client.devtools(name)
201
- if res[:error]
202
- warn "Error: #{res[:error]}"
203
- exit 1
204
- end
205
- url = res[:devtools_url]
206
- puts "Opening DevTools for '#{name}':"
207
- puts " #{url}"
208
- opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
209
- system(opener, url)
210
- else
211
- abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
212
292
  end
293
+ rescue Browserctl::Error => e
294
+ payload = {
295
+ code: e.code,
296
+ message: e.message,
297
+ context: e.respond_to?(:context) ? (e.context || {}) : {},
298
+ suggested_action: Browserctl::Error::SuggestedActions.for(e.code)
299
+ }
300
+ warn "Error: #{e.message}"
301
+ warn JSON.generate(payload)
302
+ exit Browserctl::Error::ExitCodes.for(e.code)
213
303
  end
data/bin/browserd CHANGED
@@ -5,6 +5,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
 
6
6
  require "optimist"
7
7
  require "nokogiri"
8
+ require "browserctl/crash_report"
8
9
  require "browserctl/logger"
9
10
  require "browserctl/server"
10
11
  require "browserctl/version"
@@ -35,7 +36,9 @@ end
35
36
 
36
37
  log_path = Browserctl.log_path(assigned_name)
37
38
  warn "browserd starting — log: #{log_path}"
38
- Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
39
+ warn " if browserd crashes, attach the crash report from ~/.browserctl/logs/crash-*.json"
40
+ Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path, component: "daemon")
41
+ daemon_jsonl_log = File.join(Browserctl.log_dir, "daemon.log")
39
42
  begin
40
43
  Browserctl::Server.new(
41
44
  headless: !opts[:headed],
@@ -45,4 +48,8 @@ begin
45
48
  ).run
46
49
  rescue Browserctl::BrowserNotFound => e
47
50
  abort e.message
51
+ rescue Exception => e # rubocop:disable Lint/RescueException
52
+ crash_path = Browserctl::CrashReport.write(error: e, log_path: daemon_jsonl_log)
53
+ warn "browserd crashed — crash report: #{crash_path}" if crash_path
54
+ raise
48
55
  end
@@ -15,7 +15,7 @@ module Browserctl
15
15
 
16
16
  def call(cmd, **params)
17
17
  result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
18
- Recording.append(cmd, **params) if result[:ok]
18
+ Recording.append(cmd, response: result, **params) if result[:ok]
19
19
  result
20
20
  rescue Errno::ENOENT, Errno::ECONNREFUSED
21
21
  raise DaemonUnavailableError, "browserd is not running — start it with: browserd"
@@ -50,7 +50,8 @@ module Browserctl
50
50
  def click(name, selector = nil, ref: nil)
51
51
  raise ArgumentError, "click: provide selector or ref:" unless selector || ref
52
52
 
53
- call("click", name: name, selector: selector, ref: ref)
53
+ call("click", name: name, selector: selector, ref: ref,
54
+ capture_post_snapshot: Recording.active ? true : nil)
54
55
  end
55
56
 
56
57
  # Fills an input element with a value.
@@ -62,7 +63,8 @@ module Browserctl
62
63
  def fill(name, selector = nil, value = nil, ref: nil)
63
64
  raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
64
65
 
65
- call("fill", name: name, selector: selector, ref: ref, value: value)
66
+ call("fill", name: name, selector: selector, ref: ref, value: value,
67
+ capture_post_snapshot: Recording.active ? true : nil)
66
68
  end
67
69
 
68
70
  # Takes a screenshot of a named page.
@@ -180,7 +182,7 @@ module Browserctl
180
182
  # @param path [String] file path to read cookies from
181
183
  # @return [Hash] `{ ok: true, count: }` or `{ error: }`
182
184
  def import_cookies(name, path)
183
- raise "cookie file not found: #{path}" unless File.exist?(path)
185
+ raise Browserctl::Error, "cookie file not found: #{path}" unless File.exist?(path)
184
186
 
185
187
  cookies = JSON.parse(File.read(path), symbolize_names: true)
186
188
  call("import_cookies", name: name, cookies: cookies)
@@ -308,6 +310,48 @@ module Browserctl
308
310
  call("session_delete", session_name: session_name)
309
311
  end
310
312
 
313
+ # Saves browser state (cookies + storage) into a single .bctl bundle.
314
+ # @return [Hash] `{ ok:, path:, origins:, cookies:, encrypted: }` or `{ error: }`
315
+ def state_save(name, origins: nil, flow: nil, flow_version: nil, passphrase: nil)
316
+ call("state_save",
317
+ name: name, origins: origins, flow: flow,
318
+ flow_version: flow_version, passphrase: passphrase)
319
+ end
320
+
321
+ # Restores a .bctl bundle into the running daemon. The daemon runs the
322
+ # auth_required detector against the bundle's cookies before applying;
323
+ # callers that have already verified the bundle (e.g. workflow
324
+ # `load_state` after a successful rotate) can pass `skip_auth_check: true`
325
+ # to bypass it.
326
+ def state_load(name, passphrase: nil, skip_auth_check: false)
327
+ call("state_load", name: name, passphrase: passphrase, skip_auth_check: skip_auth_check)
328
+ end
329
+
330
+ # Lists all stored state bundles (manifest only — no payload decryption).
331
+ def state_list = call("state_list")
332
+
333
+ # Reads a single bundle's manifest.
334
+ def state_info(name) = call("state_info", name: name)
335
+
336
+ # Permanently deletes a state bundle.
337
+ def state_delete(name) = call("state_delete", name: name)
338
+
339
+ # Runs the auth_required detector against a named page. Returns either
340
+ # `{ ok: true, auth_required: false }` or an AUTH_REQUIRED error response.
341
+ # @param name [String] page name
342
+ # @param include_cookies [Boolean] also feed the page's current cookies
343
+ # into the detector (catches expired-cookie auth)
344
+ # @param state [String, nil] bundle name the caller was working with;
345
+ # passed back verbatim so callers can recover without bookkeeping
346
+ # @param suggested_flow [String, nil] flow name to surface when triggered
347
+ def auth_check(name, include_cookies: false, state: nil, suggested_flow: nil)
348
+ call("auth_check",
349
+ name: name,
350
+ include_cookies: include_cookies,
351
+ state: state,
352
+ suggested_flow: suggested_flow)
353
+ end
354
+
311
355
  private
312
356
 
313
357
  def auto_discover_socket
@@ -327,10 +371,10 @@ module Browserctl
327
371
  end
328
372
 
329
373
  def read_response(sock)
330
- raise "browserd response timeout after 60s" unless sock.wait_readable(60)
374
+ raise DaemonUnavailableError, "browserd response timeout after 60s" unless sock.wait_readable(60)
331
375
 
332
376
  raw = sock.gets
333
- raise "browserd closed connection" unless raw
377
+ raise DaemonUnavailableError, "browserd closed connection" unless raw
334
378
 
335
379
  JSON.parse(raw.chomp, symbolize_names: true)
336
380
  end
@@ -1,15 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require_relative "../errors"
5
+ require_relative "../error/suggested_actions"
6
+
3
7
  module Browserctl
4
8
  module Commands
5
9
  module CliOutput
10
+ AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
11
+
6
12
  def print_result(res)
7
- if res.is_a?(Hash) && res[:error]
8
- warn "Error: #{res[:error]}"
9
- exit 1
13
+ if res.is_a?(Hash) && (res[:error] || res["error"])
14
+ message = res[:error] || res["error"]
15
+ warn "Error: #{message}"
16
+ warn structured_error_line(res, message)
17
+ puts res.to_json
18
+ exit exit_code_for(res)
10
19
  end
11
20
  puts res.to_json
12
21
  end
22
+
23
+ # Maps a daemon error response onto a process exit code. Defaults to 1;
24
+ # special-cased only for codes that callers programmatically branch on.
25
+ def exit_code_for(res)
26
+ return AUTH_REQUIRED_EXIT_CODE if (res[:code] || res["code"]) == "AUTH_REQUIRED"
27
+
28
+ 1
29
+ end
30
+
31
+ # Builds the single-line structured payload emitted to stderr after
32
+ # the human-readable line. Agents parse this JSON deterministically.
33
+ # Shape: { code, message, context, suggested_action }.
34
+ def structured_error_line(res, message)
35
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
36
+ context = res[:context] || res["context"] || {}
37
+ action = res[:suggested_action] || res["suggested_action"] ||
38
+ Browserctl::Error::SuggestedActions.for(code)
39
+ JSON.generate(
40
+ code: code,
41
+ message: message,
42
+ context: context,
43
+ suggested_action: action
44
+ )
45
+ end
13
46
  end
14
47
  end
15
48
  end