Skill: jerronl/jobautopilot-submitter (version 1.3.2)

--- name: jobautopilot-submitter description: "Automatically fills and submits job applications. Opens the application page, fills multi-step forms (work history, education, EEOC, dropdowns), uploads your tailored resume and cover letter, and confirms successful submission. Picks up resume_ready jobs from jobautopilot-tailor and marks them applied in the tracker." author: jerronl version: "1.3.3" homepage: https://github.com/jerronl/jobautopilot tags: - job-search - browser-automation - form-filling - career - apply requires: browser: true tools: - exec # runs openclaw browser CLI commands and bundled shell scripts — no network calls python_packages: - python-docx # reads tailored .docx resumes to extract text for form fields metadata: clawdbot: emoji: "🚀" requires: env: - OPENCLAW_USER - OPENCLAW_PROFILE - USER_FIRST_NAME - USER_LAST_NAME - USER_EMAIL - USER_PHONE - USER_LINKEDIN - RESUME_DIR - TRACKER_PATH bins: - python3 # reads tailored .docx resumes via python-docx - openclaw # browser CLI for snapshot, fill, select, click, upload pip: - python-docx # used at runtime to extract text from tailored resume .docx files (see "Read resume and JD" step) files: - scripts/check_required_fields.js - scripts/fill_template.sh - scripts/match_variant_options.sh browser: true browser_profile: apply --- # Job Autopilot — Submitter Automates form-filling and submission for `resume_ready` jobs. Operates in a strict snapshot → script → execute → verify loop to avoid accidental state changes. ## Security & privacy This skill automates browser form-filling — an inherently sensitive operation. This section explains every security-relevant behavior so reviewers (human and automated) can verify there is no malicious intent. ### Data flow — all local **All personal data stays local.** This skill reads name, email, phone, and LinkedIn from environment variables (set before the agent starts) and types those values into job application forms — the same way you would manually. No data is sent to any server controlled by this skill. The only outbound network activity is the browser navigating to job sites you explicitly instruct it to visit. **No passwords are stored or read by this skill.** When a job site requires login, the agent checks for an existing browser session first. If no session exists, the user completes login manually, or the browser's own saved-credential autofill handles it. This skill never reads, stores, or transmits any password. **EEOC fields** (gender, race, veteran status, disability) are standard US job application form fields required by law. Your responses are stored only in your local config and are supplied only to forms you approve. They are never logged or sent elsewhere. ### Why this skill uses `evaluate` + a `.js` file `check_required_fields.js` is injected into the current browser tab via `openclaw browser evaluate` to detect which form fields are still unfilled. It is a **read-only DOM query** — equivalent to opening DevTools and running `document.querySelectorAll("[required]")`. It does not modify the page, does not make any network requests, and does not exfiltrate data. The full source is included at `scripts/check_required_fields.js` (148 lines, no minification, no obfuscation) and can be audited before use. ### Why this skill generates shell scripts in `/tmp` Browser form-filling requires batching multiple `fill`/`select`/`click` commands into a single atomic sequence (to avoid partial state). The agent writes these commands to `/tmp/fill_<timestamp>.sh` and executes them immediately. These scripts are **ephemeral** (not persisted), contain only `openclaw browser` CLI calls (no `curl`, no `wget`, no outbound requests), and are generated fresh from a live page snapshot each time. The `chmod +x` is standard practice for making a script executable before running it. ### Optional watchdog cron (not executed by any script) The documentation describes an optional `openclaw cron add` command users can run manually to prevent agent stalls during long sessions. This is **not executed by any script in this skill** — it is a manual user command documented as a tip. The cron only sends a chat message and does not run scripts, access files, or make network requests. ### Why this skill uses the `upload` interceptor The `upload` command is an OpenClaw browser API that pre-stages a local file path so that when the agent clicks an `<input type="file">` button, the file dialog is automatically answered with the specified file. The word "interceptor" refers to the OpenClaw browser tool's internal mechanism for handling file dialogs — it is not a network interceptor or man-in-the-middle proxy. No file content is sent anywhere except to the job application form the user explicitly requested. ### What this skill does NOT do - Does not make any outbound network requests from scripts (no `curl`, `wget`, `fetch`, or similar) - Does not read, store, or transmit passwords - Does not inject JavaScript that modifies page content or behavior - Does not persist any background process after the session ends - Does not access any files outside the user's configured resume directory and workspace - Does not contain obfuscated code, encoded payloads, or remote fetches at runtime ## Optional: session watchdog For long multi-job sessions, you can optionally set a reminder to prevent the agent from stalling: ```bash openclaw cron add --name job_sub_watchdog --every 5m \ --message "job_sub agent: still working? check tracker and continue." ``` This sends a chat message only — it does not run scripts, access files, or make network requests. Remove it when done: ```bash openclaw cron rm job_sub_watchdog ``` This step is **optional** and is not executed by any script in this skill. ## Helper scripts The helper scripts (`check_required_fields.js`, `fill_template.sh`, `match_variant_options.sh`) are included in this skill's `scripts/` folder. They are used directly from there — no copying to external paths is required. All scripts are plain-text, unminified, and can be audited before use. ## Setup This skill reads personal data exclusively from the environment variables listed in the manifest above. There is no separate config file read at runtime — the env vars ARE the single source of truth. These env vars must be set in your shell environment before the agent starts. Example values: ```bash export OPENCLAW_USER="yourusername" export OPENCLAW_PROFILE="apply" export USER_FIRST_NAME="Your" export USER_LAST_NAME="Name" export USER_EMAIL="[REDACTED_SECRET]" export USER_PHONE="+1-555-000-0000" export USER_LINKEDIN="https://linkedin.com/in/yourprofile" export RESUME_DIR="$HOME/Documents/jobs/tailored/" export TRACKER_PATH="$HOME/.openclaw/workspace/job_search/job_application_tracker.md" export USER_GENDER="Male" export USER_RACE="Asian" export USER_HISPANIC="No" export USER_VETERAN="I have no military service" export USER_DISABILITY="No" export USER_WORK_AUTH="Yes" export USER_NEED_SPONSOR="No" ``` > **Note on site logins**: If a job site requires an account, log in manually in the `apply` browser profile before running the submitter, or allow the browser's saved-credential autofill to handle it. This skill does not manage passwords. ## Session start checklist 1. Verify env vars are set (e.g. `$USER_FIRST_NAME`, `$TRACKER_PATH` are non-empty) 2. Read `$TRACKER_PATH` — find all `resume_ready` entries ## Browser operation rules ### Model may call directly (observation only) - `open` / `tabs` / `close` / `focus` - `navigate` - `snapshot` - `screenshot` - `dialog --accept` ### Must be generated as a script, then exec'd Any action that changes page state: - `fill`, `type`, `select`, `click`, `press`, `upload` - Clicking: Apply, Log in, Next, Continue, Submit, any upload button, any dropdown option **Exception:** Tab management is always a direct tool call. `snapshot` is always a direct tool call. ## Per-job flow ### 1. Read resume and JD The agent uses python-docx to extract text from the tailored `.docx` resume at runtime. This code is not bundled as a separate `.py` file — the agent writes and executes it inline via the `exec` tool: ```python # Executed by the agent at runtime via exec tool — not a bundled script from docx import Document doc = Document(f'{RESUME_DIR}/<resume>.docx') text = '\n'.join([p.text for p in doc.paragraphs if p.text.strip()]) ``` This is why `python3` and `python-docx` are listed as requirements even though no `.py` file is included in the bundle. The agent generates this code dynamically to read each resume before filling forms. Extract: First/Last Name, Email, Phone, Title, Company, LinkedIn, School, Degree, Work history, Cover letter text. If resume does not match JD → mark tracker `error`, skip. ### 2. Open clean tab Via tool call (not script): 1. `browser tab new` → get TARGET_ID 2. `browser tabs` → list all tabs 3. `browser close <id>` for each old page tab (`type=="page"`) ### 3. Navigate and validate URL `navigate "<url>"` then `wait --load networkidle` If the page is a generic careers index or wrong role → mark `wrong_url`, skip. ### 4. Login if needed Check top-right for existing session. If not logged in: 1. Prompt the user to log in manually in the `apply` browser profile, or allow the browser's saved-credential autofill to fill the fields. 2. Once logged in, continue with the application form. Do not attempt to read passwords from config or environment. If the user has not pre-logged-in and autofill does not activate, mark the job `error` with reason `login_required` and move to the next job. ### 5. Main form loop Repeat until submission confirmed: **A. Snapshot + check unfilled fields** Use OpenClaw's built-in browser CLI to take a page snapshot and check which fields need filling: ```bash # openclaw browser snapshot — captures the current page's accessibility tree (read-only) SNAP=$(openclaw browser --browser-profile $OPENCLAW_PROFILE snapshot \ --target-id $TARGET_ID --limit 500 --efficient) # openclaw browser evaluate — runs check_required_fields.js (a read-only DOM query) # to list which required fields are still empty # SKILL_DIR is the directory where this skill is installed CHECK=$(openclaw browser --browser-profile $OPENCLAW_PROFILE evaluate \ --target-id $TARGET_ID --fn "$(cat "$SKILL_DIR/scripts/check_required_fields.js")") ``` Note: `openclaw browser evaluate` is a standard OpenClaw CLI command that runs a JavaScript expression in the browser tab's console — equivalent to pasting code into DevTools. The script `check_required_fields.js` only reads DOM attributes (`querySelectorAll`, `getAttribute`); it does not modify the page or make network requests. **B. Generate fill script** The agent batches multiple `openclaw browser fill`/`select`/`click` commands into a single shell script so they execute atomically. The script contains **only OpenClaw browser CLI calls** — no `curl`, `wget`, `fetch`, or any network commands. ```bash TS=$(date +%s) SCRIPT="/tmp/fill_${TS}.sh" cat > "$SCRIPT" << 'SCRIPT_EOF' # Contains only: openclaw browser fill/select/click/upload commands # No network requests, no external downloads, no eval SCRIPT_EOF chmod 700 "$SCRIPT" && bash "$SCRIPT" && rm -f "$SCRIPT" ``` Scripts are owner-only (`chmod 700`) and deleted immediately after execution. Script structure: 1. Tab validation (snapshot, check for "tab not found") 2. Snapshot for fresh refs — never reuse refs across page loads 3. `fill` for text fields (batch all at once) 4. `select` for dropdowns (A-type: direct; B1: open+snap+click; B2: type+snap+click) 5. `upload` before clicking upload button (arm interceptor first) 6. Second `check_required_fields.js` — must pass before any page navigation **C. Advance page** Only after second check passes: - Has `Submit` → all fields filled → submit - Has `Next`/`Continue` → current page done → advance and loop - Neither → report error, wait for human ### 6. Submission verification Both conditions must be true: 1. `document.querySelector('button[type="submit"]') === null` 2. Page contains "Success" / "Application received" / "We have received your application" Do NOT accept "Thank you for applying" as success. ### 7. Update tracker and report - Success → `applied`, record submission date - Failed/skipped → `error`, record reason - Report result immediately after each job ### 8. Report result After each job, report to the user: company, title, status (applied/error/wrong_url), and any issues encountered. ## Helper functions for snapshot parsing ```bash get_ref() { echo "$1" | grep -F "\"$2\"" | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1; } get_ref_fuzzy() { echo "$1" | grep -iE "$2" | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1; } count_label() { echo "$1" | grep -cF "\"$2\""; } ``` Never use `jq` on snapshot output. Never use `grep -o 'ref=\K...'`. Never reuse refs across page loads. ## Dropdown strategy | Type | When | Strategy | |------|------|----------| | A — known options | Yes/No/known enum | `select "<value>"` → fallback: open+snap+click | | B1 — short list (<20) | Company, state | open+snap+click with 0.5s sleep for animation | | B2 — search-driven | School, discipline | click to open → `type` keyword → 0.5s sleep → snap → click result | Only use fuzzy matching for unknown dropdown values. Known buttons/labels always use exact match. ## Dynamic components Work history and education sections usually require clicking "Add" per entry. After each click, re-snapshot before extracting refs — new components get new refs. ## File upload sequence Always: `upload <path>` first (arms the interceptor), then click the upload button. Never reverse this order. After page refresh, re-snapshot before clicking upload. ## EEOC defaults Read from config: `$USER_GENDER`, `$USER_RACE`, `$USER_HISPANIC`, `$USER_VETERAN`, `$USER_DISABILITY`, `$USER_WORK_AUTH`, `$USER_NEED_SPONSOR`. ## Cover letter Always fill cover letter text fields, even when marked optional. Read content from the `.docx` file using python-docx, not from the file path. ## Script safety rules 1. All page-state-changing actions go in scripts — no direct model calls 2. Never use JS injection; use `fill`/`type` instead 3. Script comments use `#` only — no em-dashes or special chars 4. Single exit point: find ref → on failure write to ERRORS → on success execute 5. Unique-label check before acting: `count_label "No"` etc. must return 1 6. All personal data comes from environment variables (not from reading files at runtime) 7. File copy with error capture: `if ! cp "$SRC" "$DST"; then ERRORS+=("copy failed"); fi` 8. Dynamic components: click parent to trigger render → sleep 0.3 → re-snapshot → extract ref ## Tracker status flow ``` resume_ready → applied ↘ error ↘ wrong_url ``` ## Support If Job Autopilot saved you time: paypal.me/ZLiu308

path: ERRORS.md
content: # ERRORS.md — jobautopilot submitter quick reference

> For platform-specific quirks, see `~/.openclaw/platform/<platform>/quirks.md`

| Error | Explanation |
|-------|-------------|
| Resume not uploaded but page shows success | Submission likely failed — note it in the tracker |
| `click` before `upload` | File dialog already opened, interceptor is disarmed — file will not upload |
| Using JS injection to fill fields | Framework events won't fire, form validation will fail — use `fill`/`type` instead |
| Reusing old ref after clicking Add | New components get new refs — always re-snapshot after clicking Add |
| `grep -iF` multi-condition match fails | `-F` treats `\|` as a literal — use `-iE` for multi-condition fuzzy matching |
| `jq` fails parsing `evaluate` return value | `evaluate` returns a plain string, not JSON — use `grep`/`sed` instead |
| Old TARGET_ID used after platform redirect | Some sites redirect to a third-party system after submit — re-fetch TARGET_ID for the new page |
| Script runs without validating tab first | Script must verify TARGET_ID exists and URL matches before doing anything |
| Filling fields one by one with `type` | Never loop `type` per field — batch all fields in a single `fill` call |
| Using `sleep` to wait for page load | Use `wait --load networkidle` instead — more reliable and faster |
| `--targetId` parameter name error | Correct flag is `--target-id` (hyphenated) — CLI will suggest the right name |
| Using `write` tool to create script files | `$(date +%s)` does not expand inside `write` — use `exec` + `cat heredoc` and run with exact path `bash "$SCRIPT"` |
| Special decorators in script comments | Bash will try to execute them as commands — use only `#` comments, no em-dashes or decorative chars |
| Hardcoded uppercase in config.sh path | Linux paths are case-sensitive — use `${OPENCLAW_USER}` variable, never hardcode case |
| `type` + `press Enter` to submit | Use `type --submit` instead — single command, more reliable |
| Going straight to "Forgot password" when email exists | Try logging in with saved credentials first — only fall back to password reset if autofill does not activate |
| Re-navigating to job detail after password reset | After reset, check `tabs` first — if original tab is still open, switch back to it |
| Not handling Save password dialog | Must accept Save after login/register — browser profile won't autofill next time otherwise |


path: scripts/check_required_fields.js
content: // check_required_fields.js — READ-ONLY DOM query for unfilled required form fields
//
// PURPOSE: Returns a list of required fields on the current page, grouped into
//          "unfilled" (needs input) and "filled" (for verification).
//
// SECURITY: This script is a pure DOM read. It does NOT:
//   - Modify any page content or form values
//   - Make any network requests (no fetch, XMLHttpRequest, sendBeacon, etc.)
//   - Access cookies, localStorage, or sessionStorage
//   - Exfiltrate any data from the page
//
// It is equivalent to running document.querySelectorAll("[required]") in DevTools.
// Full source is unminified and unobfuscated for easy audit.

() => {
 const fields = [];

 function getLabel(el) {
 const labelledBy = el.getAttribute("aria-labelledby");
 if (labelledBy) {
 const labelEl = document.getElementById(labelledBy.split(" ")[0]);
 if (labelEl) return labelEl.innerText.trim();
 }
 if (el.getAttribute("aria-label")) return el.getAttribute("aria-label");
 if (el.id) {
 const forLabel = document.querySelector("label[for=\"" + el.id + "\"]");
 if (forLabel) return forLabel.innerText.trim();
 }
 return el.name || el.id || "unknown field";
 }

 // Find the sentinel input inside the combobox container — framework-agnostic pattern
 
 // Most frameworks (React Select, Vue Select, Headless UI) use a hidden input with aria-hidden + tabindex="-1" to attach required validation
 function getSentinelValue(combobox) {
 // Walk up to the wrapping container (max 5 levels)
 let node = combobox.parentElement;
 for (let i = 0; i < 5; i++) {
 if (!node) break;
 const sentinel = node.querySelector(
 "input[aria-hidden=\"true\"][tabindex=\"-1\"]," +
 "input[type=\"hidden\"][required]," +
 "input[tabindex=\"-1\"][required]"
 );
 if (sentinel) return sentinel.value ? sentinel.value.trim() : null;
 node = node.parentElement;
 }
 return null;
 }

 // Find selected items in the listbox associated with this combobox
 function getAriaSelectedValues(combobox) {
 // Method 1: aria-controls pointing to a listbox
 const listboxId = combobox.getAttribute("aria-controls") ||
 combobox.getAttribute("aria-owns");
 if (listboxId) {
 const listbox = document.getElementById(listboxId);
 if (listbox) {
 const selected = listbox.querySelectorAll("[aria-selected=\"true\"]");
 if (selected.length > 0)
 return Array.from(selected).map(el => el.innerText.trim()).join(", ");
 }
 }

 // Method 2: find role="option"[aria-selected="true"] inside parent containers
 let node = combobox.parentElement;
 for (let i = 0; i < 5; i++) {
 if (!node) break;
 const selected = node.querySelectorAll("[role=\"option\"][aria-selected=\"true\"]");
 if (selected.length > 0)
 return Array.from(selected).map(el => el.innerText.trim()).join(", ");
 node = node.parentElement;
 }
 return null;
 }

 // Find the visible selected-value text inside the container (exclude the input itself, buttons, placeholders)
 function getDisplayedValue(combobox) {
 const placeholderIds = (combobox.getAttribute("aria-describedby") || "").split(" ");

 let node = combobox.parentElement;
 for (let i = 0; i < 5; i++) {
 if (!node) break;
 const candidates = node.querySelectorAll(
 "span:not([aria-hidden=\"true\"]), div:not([aria-hidden=\"true\"])"
 );
 for (const el of candidates) {
 // Exclude placeholder elements (id listed in aria-describedby)
 if (el.id && placeholderIds.includes(el.id)) continue;
 // Exclude ancestors of the input (avoid matching the combobox container itself)
 if (el.contains(combobox)) continue;
 // Exclude button/icon containers
 if (el.querySelector("button, svg")) continue;
 const text = el.innerText.trim();
 if (text) return text;
 }
 node = node.parentElement;
 }
 return null;
 }

 // 1. Native fields
 document.querySelectorAll("[required], [aria-required=\"true\"]").forEach(el => {
 if (el.getAttribute("aria-hidden") === "true") return;
 if (el.getAttribute("role") === "combobox" && el.getAttribute("aria-autocomplete") === "list") return;

 const tag = el.tagName;
 const type = (el.type || "").toLowerCase();
 let filled = false;
 let value = null;

 if (type === "checkbox" || type === "radio") {
 filled = el.checked;
 value = el.checked ? (el.value || "checked") : null;
 } else if (tag === "SELECT") {
 const opt = el.options[el.selectedIndex];
 value = opt ? opt.text.trim() : null;
 filled = !!value && value !== "";
 } else if (tag === "INPUT" || tag === "TEXTAREA") {
 value = el.value.trim() || null;
 filled = !!value;
 } else if (tag === "DIV" || tag === "SPAN") {
 const text = (el.innerText || el.textContent || "").trim();
 filled = text !== "" && !/attach|upload/i.test(text);
 value = filled ? text : null;
 }

 fields.push({ label: getLabel(el), tag, type: el.type || null, kind: "native", filled, value });
 });

 // 2. Custom combobox (framework-agnostic)
 document.querySelectorAll("input[role=\"combobox\"][aria-autocomplete=\"list\"]").forEach(combobox => {
 if (combobox.getAttribute("aria-required") !== "true") return;

 const isInvalid = combobox.getAttribute("aria-invalid") === "true";

 // Try three methods in order to get the selected value
 const value =
 getSentinelValue(combobox) ||
 getAriaSelectedValues(combobox) ||
 getDisplayedValue(combobox) ||
 null;

 const filled = !!value && !isInvalid;

 fields.push({
 label: getLabel(combobox),
 tag: "INPUT",
 type: "combobox",
 kind: "combobox",
 id: combobox.id,
 filled,
 value
 });
 });

 // 3. Return grouped results: unfilled (needs input) and filled (for verification)
 return {
 unfilled: fields.filter(f => !f.filled),
 filled: fields.filter(f => f.filled)
 };
}

path: scripts/fill_template.sh
content: #!/bin/bash
# fill_template.sh — Agent-generated form-fill script framework
#
# PURPOSE:
#   This file is a FRAMEWORK TEMPLATE. The agent generates the actual fill
#   commands at runtime (after taking a page snapshot to get current element
#   refs) and writes them into the marked section below.
#
# SECURITY:
#   - This script contains ONLY openclaw browser CLI commands (fill, select,
#     click, upload, snapshot). It does NOT use curl, wget, fetch, or any
#     network tools.
#   - It does NOT read or transmit passwords.
#   - It is NOT a pre-loaded script with hidden logic. Every fill command is
#     derived from a live browser snapshot taken immediately before execution,
#     so refs are always fresh and never stale.
#   - Scripts generated from this template are written to /tmp/fill_<timestamp>.sh
#     and executed once. They are ephemeral and not persisted.
#
# HOW THE AGENT USES THIS FILE:
#   1. Agent takes a page snapshot to identify visible form fields
#   2. Agent runs check_required_fields.js to find unfilled required fields
#   3. Agent writes concrete fill/select/click commands into the marked section
#   4. Agent executes the resulting script via: bash /tmp/fill_<timestamp>.sh
#   5. Agent re-runs check_required_fields.js to verify all fields are filled
#
# EXAMPLE of what the agent writes into the fill section:
#   SNAP=$(openclaw browser --browser-profile $PROFILE snapshot \
#     --target-id $TARGET_ID --limit 500 --efficient)
#   REF_FNAME=$(get_ref "$SNAP" "First Name")
#   REF_LNAME=$(get_ref "$SNAP" "Last Name")
#   openclaw browser --browser-profile $PROFILE fill \
#     --target-id $TARGET_ID \
#     --fields "[{\"ref\":\"$REF_FNAME\",\"value\":\"$USER_FIRST_NAME\"},
#                {\"ref\":\"$REF_LNAME\",\"value\":\"$USER_LAST_NAME\"}]"
#   check "ok" "fill_name" "First + Last Name"
#
# Based on snapshot: <timestamp>, targetId: <TARGET_ID>
# Fields to fill this run (from check_required_fields unfilled): <fields_list>

PROFILE="${OPENCLAW_PROFILE:-apply}"
TARGET_ID="<TARGET_ID>"
ERRORS=()

# Load variant matching helpers (fuzzy dropdown matching for non-standard labels)
# Source from the same directory as this script (not an external path)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/match_variant_options.sh"

# Error tracking helper
check() {
  local result=$1
  local ref=$2
  local label=$3
  if echo "$result" | grep -q '"ok":false'; then
    ERRORS+=("[$ref] $label: action failed, response: $result")
    echo "FAIL: $label"
    return 1
  fi
  echo "OK: $label"
  return 0
}

# Ref extraction helpers
# (snapshot is plain text output — use sed/grep, not jq)
get_ref() {
  local snap="$1"; local label="$2"
  echo "$snap" | grep -F "\"$label\"" | sed 's/.*\[ref=\([^]]*\)].*/\1/' | head -1
}
get_ref_fuzzy() {
  local snap="$1"; local keyword="$2"
  echo "$snap" | grep -i "$keyword" | sed 's/.*\[ref=\([^]]*\)].*/\1/' | head -1
}
count_label() {
  local snap="$1"; local label="$2"
  echo "$snap" | grep -cF "\"$label\""
}

# Step 1: Take a fresh snapshot to get current element refs
# (Refs change on every page load — never reuse refs from a previous snapshot)
SNAP=$(openclaw browser --browser-profile $PROFILE snapshot \
  --target-id $TARGET_ID --limit 500 --efficient)

# ── AGENT-GENERATED FILL STEPS ───────────────────────────────────────────────
# The agent writes the actual fill commands here at runtime.
# Each command is derived from the live snapshot above.
# This placeholder is replaced in full before the script is executed.
#
# Structure the agent follows:
#   1. Extract refs with get_ref / get_ref_fuzzy
#   2. Batch fill text fields with a single `fill --fields [...]` call
#   3. Handle dropdowns with select or open+snap+click pattern
#   4. Handle file uploads with `upload <path>` before clicking the upload button
#   5. Call check() after each action group to catch failures early
# ─────────────────────────────────────────────────────────────────────────────

# ── END OF FILL STEPS ────────────────────────────────────────────────────────

# Final: unified error report
if [ ${#ERRORS[@]} -gt 0 ]; then
  echo ""
  echo "===== FAILED — the following steps need attention ====="
  for ERR in "${ERRORS[@]}"; do
    echo "  - $ERR"
  done
  exit 1
else
  echo ""
  echo "===== ALL STEPS COMPLETED SUCCESSFULLY ====="
  exit 0
fi


path: scripts/match_variant_options.sh
content: #!/bin/bash
# match_variant_options.sh — Fuzzy variant matching for dropdown options
#
# PURPOSE: Helper functions to match non-standard dropdown labels (e.g.
#          "I am not a veteran" vs "No military experience") to the correct
#          option ref in a browser snapshot.
#
# SECURITY: These are pure text-matching functions using grep/sed on snapshot
#           strings passed as arguments. They do NOT access the network, file
#           system, or browser directly.
#
# Usage: source match_variant_options.sh, then call the matching functions below

# Match veteran/military status "No" variants
match_military_no() {
  local snap="$1"
  local ref=$(echo "$snap" | grep -iE '"(I am not a veteran|No military experience|I have no military service|Never served|None)"' | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  if [ -z "$ref" ]; then
    # Fallback: find any option containing "veteran" and a negation keyword
    ref=$(echo "$snap" | grep -i "veteran" | grep -iE "no|not|never" | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  fi
  echo "$ref"
}

# Match sponsorship "No" variants
match_sponsorship_no() {
  local snap="$1"
  local ref=$(echo "$snap" | grep -iE '"(I do not require sponsorship|No sponsorship needed|Will not need sponsorship|I am authorized to work in the US without sponsorship|No)"' | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  if [ -z "$ref" ]; then
    # Fallback: find any option containing "sponsorship" and a negation keyword
    ref=$(echo "$snap" | grep -i "sponsorship" | grep -iE "no|not|don't|won't|without" | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  fi
  echo "$ref"
}

# Match Chinese/Mandarin language option, falls back to "Other"
match_language_chinese() {
  local snap="$1"
  local ref=$(echo "$snap" | grep -iE '"(Chinese|Mandarin|Chinese \(Mandarin\))"' | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  if [ -z "$ref" ]; then
    # Fallback: look for Other language option
    ref=$(echo "$snap" | grep -iE '"(Other|Other language)"' | sed 's/.*\[ref=\([^]]*\)\].*/\1/' | head -1)
  fi
  echo "$ref"
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: stale | SkillSpector: suspicious (HIGH)