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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SECURITY.md +39 -0
- package/SKILL.md +206 -0
- package/bin/cli.mjs +55 -0
- package/install.sh +143 -0
- package/package.json +54 -0
- package/references/adapter-candidates.md +40 -0
- package/references/browser-mcp-cheatsheet.md +132 -0
- package/references/browser-stats-cheatsheet.md +155 -0
- package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
- package/references/midscene-integration.md +359 -0
- package/references/obscura-cheatsheet.md +103 -0
- package/references/playwright-cli-cheatsheet.md +64 -0
- package/references/playwright-lib-cheatsheet.md +90 -0
- package/references/recipes/add-a-tool-adapter.md +134 -0
- package/references/recipes/agent-workflows/README.md +37 -0
- package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
- package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
- package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
- package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
- package/references/recipes/anti-patterns-tool-extension.md +182 -0
- package/references/recipes/body-bytes-not-body.md +139 -0
- package/references/recipes/cache-write-security.md +210 -0
- package/references/recipes/fingerprint-rescue.md +154 -0
- package/references/recipes/model-routing.md +143 -0
- package/references/recipes/path-security.md +138 -0
- package/references/recipes/privacy-canary.md +96 -0
- package/references/recipes/visual-rescue-hook.md +182 -0
- package/references/stats-prices.json +42 -0
- package/references/stats-schema.json +77 -0
- package/references/tool-versions.md +8 -0
- package/scripts/browser-add-site.sh +113 -0
- package/scripts/browser-assert.sh +106 -0
- package/scripts/browser-audit.sh +68 -0
- package/scripts/browser-baseline.sh +135 -0
- package/scripts/browser-click.sh +100 -0
- package/scripts/browser-creds-add.sh +254 -0
- package/scripts/browser-creds-list.sh +67 -0
- package/scripts/browser-creds-migrate.sh +122 -0
- package/scripts/browser-creds-remove.sh +69 -0
- package/scripts/browser-creds-rotate-totp.sh +109 -0
- package/scripts/browser-creds-show.sh +82 -0
- package/scripts/browser-creds-totp.sh +94 -0
- package/scripts/browser-do.sh +630 -0
- package/scripts/browser-doctor.sh +365 -0
- package/scripts/browser-drag.sh +90 -0
- package/scripts/browser-extract.sh +192 -0
- package/scripts/browser-fill.sh +142 -0
- package/scripts/browser-flow.sh +316 -0
- package/scripts/browser-history.sh +187 -0
- package/scripts/browser-hover.sh +92 -0
- package/scripts/browser-inspect.sh +188 -0
- package/scripts/browser-list-sessions.sh +78 -0
- package/scripts/browser-list-sites.sh +42 -0
- package/scripts/browser-login.sh +279 -0
- package/scripts/browser-mcp.sh +65 -0
- package/scripts/browser-migrate.sh +195 -0
- package/scripts/browser-open.sh +134 -0
- package/scripts/browser-press.sh +80 -0
- package/scripts/browser-remove-session.sh +72 -0
- package/scripts/browser-remove-site.sh +68 -0
- package/scripts/browser-replay.sh +206 -0
- package/scripts/browser-route.sh +174 -0
- package/scripts/browser-select.sh +122 -0
- package/scripts/browser-show-session.sh +57 -0
- package/scripts/browser-show-site.sh +37 -0
- package/scripts/browser-snapshot.sh +176 -0
- package/scripts/browser-stats.sh +522 -0
- package/scripts/browser-tab-close.sh +112 -0
- package/scripts/browser-tab-list.sh +70 -0
- package/scripts/browser-tab-switch.sh +111 -0
- package/scripts/browser-upload.sh +132 -0
- package/scripts/browser-use.sh +60 -0
- package/scripts/browser-vlm.sh +707 -0
- package/scripts/browser-wait.sh +97 -0
- package/scripts/install-git-hooks.sh +16 -0
- package/scripts/lib/capture.sh +356 -0
- package/scripts/lib/common.sh +262 -0
- package/scripts/lib/credential.sh +237 -0
- package/scripts/lib/fingerprint-rescue.js +123 -0
- package/scripts/lib/flow.sh +448 -0
- package/scripts/lib/flow_record.sh +210 -0
- package/scripts/lib/mask.sh +49 -0
- package/scripts/lib/memory.sh +427 -0
- package/scripts/lib/migrate.sh +390 -0
- package/scripts/lib/migrators/README.md +23 -0
- package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
- package/scripts/lib/migrators/recent_urls/README.md +13 -0
- package/scripts/lib/migrators/stats/README.md +24 -0
- package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
- package/scripts/lib/node/mcp-server.mjs +531 -0
- package/scripts/lib/node/mcp-tools.json +68 -0
- package/scripts/lib/node/playwright-driver.mjs +1104 -0
- package/scripts/lib/node/totp-core.mjs +52 -0
- package/scripts/lib/node/totp.mjs +52 -0
- package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
- package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
- package/scripts/lib/output.sh +79 -0
- package/scripts/lib/router.sh +342 -0
- package/scripts/lib/sanitize.sh +107 -0
- package/scripts/lib/secret/keychain.sh +91 -0
- package/scripts/lib/secret/libsecret.sh +74 -0
- package/scripts/lib/secret/plaintext.sh +75 -0
- package/scripts/lib/secret_backend_select.sh +57 -0
- package/scripts/lib/session.sh +153 -0
- package/scripts/lib/site.sh +126 -0
- package/scripts/lib/stats.sh +419 -0
- package/scripts/lib/tool/.gitkeep +0 -0
- package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
- package/scripts/lib/tool/obscura.sh +249 -0
- package/scripts/lib/tool/playwright-cli.sh +155 -0
- package/scripts/lib/tool/playwright-lib.sh +106 -0
- package/scripts/lib/verb_helpers.sh +222 -0
- package/scripts/lib/visual-rescue-default.sh +145 -0
- package/scripts/regenerate-docs.sh +99 -0
- 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
|
+
})();
|