browser-automation-skill 0.71.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 (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,262 @@
1
+ # shellcheck shell=bash
2
+ # scripts/lib/common.sh
3
+ # Shared helpers for browser-automation-skill. Source this file from every
4
+ # verb script and lib module. Mirrors mqtt-skill's lib/common.sh pattern.
5
+
6
+ # Guard against double-sourcing.
7
+ [ -n "${BROWSER_SKILL_COMMON_LOADED:-}" ] && return 0
8
+ readonly BROWSER_SKILL_COMMON_LOADED=1
9
+
10
+ # Restrictive umask for everything we create.
11
+ umask 077
12
+
13
+ # --- Exit code table (matches docs/superpowers/specs §5.1) ---
14
+ # Consumed by every verb script via `die "${EXIT_USAGE_ERROR}"` etc.; shellcheck
15
+ # can't see cross-file usage, so disable the unused-var warning for this block.
16
+ # shellcheck disable=SC2034
17
+ readonly EXIT_OK=0
18
+ readonly EXIT_GENERIC_ERROR=1
19
+ readonly EXIT_USAGE_ERROR=2
20
+ readonly EXIT_EMPTY_RESULT=11
21
+ readonly EXIT_PARTIAL_RESULT=12
22
+ readonly EXIT_ASSERTION_FAILED=13
23
+ readonly EXIT_PREFLIGHT_FAILED=20
24
+ readonly EXIT_TOOL_MISSING=21
25
+ readonly EXIT_SESSION_EXPIRED=22
26
+ readonly EXIT_SITE_NOT_FOUND=23
27
+ readonly EXIT_CREDENTIAL_AMBIGUOUS=24
28
+ readonly EXIT_AUTH_INTERACTIVE_REQUIRED=25
29
+ readonly EXIT_KEYCHAIN_LOCKED=26
30
+ readonly EXIT_TTY_REQUIRED=27
31
+ readonly EXIT_BLOCKLIST_REJECTED=28
32
+ readonly EXIT_NETWORK_ERROR=30
33
+ readonly EXIT_CAPTURE_WRITE_FAILED=31
34
+ readonly EXIT_RETENTION_BLOCKED=32
35
+ readonly EXIT_SCHEMA_MIGRATION_REQUIRED=33
36
+ readonly EXIT_TOOL_UNSUPPORTED_OP=41
37
+ readonly EXIT_TOOL_CRASHED=42
38
+ readonly EXIT_TOOL_TIMEOUT=43
39
+
40
+ # --- Tool adapter ABI version (single source of truth) ---
41
+ # Bumping this is a [breaking] change. Every adapter's tool_metadata().abi_version
42
+ # must equal this value; tests/lint.sh enforces it. See:
43
+ # docs/superpowers/specs/2026-04-30-tool-adapter-extension-model-design.md §2.4
44
+ readonly BROWSER_SKILL_TOOL_ABI=1
45
+
46
+ # --- Logging ---
47
+ # All logging goes to stderr. stdout is reserved for streaming JSON + summary.
48
+ # Honors NO_COLOR=1 (https://no-color.org) and FORCE_COLOR=1.
49
+
50
+ _browser_skill_color() {
51
+ if [ "${NO_COLOR:-0}" = "1" ]; then
52
+ printf ''
53
+ return
54
+ fi
55
+ if [ "${FORCE_COLOR:-0}" = "1" ] || [ -t 2 ]; then
56
+ printf '%s' "$1"
57
+ return
58
+ fi
59
+ printf ''
60
+ }
61
+
62
+ ok() {
63
+ local prefix
64
+ prefix="$(_browser_skill_color $'\033[0;32m')ok:$(_browser_skill_color $'\033[0m')"
65
+ printf '%s %s\n' "${prefix}" "$*" >&2
66
+ }
67
+
68
+ warn() {
69
+ local prefix
70
+ prefix="$(_browser_skill_color $'\033[0;33m')warn:$(_browser_skill_color $'\033[0m')"
71
+ printf '%s %s\n' "${prefix}" "$*" >&2
72
+ }
73
+
74
+ # die EXIT_CODE MESSAGE...
75
+ # Prints to stderr in red, exits with the given code.
76
+ die() {
77
+ local code="$1"
78
+ shift
79
+ local prefix
80
+ prefix="$(_browser_skill_color $'\033[0;31m')error:$(_browser_skill_color $'\033[0m')"
81
+ printf '%s %s\n' "${prefix}" "$*" >&2
82
+ exit "${code}"
83
+ }
84
+
85
+ # --- Path resolution ---
86
+ # resolve_browser_skill_home echoes the canonical state-home path.
87
+ # Resolution order:
88
+ # 1. $BROWSER_SKILL_HOME (explicit override)
89
+ # 2. Walk up from $PWD looking for .browser-skill/ (project-scoped mode)
90
+ # 3. ~/.browser-skill/ (user-level fallback)
91
+ resolve_browser_skill_home() {
92
+ if [ -n "${BROWSER_SKILL_HOME:-}" ]; then
93
+ printf '%s\n' "${BROWSER_SKILL_HOME}"
94
+ return 0
95
+ fi
96
+ local dir="${PWD}"
97
+ while [ "${dir}" != "/" ]; do
98
+ if [ -d "${dir}/.browser-skill" ]; then
99
+ printf '%s\n' "${dir}/.browser-skill"
100
+ return 0
101
+ fi
102
+ dir="$(dirname "${dir}")"
103
+ done
104
+ printf '%s\n' "${HOME}/.browser-skill"
105
+ }
106
+
107
+ # Convenience: export the resolved home + canonical subdirs once per invocation.
108
+ # Verbs source common.sh and call this immediately.
109
+ init_paths() {
110
+ BROWSER_SKILL_HOME="$(resolve_browser_skill_home)"
111
+ export BROWSER_SKILL_HOME
112
+ export SITES_DIR="${BROWSER_SKILL_HOME}/sites"
113
+ export SESSIONS_DIR="${BROWSER_SKILL_HOME}/sessions"
114
+ export CREDENTIALS_DIR="${BROWSER_SKILL_HOME}/credentials"
115
+ export CAPTURES_DIR="${BROWSER_SKILL_HOME}/captures"
116
+ export FLOWS_DIR="${BROWSER_SKILL_HOME}/flows"
117
+ export CURRENT_FILE="${BROWSER_SKILL_HOME}/current"
118
+ export CONFIG_FILE="${BROWSER_SKILL_HOME}/config.json"
119
+
120
+ # Adapter directory — single source of truth for "what adapters exist".
121
+ # Tools live one-file-per-adapter at $LIB_TOOL_DIR/<name>.sh; basename minus
122
+ # .sh is the canonical adapter name (enforced by tests/lint.sh §7.2).
123
+ local lib_dir
124
+ lib_dir="$(dirname "${BASH_SOURCE[0]}")"
125
+ export LIB_TOOL_DIR
126
+ LIB_TOOL_DIR="$(cd "${lib_dir}/tool" 2>/dev/null && pwd || printf '%s/tool' "${lib_dir}")"
127
+ }
128
+
129
+ # --- JSON summary writer ---
130
+ # Usage: summary_json key=value key=value ...
131
+ # Emits one valid JSON object per line on stdout. Uses jq for safe escaping —
132
+ # never let bash interpolation construct JSON strings (quote bugs = leaks).
133
+ # Numeric values (duration_ms, console_errors, etc.) stay as JSON numbers.
134
+ summary_json() {
135
+ if [ "$#" -eq 0 ]; then
136
+ die "${EXIT_USAGE_ERROR}" "summary_json: no key=value pairs supplied"
137
+ fi
138
+
139
+ # JSON field names (e.g. `label`, `def`, `or`) can collide with jq's
140
+ # reserved keywords when used as VARIABLE names — `--arg label X` followed by
141
+ # `$label` in the filter triggers `syntax error, unexpected label`. Decouple
142
+ # the two name spaces: every jq variable is `$_v_<key>`, while the JSON field
143
+ # name stays `<key>`. Prefix is intentionally awkward to avoid colliding with
144
+ # any caller-supplied key like `v_url`.
145
+ local args=()
146
+ local pair key value
147
+ for pair in "$@"; do
148
+ case "${pair}" in
149
+ *=*)
150
+ key="${pair%%=*}"
151
+ value="${pair#*=}"
152
+ # Numeric? Pass as --argjson; else --arg (string). Reject leading-zero
153
+ # integers (e.g. capture_id "001") so they stay as strings — leading
154
+ # zeros are intentional padding, never numeric.
155
+ if [[ "${value}" =~ ^-?(0|[1-9][0-9]*)(\.[0-9]+)?$ ]]; then
156
+ args+=(--argjson "_v_${key}" "${value}")
157
+ elif [ "${value}" = "true" ] || [ "${value}" = "false" ] || [ "${value}" = "null" ]; then
158
+ args+=(--argjson "_v_${key}" "${value}")
159
+ else
160
+ args+=(--arg "_v_${key}" "${value}")
161
+ fi
162
+ ;;
163
+ *)
164
+ die "${EXIT_USAGE_ERROR}" "summary_json: bad pair '${pair}' (expected key=value)"
165
+ ;;
166
+ esac
167
+ done
168
+
169
+ # Build the object dynamically: jq -n binds the prefixed variable names; the
170
+ # filter places each value under its caller-supplied JSON field name.
171
+ local jq_filter='. = {}'
172
+ for pair in "$@"; do
173
+ key="${pair%%=*}"
174
+ jq_filter="${jq_filter} | .${key} = \$_v_${key}"
175
+ done
176
+ jq -nc "${args[@]}" "${jq_filter}"
177
+ }
178
+
179
+ # --- Millisecond timestamp ---
180
+ # now_ms echoes the current epoch time in milliseconds as a positive integer.
181
+ # Verbs use this to compute duration_ms for their JSON summary.
182
+ # Phase 12 part 2 perf: bash 5.0+ $EPOCHREALTIME first (fork-free); then GNU
183
+ # date %3N (Linux); then BSD/macOS date second precision × 1000 (one fork,
184
+ # still better than the prior python3 fork). The python3 fallback is removed
185
+ # — bash 5.0 is universal on modern macOS/Linux + the skill's other bash-isms
186
+ # already require Homebrew bash on macOS.
187
+ now_ms() {
188
+ if [ -n "${EPOCHREALTIME:-}" ]; then
189
+ # EPOCHREALTIME = "<sec>.<microsec>". Slice to ms via parameter expansion.
190
+ local secs=${EPOCHREALTIME%.*}
191
+ local frac=${EPOCHREALTIME#*.}
192
+ printf '%s%s\n' "${secs}" "${frac:0:3}"
193
+ return 0
194
+ fi
195
+ local t
196
+ t="$(date +%s%3N 2>/dev/null)"
197
+ case "${t}" in
198
+ *N|'') printf '%s000\n' "$(date +%s)" ;;
199
+ *) printf '%s\n' "${t}" ;;
200
+ esac
201
+ }
202
+
203
+ # now_iso echoes the current UTC time as RFC-3339 / ISO-8601, second precision,
204
+ # trailing Z. Portable across GNU date (-u +%FT%TZ) and BSD date.
205
+ now_iso() {
206
+ date -u +%Y-%m-%dT%H:%M:%SZ
207
+ }
208
+
209
+ # file_mode PATH echoes the octal permission bits (e.g. "700", "0600") of PATH.
210
+ # Portable across GNU stat (-c '%a') and BSD stat (-f '%Lp'). GNU is tried
211
+ # first because GNU's `stat -f` does NOT fail on a regular file — it dumps
212
+ # filesystem-status info instead, polluting stdout. So order matters:
213
+ # GNU first; if -c is unrecognised (BSD), fall through to -f.
214
+ file_mode() {
215
+ stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
216
+ }
217
+
218
+ # --- Name safety ---
219
+ # assert_safe_name NAME [FIELD_LABEL]
220
+ # Refuses names that could escape a state dir or run outside the allowed
221
+ # character set. Allowed chars: A-Z a-z 0-9 dash underscore. Empty rejected.
222
+ # FIELD_LABEL (default "name") shows up in the error so callers can pass
223
+ # "session-name", "default-session", etc. for clearer messages.
224
+ assert_safe_name() {
225
+ local name="$1"
226
+ local field="${2:-name}"
227
+ if [[ ! "${name}" =~ ^[A-Za-z0-9_-]+$ ]]; then
228
+ die "${EXIT_USAGE_ERROR}" "${field} must match ^[A-Za-z0-9_-]+$ (got: ${name})"
229
+ fi
230
+ }
231
+
232
+ # --- Timeout wrapper ---
233
+ # with_timeout SECONDS COMMAND ARGS...
234
+ # Wraps `timeout` (GNU) or `gtimeout` (macOS coreutils) or a hand-rolled fallback.
235
+ # On timeout: kills the child, returns EXIT_TOOL_TIMEOUT (43).
236
+ # On success: returns the child's exit code.
237
+ with_timeout() {
238
+ local secs="$1"
239
+ shift
240
+ local rc=0
241
+
242
+ if command -v timeout >/dev/null 2>&1; then
243
+ timeout --preserve-status -k 2 "${secs}" "$@" || rc=$?
244
+ elif command -v gtimeout >/dev/null 2>&1; then
245
+ gtimeout --preserve-status -k 2 "${secs}" "$@" || rc=$?
246
+ else
247
+ # Fallback: spawn child + watcher in subshell.
248
+ "$@" &
249
+ local child=$!
250
+ ( sleep "${secs}"; kill -TERM "${child}" 2>/dev/null; sleep 2; kill -KILL "${child}" 2>/dev/null ) &
251
+ local watcher=$!
252
+ wait "${child}" 2>/dev/null || rc=$?
253
+ kill "${watcher}" 2>/dev/null || true
254
+ fi
255
+
256
+ # 124 = GNU timeout's "timed out" code; 137 = SIGKILL; 143 = SIGTERM (fallback).
257
+ # Map any of these timeout signals to our 43.
258
+ if [ "${rc}" = "124" ] || [ "${rc}" = "137" ] || [ "${rc}" = "143" ]; then
259
+ return "${EXIT_TOOL_TIMEOUT}"
260
+ fi
261
+ return "${rc}"
262
+ }
@@ -0,0 +1,237 @@
1
+ # scripts/lib/credential.sh
2
+ # Credentials substrate: metadata I/O + backend dispatch.
3
+ # Source from any verb / lib that needs credential CRUD.
4
+ # Requires lib/common.sh sourced first (init_paths must have run).
5
+ #
6
+ # Two files per credential:
7
+ # ${CREDENTIALS_DIR}/<name>.json — metadata (mode 0600, NEVER secrets)
8
+ # ${CREDENTIALS_DIR}/<name>.secret — secret payload (backend-owned shape)
9
+ #
10
+ # Backend dispatch: metadata.backend ∈ {plaintext, keychain, libsecret}.
11
+ # Each backend exposes the same 4-fn API (secret_set/get/delete/exists).
12
+ # - plaintext → scripts/lib/secret/plaintext.sh (this PR)
13
+ # - keychain → scripts/lib/secret/keychain.sh (phase-05 part 2b)
14
+ # - libsecret → scripts/lib/secret/libsecret.sh (phase-05 part 2c)
15
+ #
16
+ # AP-7: secret material flows via stdin pipes only — never argv. Helpers
17
+ # credential_set_secret / credential_get_secret use stdin/stdout exclusively.
18
+ # credential_load returns ONLY metadata; if you see a 'secret' key in its
19
+ # output, that's a privacy regression — tests/credential.bats asserts this.
20
+
21
+ [ -n "${BROWSER_SKILL_CREDENTIAL_LOADED:-}" ] && return 0
22
+ readonly BROWSER_SKILL_CREDENTIAL_LOADED=1
23
+
24
+ # --- Schema version (single source of truth for future migrations) ---
25
+ # Bump this is a [schema] change; phase-10 introduces migrate-schema.
26
+ readonly BROWSER_SKILL_CREDENTIAL_SCHEMA_VERSION=1
27
+
28
+ # Bash array (not space-separated string) so iteration is IFS-independent.
29
+ # Verb scripts set IFS=$'\n\t' which breaks word-splitting on space-separated
30
+ # strings — using "${arr[@]}" sidesteps that.
31
+ readonly _CREDENTIAL_REQUIRED_FIELDS=(schema_version name site account backend created_at)
32
+
33
+ _credential_path() {
34
+ printf '%s/%s.json' "${CREDENTIALS_DIR}" "$1"
35
+ }
36
+
37
+ # --- Metadata CRUD ---
38
+
39
+ # credential_exists NAME — 0 if metadata file present, 1 if not.
40
+ credential_exists() {
41
+ [ -f "$(_credential_path "$1")" ]
42
+ }
43
+
44
+ # credential_save NAME META_JSON
45
+ # Validates JSON + required fields. Refuses if NAME already exists (caller
46
+ # must credential_delete first). Mode 0600. Atomically (tmp + mv).
47
+ credential_save() {
48
+ local name="$1" meta_json="$2"
49
+ assert_safe_name "${name}" "credential-name"
50
+
51
+ if ! printf '%s' "${meta_json}" | jq -e . >/dev/null 2>&1; then
52
+ die "${EXIT_USAGE_ERROR}" "credential_save: metadata JSON is not valid"
53
+ fi
54
+
55
+ local field
56
+ for field in "${_CREDENTIAL_REQUIRED_FIELDS[@]}"; do
57
+ if ! printf '%s' "${meta_json}" | jq -e --arg f "${field}" 'has($f) and (.[$f] != null)' >/dev/null 2>&1; then
58
+ die "${EXIT_USAGE_ERROR}" "credential_save: metadata missing required field '${field}'"
59
+ fi
60
+ done
61
+
62
+ if credential_exists "${name}"; then
63
+ die "${EXIT_USAGE_ERROR}" "credential_save: ${name} already exists; call credential_delete first"
64
+ fi
65
+
66
+ mkdir -p "${CREDENTIALS_DIR}"
67
+ chmod 700 "${CREDENTIALS_DIR}"
68
+
69
+ local path tmp
70
+ path="$(_credential_path "${name}")"
71
+ tmp="${path}.tmp.$$"
72
+
73
+ ( umask 077; printf '%s\n' "${meta_json}" | jq . > "${tmp}" )
74
+ chmod 600 "${tmp}"
75
+ mv "${tmp}" "${path}"
76
+ }
77
+
78
+ # credential_load NAME → echoes metadata JSON (un-jq'd, exactly as on disk).
79
+ # NEVER includes the secret payload.
80
+ credential_load() {
81
+ local name="$1"
82
+ assert_safe_name "${name}" "credential-name"
83
+ local path
84
+ path="$(_credential_path "${name}")"
85
+ if [ ! -f "${path}" ]; then
86
+ die "${EXIT_SITE_NOT_FOUND}" "credential not found: ${name}"
87
+ fi
88
+ cat "${path}"
89
+ }
90
+
91
+ # credential_meta_load NAME — alias for credential_load. Provided for caller
92
+ # clarity (some callers want to be explicit they're reading metadata).
93
+ credential_meta_load() {
94
+ credential_load "$@"
95
+ }
96
+
97
+ # credential_list_names — sorted credential names, one per line. Excludes
98
+ # .secret files. Empty (or missing) CREDENTIALS_DIR prints nothing.
99
+ credential_list_names() {
100
+ if [ ! -d "${CREDENTIALS_DIR}" ]; then
101
+ return 0
102
+ fi
103
+ find "${CREDENTIALS_DIR}" -maxdepth 1 -type f -name '*.json' \
104
+ -exec basename {} .json \; 2>/dev/null | sort
105
+ }
106
+
107
+ # credential_delete NAME — removes metadata + secret (via backend). Idempotent.
108
+ credential_delete() {
109
+ local name="$1"
110
+ assert_safe_name "${name}" "credential-name"
111
+ if credential_exists "${name}"; then
112
+ _credential_dispatch_backend "${name}" delete || true
113
+ else
114
+ # Try to clean up an orphan plaintext .secret with no metadata, just in case.
115
+ rm -f "${CREDENTIALS_DIR}/${name}.secret" 2>/dev/null || true
116
+ fi
117
+ rm -f "$(_credential_path "${name}")"
118
+ }
119
+
120
+ # --- Backend dispatch ---
121
+ # credential_set_secret NAME — reads stdin, dispatches to backend's secret_set.
122
+ # credential_get_secret NAME — dispatches to backend's secret_get → stdout.
123
+
124
+ credential_set_secret() {
125
+ _credential_dispatch_backend "$1" set
126
+ }
127
+
128
+ credential_get_secret() {
129
+ _credential_dispatch_backend "$1" get
130
+ }
131
+
132
+ # Phase 5 part 4-ii: TOTP shared secret stored in the SAME backend as the
133
+ # password but under a sibling slot named "<NAME>__totp". The double-
134
+ # underscore suffix is allowed by assert_safe_name's regex
135
+ # (^[A-Za-z0-9_-]+$) so backends can validate the slot name through their
136
+ # normal path. Each cred's metadata still has only one entry; the backend
137
+ # has two secret slots (password + TOTP). Edge: a user-facing cred named
138
+ # `<X>__totp` would collide with `<X>`'s TOTP slot — `creds-add` rejects
139
+ # names containing `__totp` to prevent this.
140
+
141
+ credential_set_totp_secret() {
142
+ _credential_dispatch_backend_internal "$1__totp" set "$1"
143
+ }
144
+
145
+ credential_get_totp_secret() {
146
+ _credential_dispatch_backend_internal "$1__totp" get "$1"
147
+ }
148
+
149
+ # Internal: dispatch with a slot name SLOT_NAME but read backend from
150
+ # CRED_NAME's metadata. Used by TOTP slot operations where the slot name
151
+ # differs from the cred name but they share the backend.
152
+ _credential_dispatch_backend_internal() {
153
+ local slot_name="$1" op="$2" cred_name="$3"
154
+ shift 3
155
+
156
+ local meta backend
157
+ meta="$(credential_load "${cred_name}")"
158
+ backend="$(printf '%s' "${meta}" | jq -r '.backend')"
159
+ _credential_dispatch_to "${backend}" "${op}" "${slot_name}" "$@"
160
+ }
161
+
162
+ # Internal: dispatch a secret op (set/get/delete/exists) to the backend
163
+ # named by the credential's metadata.backend field. Backend lib is sourced
164
+ # on-demand to keep the parent shell's namespace clean.
165
+ _credential_dispatch_backend() {
166
+ local name="$1" op="$2"
167
+ shift 2
168
+
169
+ local meta backend
170
+ meta="$(credential_load "${name}")"
171
+ backend="$(printf '%s' "${meta}" | jq -r '.backend')"
172
+ _credential_dispatch_to "${backend}" "${op}" "${name}" "$@"
173
+ }
174
+
175
+ # _credential_dispatch_to BACKEND OP NAME [...args]
176
+ # Like _credential_dispatch_backend but uses BACKEND directly (instead of
177
+ # reading metadata.backend). Used by credential_migrate_to to write to the
178
+ # new backend BEFORE updating metadata, so a failed new-write doesn't leave
179
+ # the credential in an orphaned state.
180
+ _credential_dispatch_to() {
181
+ local backend="$1" op="$2" name="$3"
182
+ shift 3
183
+
184
+ local lib_dir
185
+ lib_dir="$(dirname "${BASH_SOURCE[0]}")/secret"
186
+
187
+ case "${backend}" in
188
+ plaintext|keychain|libsecret)
189
+ # shellcheck source=/dev/null
190
+ source "${lib_dir}/${backend}.sh"
191
+ "secret_${op}" "${name}" "$@"
192
+ ;;
193
+ *)
194
+ die "${EXIT_USAGE_ERROR}" "credential ${name}: unknown backend '${backend}'"
195
+ ;;
196
+ esac
197
+ }
198
+
199
+ # credential_migrate_to NAME NEW_BACKEND
200
+ # Move secret material from current backend to NEW_BACKEND, then update
201
+ # metadata. Fail-safe ordering:
202
+ # 1. Read secret from old backend
203
+ # 2. Write secret to new backend (if this fails, original intact)
204
+ # 3. Delete secret from old backend (failure → degraded but not fatal;
205
+ # verb script logs a warning, both backends transiently hold the secret)
206
+ # 4. Update metadata.backend → new (atomic tmp+mv)
207
+ # Refuses if new == old (no-op). Refuses unknown target backend.
208
+ credential_migrate_to() {
209
+ local name="$1" new_backend="$2"
210
+ assert_safe_name "${name}" "credential-name"
211
+
212
+ case "${new_backend}" in
213
+ plaintext|keychain|libsecret) ;;
214
+ *) die "${EXIT_USAGE_ERROR}" "credential_migrate_to: unknown target backend '${new_backend}'" ;;
215
+ esac
216
+
217
+ local old_meta old_backend
218
+ old_meta="$(credential_load "${name}")"
219
+ old_backend="$(printf '%s' "${old_meta}" | jq -r '.backend')"
220
+
221
+ if [ "${old_backend}" = "${new_backend}" ]; then
222
+ die "${EXIT_USAGE_ERROR}" "credential ${name}: backend already '${new_backend}' (no-op refused)"
223
+ fi
224
+
225
+ local secret
226
+ secret="$(_credential_dispatch_to "${old_backend}" get "${name}")"
227
+ printf '%s' "${secret}" | _credential_dispatch_to "${new_backend}" set "${name}"
228
+ _credential_dispatch_to "${old_backend}" delete "${name}" || true
229
+
230
+ local new_meta path tmp
231
+ new_meta="$(printf '%s' "${old_meta}" | jq --arg b "${new_backend}" '.backend = $b')"
232
+ path="$(_credential_path "${name}")"
233
+ tmp="${path}.tmp.$$"
234
+ ( umask 077; printf '%s\n' "${new_meta}" | jq . > "${tmp}" )
235
+ chmod 600 "${tmp}"
236
+ mv "${tmp}" "${path}"
237
+ }
@@ -0,0 +1,123 @@
1
+ // scripts/lib/fingerprint-rescue.js — Phase 13: weak-fingerprint selector rescue.
2
+ //
3
+ // Browser-side scoring algorithm. Runs inside the page via `browser-extract
4
+ // --eval`. Receives a target fingerprint + threshold (inlined as constants by
5
+ // scripts/lib/memory.sh::memory_fingerprint_rescue before eval) and returns
6
+ // the synthesised selector for the highest-scoring DOM element above
7
+ // threshold, or null on miss.
8
+ //
9
+ // Fingerprint shape (parsed bash-side from the cached CSS selector — "weak"
10
+ // because we don't capture rich state at record-time):
11
+ // { tag: "BUTTON" | "*", // upper-case HTML tag, or wildcard
12
+ // classes: ["delete", "btn"], // class tokens (.foo.bar in selector)
13
+ // attrs: { id: "...", role: "..." } // [name=value] selectors + #id
14
+ // }
15
+ //
16
+ // Scoring (algorithm cribbed from Scrapling adaptive parser, simplified for
17
+ // CSS-selector inputs):
18
+ // score = 0.4 × tag_match
19
+ // + 0.4 × jaccard(classes_target, classes_candidate)
20
+ // + 0.2 × jaccard(attrs_target, attrs_candidate)
21
+ //
22
+ // Synthesised selector priority (locked design choice):
23
+ // 1. #id when id is alphanumeric/hyphen and resolves uniquely
24
+ // 2. [data-testid="…"] preferred test-automation hook
25
+ // 3. tag.class[.class…] when this combination resolves uniquely
26
+ // 4. nth-child path absolute last-resort fallback
27
+ //
28
+ // Output (returned to bash via the eval channel):
29
+ // {"rescued_selector": "#submit-btn", "score": 0.82, "candidates_scanned": 1247}
30
+ // {"rescued_selector": null, "score": 0.0, "candidates_scanned": 1247}
31
+ //
32
+ // Phase 13. Best-effort — failure throws nothing; caller treats null as miss.
33
+
34
+ (() => {
35
+ // __FP and __TH are constants prepended by memory_fingerprint_rescue.
36
+ // eslint-disable-next-line no-undef
37
+ const target = typeof __FP !== "undefined" ? __FP : { tag: "*", classes: [], attrs: {} };
38
+ // eslint-disable-next-line no-undef
39
+ const threshold = typeof __TH !== "undefined" ? __TH : 0.7;
40
+
41
+ function jaccard(a, b) {
42
+ if (a.length === 0 && b.length === 0) return 1.0;
43
+ const setA = new Set(a);
44
+ const setB = new Set(b);
45
+ let intersect = 0;
46
+ setA.forEach((x) => { if (setB.has(x)) intersect++; });
47
+ const union = new Set([...setA, ...setB]).size;
48
+ return union === 0 ? 0 : intersect / union;
49
+ }
50
+
51
+ function score(el) {
52
+ const tagScore = target.tag === "*" || el.tagName === target.tag ? 1.0 : 0.0;
53
+ const candidateClasses = Array.from(el.classList);
54
+ const classScore = jaccard(target.classes, candidateClasses);
55
+ const targetAttrPairs = Object.entries(target.attrs).map(([k, v]) => `${k}=${v}`);
56
+ const candidateAttrPairs = Array.from(el.attributes).map((a) => `${a.name}=${a.value}`);
57
+ const attrScore = jaccard(targetAttrPairs, candidateAttrPairs);
58
+ return 0.4 * tagScore + 0.4 * classScore + 0.2 * attrScore;
59
+ }
60
+
61
+ function isSafeIdent(s) {
62
+ return typeof s === "string" && /^[A-Za-z][\w-]*$/.test(s);
63
+ }
64
+
65
+ function synthesise(el) {
66
+ // 1. #id (safe identifier + uniquely resolving)
67
+ if (el.id && isSafeIdent(el.id)) {
68
+ const sel = "#" + el.id;
69
+ try {
70
+ if (document.querySelectorAll(sel).length === 1) return sel;
71
+ } catch (_) { /* invalid selector — fall through */ }
72
+ }
73
+ // 2. [data-testid="…"]
74
+ const testid = el.getAttribute("data-testid");
75
+ if (testid) {
76
+ const sel = '[data-testid="' + testid.replace(/"/g, '\\"') + '"]';
77
+ try {
78
+ if (document.querySelectorAll(sel).length === 1) return sel;
79
+ } catch (_) { /* fall through */ }
80
+ }
81
+ // 3. tag.class[.class…] when uniquely resolving
82
+ if (el.classList.length > 0) {
83
+ const safeClasses = Array.from(el.classList).filter(isSafeIdent);
84
+ if (safeClasses.length > 0) {
85
+ const sel = el.tagName.toLowerCase() + "." + safeClasses.join(".");
86
+ try {
87
+ if (document.querySelectorAll(sel).length === 1) return sel;
88
+ } catch (_) { /* fall through */ }
89
+ }
90
+ }
91
+ // 4. nth-child path — climb to BODY
92
+ const path = [];
93
+ let cur = el;
94
+ while (cur && cur.tagName !== "HTML" && cur.parentElement) {
95
+ const parent = cur.parentElement;
96
+ const idx = Array.prototype.indexOf.call(parent.children, cur) + 1;
97
+ path.unshift(cur.tagName.toLowerCase() + ":nth-child(" + idx + ")");
98
+ if (parent.tagName === "BODY") break;
99
+ cur = parent;
100
+ }
101
+ return path.length > 0 ? path.join(" > ") : null;
102
+ }
103
+
104
+ let best = null;
105
+ let bestScore = 0;
106
+ let scanned = 0;
107
+ // Use a NodeList iterator — querySelectorAll('*') returns DOM order. Stable.
108
+ const all = document.querySelectorAll("*");
109
+ for (let i = 0; i < all.length; i++) {
110
+ scanned++;
111
+ const s = score(all[i]);
112
+ if (s > bestScore) {
113
+ bestScore = s;
114
+ best = all[i];
115
+ }
116
+ }
117
+ const rescued = best && bestScore >= threshold ? synthesise(best) : null;
118
+ return {
119
+ rescued_selector: rescued,
120
+ score: Math.round(bestScore * 100) / 100,
121
+ candidates_scanned: scanned,
122
+ };
123
+ })();