diff --git a/domains/agentic/skills/recipe-cook/references/evidence-package.md b/domains/agentic/skills/recipe-cook/references/evidence-package.md deleted file mode 100644 index 1c27a3a..0000000 --- a/domains/agentic/skills/recipe-cook/references/evidence-package.md +++ /dev/null @@ -1,57 +0,0 @@ -# Evidence Package - -Use a small artifact directory per run. Do not commit it unless the repo workflow asks for checked-in evidence. - -Recommended files: - -- `recipe.json`: resolved recipe used for the run. -- `summary.json`: run status, command, environment, start/end time, and proof target results. -- `trace.json`: ordered node events with timestamps, status, and linked artifacts. -- `artifact-manifest.json`: reviewer-facing artifact index. - -Runner output note: `index_artifacts` may run before runner-generated -`summary.json` and `trace.json` exist, and some repo runners write those files -outside the task artifact directory, such as `.agent/recipe-runs//`. -Search the runner's declared output path and keep those files in the evidence -package; stdout-only coverage is a gap when trace files exist elsewhere. - -`artifact-manifest.json` shape: - -```json -{ - "version": 1, - "runStatus": "pass", - "artifacts": [ - { - "path": "screenshots/send-valid-amount.png", - "type": "screenshot", - "label": "Valid amount screen after error clears", - "nodeId": "capture", - "proofTarget": "PT-2", - "mimeType": "image/png" - } - ] -} -``` - -Useful artifact types: - -- `screenshot` -- `video` -- `log` -- `trace` -- `summary` -- `json` -- `report` -- `metric` -- `diff` -- `recipe` - -Evidence rules: - -- Store relative paths in the manifest. -- Link each artifact to a node and proof target. -- Capture screenshots only after a settle condition. -- Prefer logs/reports for backend claims and screenshots/videos for visible UI claims. -- For "no error in logs" claims, include the log path, baseline offset/time, end offset/time, searched strings, and a proof that the watched window was live, such as a benign marker or nonzero appended bytes. -- Redact SRPs, private keys, bearer tokens, production account data, and private user data. diff --git a/domains/agentic/skills/recipe-cook/references/examples.md b/domains/agentic/skills/recipe-cook/references/examples.md deleted file mode 100644 index 1cab81d..0000000 --- a/domains/agentic/skills/recipe-cook/references/examples.md +++ /dev/null @@ -1,243 +0,0 @@ -# Examples - -Use these as composition patterns. Keep the recipe small: proof targets first, then setup, action, assertion, evidence, teardown. - -## Mobile Composition Pattern - -For MetaMask Mobile PRs, compose existing flows instead of inventing raw evals: - -1. **Preflight/status** — prove the intended simulator/device and debug app are reachable. -2. **Setup** — load or assert the wallet/network fixture needed by the claim. -3. **Navigate** — use a route or existing flow to reach the screen under test. -4. **Wait/assert** — wait on state or UI, not a fixed sleep. -5. **Capture** — screenshot/video/log only after the assertion proves the screen settled. -6. **Teardown** — reset wallet/app state when a run changes balances, permissions, txs, or network. - -Good Mobile recipes compose the runner manifest's semantic actions instead of shelling to scripts. Prefer, where the installed manifest advertises them: - -- `metamask.wallet.setup`, `metamask.wallet.ensure_unlocked`, `metamask.wallet.select_account`, `metamask.wallet.read_state` for wallet setup/start-state. -- `metamask.perps.start_state`, `metamask.perps.ensure_positions`, `metamask.perps.ensure_orders`, `metamask.perps.place_order`, `metamask.perps.close_positions`, `metamask.perps.read_positions`, `metamask.perps.assert_positions` for Perps flows. -- `ui.navigate`, `ui.wait_for`, `ui.press`, `ui.set_input`, `ui.scroll`, `ui.screenshot`, `app.status`, `app.hud` for the user path and evidence. - -`call`/flow-catalog composition is valid only when the installed runner manifest advertises a flow catalog; do not point at in-repo flow files (the legacy `scripts/perps/agentic/teams/perps/flows/*` recipes are not part of the runner model). When reusing an action or flow, state which proof target it covers and add only the nodes needed for the PR-specific claim. - -## Mobile Direct Smoke Recipe - -Use this for live-device validation of the recipe plumbing itself. It intentionally avoids wallet-specific dependencies. - -```json -{ - "schema_version": 1, - "title": "Mobile direct smoke \u2014 reach a settled wallet screen", - "description": "Proves the Mobile debug app is reachable and the runner can drive the bridge to a settled wallet screen. Intentionally avoids wallet-specific assertions beyond reachability.", - "validate": { - "workflow": { - "pre_conditions": [ - "Run from the metamask-mobile checkout", - "Debug app is already running on the intended simulator" - ], - "entry": "status", - "nodes": { - "status": { - "action": "app.status", - "timeout_ms": 30000, - "next": "ensure-unlocked", - "intent": "PT-1: read app route/device/platform through the app.status action" - }, - "ensure-unlocked": { - "action": "metamask.wallet.ensure_unlocked", - "timeout_ms": 45000, - "next": "navigate-wallet", - "intent": "PT-1: idempotently reach an unlocked wallet" - }, - "navigate-wallet": { - "action": "ui.navigate", - "route": "WalletView", - "timeout_ms": 30000, - "next": "wait-wallet", - "intent": "PT-2: open the wallet view through the navigation layer" - }, - "wait-wallet": { - "action": "ui.wait_for", - "test_id": "wallet-screen", - "expected": "present", - "timeout_ms": 30000, - "next": "capture", - "intent": "PT-2: the wallet screen is present after navigation settles" - }, - "capture": { - "action": "ui.screenshot", - "path": "screenshots/mobile-direct-smoke-wallet.png", - "next": "index-artifacts", - "intent": "PT-2: reviewer-visible settled wallet screen" - }, - "index-artifacts": { - "action": "index_artifacts", - "artifacts": [ - "screenshots/" - ], - "next": "done", - "intent": "Index the screenshot proof" - }, - "done": { - "action": "end", - "status": "pass" - } - }, - "teardown": [] - } - } -} -``` - -## Mobile Flow-Based Recipe - -This pattern composes a real Mobile flow and adds a PR-specific assertion. It is stronger than a direct smoke recipe because it proves the user path plus the state after the path settles. - -```json -{ - "schema_version": 1, - "title": "Perps market detail shows a loaded BTC price", - "description": "Proves the market list can open BTC details and the price is loaded after navigation settles.", - "inputs": { - "symbol": { - "type": "string", - "default": "BTC", - "description": "Perps market symbol to open and assert" - } - }, - "validate": { - "workflow": { - "pre_conditions": [ - "wallet.unlocked", - "perps.feature_enabled" - ], - "entry": "open-market", - "nodes": { - "open-market": { - "action": "ui.navigate", - "route": "PerpsMarketDetails", - "params": { - "market": { - "symbol": "{{symbol}}" - } - }, - "timeout_ms": 30000, - "next": "wait-market", - "intent": "PT-1: open the BTC market detail through the raw Perps market route" - }, - "wait-market": { - "action": "ui.wait_for", - "text": "{{symbol}}", - "expected": "present", - "timeout_ms": 30000, - "next": "capture-detail", - "intent": "PT-2: after navigation settles, the BTC market detail content is present" - }, - "capture-detail": { - "action": "ui.screenshot", - "path": "screenshots/perps-btc-detail.png", - "next": "index-artifacts", - "intent": "PT-2: reviewer-visible settled market detail screen" - }, - "index-artifacts": { - "action": "index_artifacts", - "artifacts": [ - "screenshots/" - ], - "next": "done", - "intent": "Index state and screenshot evidence" - }, - "done": { - "action": "end", - "status": "pass" - } - }, - "teardown": [] - } - } -} -``` - -## Backend or Non-UI Recipe - -Use command assertions when the PR claim is not user-facing. - -```json -{ - "schema_version": 1, - "title": "Validate token metadata normalization", - "description": "Proves the changed parser preserves symbol and decimals for malformed metadata responses.", - "inputs": {}, - "validate": { - "workflow": { - "pre_conditions": [ - "PR branch is checked out" - ], - "setup": [], - "entry": "run-focused-test", - "nodes": { - "run-focused-test": { - "action": "command", - "cmd": "mkdir -p reports && yarn test --runInBand app/core/token-service/metadata.test.ts --json --outputFile reports/jest-token-metadata.json", - "timeout_ms": 120000, - "next": "assert-pass", - "intent": "PT-1: focused unit test covers malformed metadata" - }, - "assert-pass": { - "action": "assert_json", - "path": "reports/jest-token-metadata.json", - "assert": { - "path": "$.numFailedTests", - "operator": "eq", - "value": 0 - }, - "next": "index-artifacts", - "intent": "PT-1: Jest reports zero failed tests" - }, - "index-artifacts": { - "action": "index_artifacts", - "artifacts": [ - "reports/jest-token-metadata.json" - ], - "next": "done", - "intent": "Index the test report" - }, - "done": { - "action": "end", - "status": "pass" - } - }, - "teardown": [] - } - } -} -``` - -## Weak Recipe to Avoid - -```json -{ - "schema_version": 1, - "title": "Check send works", - "validate": { - "workflow": { - "entry": "test", - "nodes": { - "test": { - "action": "wait", - "duration_ms": 10000, - "next": "done", - "intent": "Perform test for recipe proof" - }, - "done": { - "action": "end", - "status": "pass" - } - } - } - } -} -``` - -Problems: no proof target, no user path, sleep instead of state wait, no assertion, no artifact, and success is unconditional. diff --git a/domains/agentic/skills/recipe-cook/references/recipe-v1.md b/domains/agentic/skills/recipe-cook/references/recipe-v1.md deleted file mode 100644 index cbdfb45..0000000 --- a/domains/agentic/skills/recipe-cook/references/recipe-v1.md +++ /dev/null @@ -1,121 +0,0 @@ -# Recipe v1 - -Canonical source of truth: `$FARMSLOT_ROOT/docs/RECIPE-PROTOCOL-V1.md`. This file is a skill-local summary and must not redefine the protocol differently. - -Use this shape unless the target repo already publishes a stricter schema. - -```json -{ - "schema_version": 1, - "inputs": {}, - "proofTargets": [ - { "id": "PT-1", "claim": "The changed behavior is visible and settled." } - ], - "validate": { - "workflow": { - "pre_conditions": [], - "setup": [], - "entry": "start", - "nodes": { - "start": { - "action": "command", - "intent": "Run a project-native check", - "cmd": "mkdir -p logs && yarn test --runInBand path/to/test > logs/test.log 2>&1; status=$?; cat logs/test.log; exit $status", - "timeout_ms": 120000, - "next": "assert-result" - }, - "assert-result": { - "action": "assert_exit_code", - "intent": "Check the project-native check passed", - "expected": 0, - "next": "assert-output" - }, - "assert-output": { - "action": "assert_output", - "intent": "Check the project-native output looked successful", - "source": "start", - "stream": "stdout", - "contains": "PASS", - "next": "index-artifacts" - }, - "index-artifacts": { - "action": "index_artifacts", - "intent": "Write the artifact manifest", - "artifacts": ["logs/test.log"], - "next": "done" - }, - "done": { "action": "end", "status": "pass" } - }, - "teardown": [] - } - } -} -``` - - -## Composition and start-state fields - -Recipe authoring should support reusable flow composition. When the installed runner publishes flow catalogs, prefer `call` nodes over repeated raw setup. - -Recommended metadata: - -- `proofTargets`: small claims derived from ACs. -- `phase`: one of `setup`, `start_state`, `proof`, `assert`, or `teardown`. -- `proofTarget`: the AC/proof target a proof or assertion node validates. -- `record`: use `proof_window` for the smallest reviewer-visible interaction. - -Recommended current MetaMask setup action shape: - -```json -{ - "action": "metamask.perps.start_state", - "intent": "Converge Perps to the requested baseline before proof", - "phase": "start_state", - "network": "testnet", - "provider": "hyperliquid", - "page": "positions", - "market": "BTC", - "positions": { "state": "none", "mode": "matching" }, - "orders": { "state": "none", "mode": "matching" } -} -``` - -An `ensure_*` or `start_state` action/flow is idempotent: it inspects the current app state, performs only required transitions, and fails unless its postcondition is proved. Use it for unlock, network/provider selection, navigation, fixtures, and domain-specific starting state. Use `call` only when the installed manifest explicitly advertises flow-catalog support. - -Minimum required fields: - -- `schema_version: 1` -- `validate.workflow.entry` -- non-empty `validate.workflow.nodes` -- `intent` on every non-terminal executable node - -Node rules: - -- Every node key is a stable id. -- Every non-terminal node has `action` and `intent`, except a minimal terminal `end` node. -- Every non-terminal node has `next`, `cases`, or `default`. -- Transition targets exist. -- At least one node reaches `action: "end"`. -- Assertions name the proof target they validate with `proofTarget`. -- Setup/start-state flows should be declared separately from proof nodes so evidence can focus on the AC interaction. - -Every non-terminal recipe node needs `intent`: one short HUD/trace line for what the agent is doing now. Do not use generic/action/node/selector/test-id/title/description/note text, and do not author sub-intent/HUD fields. - -Action classes: - -- Portable/base: `command`, `wait`, `assert_json`, `assert_file`, `assert_exit_code`, `assert_output`, `watch_logs`, `index_artifacts`, `end`. -- Manifest-declared UI/app/CDP: `ui.press`, `ui.set_input`, `ui.scroll`, `ui.wait_for`, `ui.screenshot`, `app.status`, `app.hud`, supported `ui.navigate`, supported `cdp.target`. -- Flow composition: manifest-declared `call` when the runner publishes reusable flow catalogs. -- MetaMask custom: manifest-declared `metamask.wallet.*` and `metamask.perps.*`; prefer `metamask.wallet.ensure_unlocked` and `metamask.perps.start_state` for setup when available. `metamask.debug.*` is reserved but should not be used unless the target manifest declares an E2E-validated debug action. - -Prefer named project actions over raw eval. If raw eval is unavoidable, keep it to inspection/setup and explain why the user-facing claim is still proven. - -Runner expectations: - -- `command` runs from the target repo root and returns stdout/stderr/exitCode in node output. If a recipe needs a durable log artifact, redirect output explicitly in `cmd`, for example `> logs/test.log 2>&1`. Use `timeout_ms` for commands that can hang or take unbounded time. -- `assert_exit_code` checks the previous command with `expected` as a number, for example `"expected": 0`. Use `expected`; do not use ambiguous fields such as `code`. -- `assert_json` reads a JSON file and evaluates an `assert` object, for example `{ "path": "$.status", "operator": "eq", "value": "pass" }`. -- `assert_file` checks that an expected artifact exists. -- `index_artifacts` writes or updates `artifact-manifest.json`. For the portable overlay, list recipe-authored artifacts that already exist before the graph ends, such as logs, reports, screenshots, and runner metadata. Do not list runner-generated `summary.json` or `trace.json` in this node unless the target runner explicitly writes the manifest after those files exist. -- Recipes that assert an error is absent from logs should record a baseline before the user action, prove the watched stream advanced after the action, and write the searched strings plus baseline/end offsets into the evidence package. -- Every runner should emit `summary.json` and `trace.json` after the graph completes. diff --git a/domains/agentic/skills/recipe-cook/repos/metamask-extension.md b/domains/agentic/skills/recipe-cook/repos/metamask-extension.md deleted file mode 100644 index a2b153e..0000000 --- a/domains/agentic/skills/recipe-cook/repos/metamask-extension.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-cook ---- - -# MetaMask Extension - -Use this overlay when cooking recipes for `metamask-extension`. - -## Runtime Harness - -Before claiming live Extension recipe proof, install and verify `/recipe-harness`: - -```sh -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension install --target . -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension verify --target . --cdp-port -``` - -The same `scripts/recipe-harness.sh` path is mirrored under `.claude/skills/mms-recipe-harness/` and `.cursor/rules/mms-recipe-harness/`; examples use `.agents/skills` because Codex reads that tree. - -Use `mme-4` when available. Record `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/extension/manifest.json` and the verify artifacts. Exclude harness overlay paths from product diffs and PR evidence. - -## Discovery - -Before authoring new actions, inspect the checkout for existing automation: - -```sh -find test tests e2e development temp -iname '*agentic*' -o -iname '*recipe*' -o -iname '*fixture*' -o -iname '*playwright*' -find . -maxdepth 3 -iname '*manifest*' -o -iname '*fixture*' -``` - -Prefer repo-owned browser, extension, fixture, and mock helpers over raw CDP snippets. - -## Preferred Surfaces - -- Existing e2e fixtures for unlocked wallets, networks, dapps, and permissions. -- Browser or extension automation already used by the repo. -- Project-owned helpers for service worker/background state. -- Command recipes for reducers, selectors, controllers, migrations, or build artifacts. - -## Common Action Mapping - -Use only action names declared by the installed action manifest. Typical Extension mappings are: - -- Launch extension: `/recipe-harness` live/verify flow or runner setup with `--launch-existing-dist`. -- Open route/popup: `ui.navigate` with a raw extension `hash` route, e.g. `{ "hash": "#/?tab=perps" }`. -- Probe browser/extension runtime: `cdp.target` for target metadata and reachability. -- Interact with UI: `ui.press`, `ui.wait_for`, `ui.scroll`, `ui.screenshot`, and any manifest-declared text-entry action. -- Assert internal/domain state: command-level tests, `assert_json`, or manifest-declared domain actions such as `metamask.wallet.read_state` and `metamask.perps.assert_positions`. -- Capture proof: `ui.screenshot`, trace, console log, test report, or state JSON, then `index_artifacts` for extra files not registered by the runner. - -## Extension Quality Bar - -- Name the browser/channel, extension build, fixture, and dapp/network dependency. -- Use UI evidence for user-visible claims and command/state evidence for internal claims. -- Wait for route, selector, service worker response, or controller state before screenshots. -- Do not use backend or state probes as the primary proof of a popup UI claim. -- Keep raw CDP and service worker eval scoped, named, and tied to the claim. diff --git a/domains/agentic/skills/recipe-cook/repos/metamask-mobile.md b/domains/agentic/skills/recipe-cook/repos/metamask-mobile.md deleted file mode 100644 index 6b7186f..0000000 --- a/domains/agentic/skills/recipe-cook/repos/metamask-mobile.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-cook ---- - -# MetaMask Mobile - -Use this overlay when cooking recipes for `metamask-mobile`. - -## Discovery - -Before authoring new actions, inspect what the checkout already exposes: - -```sh -find scripts test e2e -iname '*agentic*' -o -iname '*recipe*' -o -iname '*fixture*' -yarn --silent a:status 2>/dev/null || true -``` - -If `/recipe-wallet-control` is installed, read its Mobile overlay and action vocabulary. Treat it as an implementation layer for wallet primitives, not as the recipe contract. - -## Runtime Harness - -Before claiming live Mobile recipe proof, install and verify `/recipe-harness`: - -```sh -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh mobile install --target . -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh mobile verify --target . -``` - -The same `scripts/recipe-harness.sh` path is mirrored under `.claude/skills/mms-recipe-harness/` and `.cursor/rules/mms-recipe-harness/`; examples use `.agents/skills` because Codex reads that tree. - -Do this especially on historical commits, where the checked-out runner may be stale or absent. Record `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/mobile/manifest.json` and the verify artifacts. Exclude harness overlay paths from product diffs and PR evidence. - -## Preferred Surfaces - -- `/recipe-harness` verified Mobile runtime for live recipe proof. -- Existing e2e flows and page objects for navigation and selectors. -- Existing fixtures for wallet/account/network setup. -- Simulator/device status commands before UI work. -- Manifest-declared app, UI, wallet, and domain actions exposed by the installed runner manifest. - -## Common Action Mapping - -Use only action names declared by the installed action manifest. Typical Mobile mappings are: - -- Open app area/screen: `ui.navigate` with a raw `route` (and optional `params`), e.g. `{ "route": "PerpsMarketListView" }` or `{ "route": "PerpsMarketDetails", "params": { "market": { "symbol": "ETH" } } }`. -- Tap: `ui.press` with a stable `test_id`, text, or page-object target. -- Enter text: use a domain action that owns the flow unless the installed manifest declares a text-entry UI action. -- Scroll: `ui.scroll` with direction/target parameters. -- Wait: `ui.wait_for` with `test_id`, `text`, `expected`, or `visible` (manifest-declared fields). -- Assert wallet/app/domain state: manifest-declared actions such as `metamask.wallet.read_state`, `metamask.perps.assert_positions`, or `assert_json` over a real artifact/output. -- Capture proof: `ui.screenshot` after `ui.wait_for` or a domain assertion. -- Index proof: `index_artifacts` for screenshots/logs not automatically registered by the runner. -- Reset state: fixture setup, app relaunch, or manifest-declared project cleanup action. - -## Mobile Quality Bar - -- State the simulator/device, platform, build type, and wallet fixture. -- Prefer focused tests or recipe commands over broad lint/test globs. Do not run full-repo eslint or unbounded `**/*` commands from recipes. -- Avoid recipes that rely on arbitrary sleeps. -- Add `timeout_ms` to slow Mobile commands so runner output records a real timeout instead of leaving the operator to infer a stall. -- Avoid raw runtime eval as the only proof of user-visible behavior. -- Teardown or isolate wallet state so repeated runs do not inherit balances, permissions, pending txs, or network changes. -- If a recipe cannot be run, include the missing device/build/fixture requirement as a gap. diff --git a/domains/agentic/skills/recipe-cook/skill.md b/domains/agentic/skills/recipe-cook/skill.md deleted file mode 100644 index 5893fde..0000000 --- a/domains/agentic/skills/recipe-cook/skill.md +++ /dev/null @@ -1,202 +0,0 @@ ---- -name: recipe-cook -description: Author, run, and refine executable per-PR validation recipes for MetaMask work. Use when an agent needs to turn acceptance criteria, changed behavior, or reviewer requests into a portable recipe graph with concrete proof targets, project-native actions, and reviewable artifacts. Recipes may use recipe-wallet-control when available, but must not depend on it. -maturity: experimental ---- - -# Recipe Cook - -`recipe-cook` turns PR claims into executable validation recipes: small graphs that map acceptance criteria to project-native actions, assertions, and reviewable artifacts. - -Load only the files needed for the target repo: - -- Canonical protocol: `$FARMSLOT_ROOT/docs/RECIPE-PROTOCOL-V1.md` -- Skill-local recipe format summary: `references/recipe-v1.md` -- Mobile-first recipe examples and composition patterns: `references/examples.md` -- Evidence package shape: `references/evidence-package.md` -- Runtime harness: use `/recipe-harness` before claiming live Mobile or Extension recipe proof. -- Action catalog: discover the installed runner manifest/schema first (for MetaMask, `metamask-recipe actions --adapter --json` or the installed `action-manifest.json`); use only manifest-declared action names. -- Runner CLI: run recipes through `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//runner/bin/metamask-recipe run ...`, then validate artifacts with `farmslot-recipe validate ...`. -- Target-repo instructions are appended below when installed. - - - -## Composition and Start-State Contract - -Production recipes should be composed like small programs, not written as one long automation script. The default shape is: - -1. declare proof targets from ACs; -2. call idempotent `ensure_*` setup/start-state flows; -3. run the smallest AC-specific proof flow; -4. assert the settled result; -5. capture reviewer-visible evidence; -6. teardown only what the recipe created. - -Use parameterized `ensure_*` flows for reusable convergence; do not create separate positive/negative or route-specific flows when one typed parameter covers the same domain concept. An ensure flow may inspect current state, perform only the transitions needed, and must prove its postcondition before continuing. Do not inline repeated wallet unlock, network/provider selection, navigation, or position setup in every recipe when the runner publishes a domain flow. - -For MetaMask Perps, prefer the installed manifest's current executable actions. Today that means direct `metamask.*` action nodes; use `call` only when the installed manifest explicitly advertises flow-catalog support. - -```json -{ - "action": "metamask.perps.start_state", - "phase": "start_state", - "network": "testnet", - "provider": "hyperliquid", - "page": "positions", - "market": "BTC", - "positions": { - "state": "none", - "mode": "matching" - }, - "orders": { - "state": "none", - "mode": "matching" - }, - "intent": "Converge Perps to the requested start state before proof" -} -``` - -`metamask.perps.start_state` is the Perps convergence point for starting page, mainnet/testnet, provider selection, optional market, and optional position/order precondition. Other teams should publish equivalent domain start-state actions or catalogs, for example `checkout.ensure_cart`, `orders.ensure_seeded`, or `cli.ensure_logged_in`. - -## Mobile-First Quick Example - -For Mobile, start with a small proof-target map, then compose existing flows or actions: - -- PT-1: app is reachable on the intended simulator/device. -- PT-2: the target screen can be opened through the UI/navigation layer. -- PT-3: the changed state is asserted after a real wait condition. -- PT-4: reviewer-visible evidence is captured after the assertion. - -Minimal Mobile smoke recipe shape: - -```json -{ - "schema_version": 1, - "validate": { - "workflow": { - "pre_conditions": ["mm-4 or another intended simulator is booted", "debug app is installed"], - "entry": "status", - "nodes": { - "status": { - "action": "app.status", - "intent": "PT-1: read app status (device, platform, route) through the app.status action", - "timeout_ms": 30000, - "next": "ensure-unlocked" - }, - "ensure-unlocked": { - "action": "metamask.wallet.ensure_unlocked", - "intent": "PT-1: idempotently reach an unlocked wallet before navigating", - "timeout_ms": 45000, - "next": "navigate-wallet" - }, - "navigate-wallet": { - "action": "ui.navigate", - "intent": "PT-2: open the wallet view through the UI/navigation layer", - "route": "WalletView", - "timeout_ms": 30000, - "next": "wait-wallet" - }, - "wait-wallet": { - "action": "ui.wait_for", - "intent": "PT-3: after navigation settles, the wallet screen is present", - "test_id": "wallet-screen", - "expected": "present", - "timeout_ms": 30000, - "next": "capture" - }, - "capture": { - "action": "ui.screenshot", - "intent": "PT-4: reviewer-visible settled wallet screen", - "path": "screenshots/mobile-smoke-wallet.png", - "next": "index-artifacts" - }, - "index-artifacts": { - "action": "index_artifacts", - "intent": "Index the screenshot proof", - "artifacts": ["screenshots/"], - "next": "done" - }, - "done": { "action": "end", "status": "pass" } - } - } - } -} -``` - -For product recipes, replace the smoke nodes with runner-supported actions for the PR's claim: navigate first (`ui.navigate`), wait for settled state (`ui.wait_for`), assert state/UI (`assert_json`, or a manifest-declared read/assert action), then capture evidence (`ui.screenshot`). Compose reusable `metamask.wallet.*` / `metamask.perps.*` setup/start-state actions only where the installed manifest advertises them. See `references/examples.md` for concrete Mobile composition patterns. - -## When to Use - -Use this skill for PRs that need runtime proof, reproducible evidence, or a repeatable reviewer flow. Skip recipe authoring only when the change is static-only and ordinary lint/type/unit checks fully prove it. - -## Hard Rules - -- Start from acceptance criteria or changed behavior, not from available tooling. -- Each proof target must have an action path, an assertion, and evidence when the result is reviewer-visible. -- Production recipes must declare a setup/start-state contract. Prefer idempotent `ensure_*` flows instead of repeated inline setup. -- User-visible UI claims need visual evidence. A recipe that only asserts state - or passes unit tests is incomplete for a visible banner, modal, button, route, - balance, form, or error-message claim unless the visual gap is explicitly - marked blocked. -- Runtime proof is not complete until the run emits `summary.json`, - `trace.json`, and an `artifact-manifest.json`/evidence manifest that indexes - the screenshots, videos, logs, or state files used as proof. -- Prefer manifest-declared runner actions, existing repo fixtures, page objects, selectors, and test helpers. -- Recipes may use `/recipe-wallet-control` where installed, but must remain understandable without that skill. -- Do not include SRPs, private keys, bearer tokens, production account dumps, or private user data. -- Do not mark a recipe proven unless it was run or the unrun gap is explicit. - -## Workflow - -1. **Extract proof targets** - - Read the PR/task, changed files, issue, and acceptance criteria. - - Write 1-5 concrete proof targets: each should be observable, executable, and small enough to fail clearly. - - Mark any manual or environment-only target explicitly; do not hide untestable claims. - -2. **Choose the execution surface** - - Prefer the MetaMask runner manifest/schema installed by `/recipe-harness`. - - Use the installed repo overlay before inventing actions; recipe graph execution should go through the runner. - - Use UI/mobile/browser actions only for user-facing behavior. - - Use command and JSON assertions for backend, static, or artifact-only behavior. - -3. **Author the recipe graph** - - Use the schema envelope in `references/recipe-v1.md`. - - Start with reusable `ensure_*` flows for setup/start-state when the runner publishes them. - - Keep setup/start-state/proof/assert/teardown boundaries explicit. - - Give every non-terminal node a stable id, an `action`, and a human-readable `intent`. - - Every non-terminal node must transition with `next`, `cases`, or `default`. - - Every assertion should point back to a proof target. - - Put proof recording/screenshots around the AC interaction, not around generic setup unless setup is the claim. - - For screenshots/videos, `note` is optional artifact caption text only. It is not the HUD text; captions may fall back to node `intent` when `note` is absent. - - For `assert_exit_code`, use `"expected": 0` or another numeric expected code. Do not use `"code"`. - - Add `timeout_ms` to commands that can hang, such as focused Jest, build, simulator, or browser checks. - -4. **Run or dry-run what you can** - - Execute non-destructive commands on the target device/session when available. - - For historical commits or fresh checkouts, run `/recipe-harness install` and `/recipe-harness verify` before judging runner support. - - Treat dry-run as schema validation only; a recipe is not proven until the run emits `summary.json`, `trace.json`, and the named artifacts. - - Runtime proof must record the harness adapter, source/version, verification status, and artifact paths. - - Save artifacts under `/tmp` or a repo-ignored evidence directory unless the user asks to commit them. - - If a runner is missing, still produce the recipe plus the exact command or adapter work needed to run it. - -5. **Package evidence** - - Follow `references/evidence-package.md`. - - Include screenshots/videos/logs/reports only when they prove a named target. - -6. **Quality loop** - - Use `/recipe-quality` before calling the recipe done. - - Fix must-fix critique items, rerun if possible, then summarize remaining gaps honestly. - - If the critique says visual evidence is missing for a visible UI claim, - improve the recipe/evidence package or mark the proof target as blocked; - do not downgrade the claim to unit-test-only proof. - -## Output Format - -When cooking, return: - -1. `Proof Targets` — numbered claims and how each is proven. -2. `Recipe` — path plus important graph nodes, or the full JSON if short. -3. `Run Command` — exact command(s) used or needed. -4. `Artifacts` — paths and what each proves. -5. `Quality Loop` — critique verdict, improvement made, and rerun status. -6. `Gaps / Follow-ups` — only if something remains unrun, manual, flaky, or blocked. diff --git a/domains/agentic/skills/recipe-dev/references/metamask-extension-checklist.md b/domains/agentic/skills/recipe-dev/references/metamask-extension-checklist.md deleted file mode 100644 index 2e90de0..0000000 --- a/domains/agentic/skills/recipe-dev/references/metamask-extension-checklist.md +++ /dev/null @@ -1,34 +0,0 @@ -# Extension dev checklist - -Target checklist for `metamask-extension` feature/dev/investigation work. Start from desired -behavior and ACs; do not reproduce a known bug unless the task asks. - -This file is the human's live progress view. `init-checklist.sh` copies it to the task -folder as `CHECKLIST.md`. Execute top-to-bottom; the moment a gate completes, flip -`[ ]` → `[x]` (or `BLOCKED: ` / `N/A: `) and add the artifact path/result -under it. - -- [ ] 0. Coffee handoff sent, naming this CHECKLIST.md path to monitor. -- [ ] 1. Task captured — URL or pasted text, summary, ACs. -- [ ] 2. AC matrix — numbered ACs; proof mode state/visual/mixed; primary evidence. -- [ ] 3. Extension target selected — popup, sidepanel, fullscreen, dapp tab, or service-worker/controller + rationale. -- [ ] 4. Proof plan written before implementation — fixture/profile, route/tab, selectors/testIDs, expected after evidence; before evidence or `Baseline: N/A`. -- [ ] 5. /mms-recipe-doctor setup readiness recorded — fixtures/tools; malformed fixture or missing tool = BLOCKED. -- [ ] 6. /mms-recipe-harness install/verify when runtime proof applies — manifest + verify path, or `N/A: `. -- [ ] 7. /mms-recipe-cook drafted recipe — path + exact command covering ACs. -- [ ] 8. Minimal implementation — product diff summary; every changed line maps to an AC, no unrelated refactor. -- [ ] 9. Focused checks run — changed-file typecheck/Jest/lint. Not a stop gate. -- [ ] 10. Runtime recipe run when applicable — summary.json, trace.json, manifest, screenshots/video. -- [ ] 11. Visual evidence gate — read PNGs; claimed UI visible in viewport for visual/mixed ACs. -- [ ] 12. /mms-recipe-quality critique — verdict + gaps. -- [ ] 13. Improvement/rerun loop — one fix + rerun, or explicit no-rerun verdict. -- [ ] 14. /mms-recipe-evidence package — PR-ready evidence block/file. -- [ ] 15. Final response — change, tests, recipe evidence, quality loop, gaps. Ask about runtime cleanup; offer PR on consent. - -Extension notes: - -- Name the browser context and UI target (`popup`, `sidepanel`, `fullscreen`, dapp tab, or service worker/controller). -- Runtime start (webpack/dev server/Chrome/CDP) is approval-gated: without approval, record `BLOCKED: pending runtime-start approval` with the exact command and wait; with approval, start through `/mms-recipe-harness`, not raw builds. A live recipe that can't reach CDP (`/json/version` unreachable, no extension target, no summary.json) is `BLOCKED: CDP bootstrap failed` — fix the root cause, don't downgrade to pass-with-gaps. -- Visual/mixed ACs need a viewport-visible screenshot (`ui.scroll` + `ui.wait_for visible`), not DOM/controller state or a passing recipe alone; without a runtime PNG it is `BLOCKED: no runtime visual evidence`, never `code-proven`. -- No manufactured state: don't inject via `stateHooks`, store/controller writes, or DOM mutation. Use a real UI flow or harness pre-start fixture, else mark the AC a fixture/runtime gap. -- A fallback screenshot (`DOM-rendered fallback` / `fallbackReason` in trace.json) keeps that visual AC at `PASS-WITH-GAPS` even if summary.json says pass. diff --git a/domains/agentic/skills/recipe-dev/references/metamask-mobile-checklist.md b/domains/agentic/skills/recipe-dev/references/metamask-mobile-checklist.md deleted file mode 100644 index 04d35ae..0000000 --- a/domains/agentic/skills/recipe-dev/references/metamask-mobile-checklist.md +++ /dev/null @@ -1,34 +0,0 @@ -# Mobile dev checklist - -Target checklist for `metamask-mobile` feature/dev/investigation work. Start from desired -behavior and ACs; do not reproduce a known bug unless the task asks. - -This file is the human's live progress view. `init-checklist.sh` copies it to the task -folder as `CHECKLIST.md`. Execute top-to-bottom; the moment a gate completes, flip -`[ ]` → `[x]` (or `BLOCKED: ` / `N/A: `) and add the artifact path/result -under it. - -- [ ] 0. Coffee handoff sent, naming this CHECKLIST.md path to monitor. -- [ ] 1. Task captured — URL or pasted text, summary, ACs. -- [ ] 2. AC matrix — numbered ACs; proof mode state/visual/mixed; primary evidence. -- [ ] 3. Mobile target selected — ios, android, or both + rationale. -- [ ] 4. Proof plan written before implementation — fixture/state, route, selectors/testIDs, expected after evidence; before evidence or `Baseline: N/A`. -- [ ] 5. /mms-recipe-doctor setup readiness recorded — fixtures/tools; malformed fixture or missing tool = BLOCKED. -- [ ] 6. /mms-recipe-harness install/verify when runtime proof applies — manifest + verify path, or `N/A: `. -- [ ] 7. /mms-recipe-cook drafted recipe — path + exact command covering ACs. -- [ ] 8. Minimal implementation — product diff summary; every changed line maps to an AC, no unrelated refactor. -- [ ] 9. Focused checks run — changed-file typecheck/Jest/lint. Not a stop gate. -- [ ] 10. Runtime recipe run when applicable — summary.json, trace.json, manifest, screenshots/video. -- [ ] 11. Visual evidence gate — read PNGs; claimed UI visible in viewport for visual/mixed ACs. -- [ ] 12. /mms-recipe-quality critique — verdict + gaps. -- [ ] 13. Improvement/rerun loop — one fix + rerun, or explicit no-rerun verdict. -- [ ] 14. /mms-recipe-evidence package — PR-ready evidence block/file. -- [ ] 15. Final response — change, tests, recipe evidence, quality loop, gaps. Ask about runtime cleanup; offer PR on consent. - -Mobile notes: - -- Name the target (`ios`/`android`/`both`). Prefer after evidence for new UI; use before/after only when a meaningful prior state exists. -- Runtime start (Metro/simulator) is approval-gated: without approval, record `BLOCKED: pending runtime-start approval` with the exact command and wait; with approval, start through `/mms-recipe-harness`, not raw `yarn`/native rebuilds. -- Visual/mixed ACs need a viewport-visible screenshot (`ui.scroll` + `ui.wait_for visible`), not fiber-tree/controller state or a passing recipe alone; without a runtime PNG it is `BLOCKED: no runtime visual evidence`, never `code-proven`. -- No manufactured state: don't inject via `stateHooks`, store/controller writes, or DOM/fiber mutation. Use a real UI flow or harness pre-start fixture, else mark the AC a fixture/runtime gap. -- A fallback screenshot (`DOM-rendered fallback` / `fallbackReason` in trace.json) keeps that visual AC at `PASS-WITH-GAPS` even if summary.json says pass. diff --git a/domains/agentic/skills/recipe-dev/repos/metamask-extension.md b/domains/agentic/skills/recipe-dev/repos/metamask-extension.md deleted file mode 100644 index 9a9078d..0000000 --- a/domains/agentic/skills/recipe-dev/repos/metamask-extension.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-dev ---- - -# MetaMask Extension - -For Extension dev tasks, classify whether proof belongs in popup UI, full-screen UI, service worker/controller state, dapp interaction, permissions, network, transaction, migration, or build/config behavior. - -Use the runner-appropriate `mms-recipe-harness` delegate (Codex: `$mms-recipe-harness`; Claude/Cursor: `/mms-recipe-harness`) or its installed portable `scripts/recipe-harness verify` wrapper before live CDP proof. Save recipe artifacts under an ignored task directory such as `temp/tasks//artifacts/` and report native screenshot/CDP gaps explicitly instead of claiming visual proof from DOM/state alone. Do not require personal shell aliases. - -Load `references/metamask-extension-checklist.md` and execute it as the ordered checklist for Extension dev work. Name the target context: popup, sidepanel, fullscreen, dapp tab, or service-worker/controller. diff --git a/domains/agentic/skills/recipe-dev/repos/metamask-mobile.md b/domains/agentic/skills/recipe-dev/repos/metamask-mobile.md deleted file mode 100644 index c1ffd0c..0000000 --- a/domains/agentic/skills/recipe-dev/repos/metamask-mobile.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-dev ---- - -# MetaMask Mobile - -For Mobile dev tasks, prove visible changes on the intended simulator/device when practical. Use the runner-appropriate `mms-recipe-harness` delegate (Codex: `$mms-recipe-harness`; Claude/Cursor: `/mms-recipe-harness`) or its installed portable `scripts/recipe-harness verify` wrapper before live recipe proof, and keep harness/generated files out of the product diff summary. Do not require personal shell aliases. - -Prefer existing fixtures, page objects, and wallet-control primitives before adding new runtime helpers. If the simulator/app cannot be prepared, mark runtime proof blocked with the verify/preflight artifact path. - -Load `references/metamask-mobile-checklist.md` and execute it as the ordered checklist for Mobile dev work. Runtime proof should avoid inherited simulator state and name the fixture/setup flow used. diff --git a/domains/agentic/skills/recipe-dev/scripts/init-checklist.sh b/domains/agentic/skills/recipe-dev/scripts/init-checklist.sh deleted file mode 100755 index 6604f1c..0000000 --- a/domains/agentic/skills/recipe-dev/scripts/init-checklist.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat >&2 <<'USAGE' -Usage: init-checklist.sh --platform mobile|extension [--slug task-slug] [--task-dir path] - -Creates a live CHECKLIST.md copied from this skill's embedded -platform checklist. Prints the CHECKLIST.md path on stdout. -USAGE -} - -platform="" -slug="task" -task_dir="" -while [ "$#" -gt 0 ]; do - case "$1" in - --platform) platform="${2:-}"; shift 2 ;; - --slug) slug="${2:-}"; shift 2 ;; - --task-dir) task_dir="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; - esac -done - -case "$platform" in - mobile|extension) ;; - *) echo "--platform must be mobile or extension" >&2; usage; exit 2 ;; -esac - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -skill_dir="$(cd "$script_dir/.." && pwd)" -skill_name="$(basename "$skill_dir")" -ref="$skill_dir/references/metamask-${platform}-checklist.md" -if [ ! -f "$ref" ]; then - echo "Checklist reference not found: $ref" >&2 - exit 1 -fi - -safe_slug="$(printf '%s' "$slug" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')" -[ -n "$safe_slug" ] || safe_slug="task" -if [ -z "$task_dir" ]; then - ts="$(date -u +%Y%m%dT%H%M%SZ)" - task_dir="temp/tasks/${skill_name}/${ts}-${safe_slug}" -fi -mkdir -p "$task_dir/artifacts" -out="$task_dir/CHECKLIST.md" -{ - printf '# Live Recipe Workflow Checklist\n\n' - printf 'Generated: %s\n\n' "$(date -Iseconds)" - printf 'Skill: `%s`\n\n' "$skill_name" - printf 'Platform: `%s`\n\n' "$platform" - printf 'Task slug: `%s`\n\n' "$safe_slug" - printf '> Human progress file: monitor this file. The agent must mark each gate `[ ]` → `[x]` as work progresses and add artifact paths/results under the relevant gate.\n\n' - cat "$ref" -} > "$out" -printf '%s\n' "$out" diff --git a/domains/agentic/skills/recipe-dev/skill.md b/domains/agentic/skills/recipe-dev/skill.md deleted file mode 100644 index 8498e12..0000000 --- a/domains/agentic/skills/recipe-dev/skill.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: recipe-dev -description: Build a MetaMask feature, investigation, or product change from a task/ticket with acceptance criteria and prove it with a recipe. The human hands off a task and walks away; this drives task → implementation → recipe proof → evidence package, then stops for review. Use when an agent implements desired behavior without first reproducing a bug. For bug fixes that need reproduction first, use /mms-recipe-fix-ticket. -maturity: experimental ---- - -# Recipe Dev - -A thin orchestrator over the recipe pipeline (`/mms-recipe-doctor` → `/mms-recipe-harness` → -`/mms-recipe-cook` → `/mms-recipe-quality` → `/mms-recipe-evidence`). The human starts it and -leaves; you run autonomously to a finalized, reviewer-ready result. Unlike -`/mms-recipe-fix-ticket`, you start from desired behavior and clear ACs — no time spent -reproducing an existing failure unless the task asks for it. - -The proof, runtime, and recipe rules live in the lower skills. This wrapper only orders the -delegates, keeps the run honest, and stops with an evidence package. Do **not** re-derive -harness/recipe/runtime detail here — invoke or follow the named delegate instead. - -## Handoff (do first) - -1. Create the progress file the human monitors, then continue without waiting: - ```bash - .agents/skills/mms-recipe-dev/scripts/init-checklist.sh --platform --slug - ``` - It prints the `CHECKLIST.md` path. Fallback: copy `references/metamask--checklist.md` - to `temp/tasks//-/CHECKLIST.md`. -2. Reply once: *"Go get a coffee ☕ — I'll take it from task → implementation → recipe → - evidence and report back when it's done or concretely blocked. Live progress: ``."* - Then run autonomously; do not wait for the human after this. -3. `CHECKLIST.md` is the source of truth. Mark each gate `[x]` / `BLOCKED: ` / - `N/A: ` with its artifact path as you go — not in one batch at the end. - -## Source of truth - -The task prompt or pasted details are authoritative. If the task source returns a login -wall, empty issue, or ambiguous page, ask the human once for the summary + acceptance -criteria, then continue. Never infer or rewrite ACs from branch names, stale artifacts, or -prior runs. Before editing product code, print the numbered AC matrix: target surface, state -precondition, exact copy, and proof mode (`state` / `visual` / `mixed`). Label any inferred -field `UNKNOWN` and ask rather than guess. - -## Delegate chain (in order) - -Each gate must actually invoke or follow the named skill; ad-hoc scripts, controller evals, -or screenshots do not satisfy it. Record the delegate output path or blocker in `CHECKLIST.md`. - -1. `/mms-recipe-doctor` — setup/fixture readiness. A malformed fixture or missing tool/harness - is `BLOCKED`; fix before product edits. -2. `/mms-recipe-harness` — install/verify the runtime when live proof applies. -3. `/mms-recipe-cook` — draft the happy-path recipe before or alongside implementation; note - whether a before/baseline is meaningful or `Baseline: N/A`. recipe-cook owns recipe format, - proof modes, reuse, and the no-fake-state rule. -4. Implement the **smallest** product change that satisfies the ACs (every changed line traces - to an AC; no adjacent refactors). Run focused lint/type/unit checks — passing only unlocks - proof, it is not a stop point. -5. Run the recipe live and read the screenshots yourself before trusting `status: pass`. -6. `/mms-recipe-quality` — critique against the AC matrix; apply one improve/rerun cycle or - record that none is needed. -7. `/mms-recipe-evidence` — PR-ready package: product diff, recipe path, run command, - `summary.json`, `trace.json`, artifact manifest, screenshots, quality verdict, gaps. - -## Safety invariants - -- **Runtime start is approval-gated.** Do not start or restart Metro, a simulator, webpack, - or Chrome/CDP — including wrappers, aliases, `nohup`, or background tmux that do — without - approval. Missing approval → record `BLOCKED: pending runtime-start approval` with the exact - command and wait. With approval, drive starts through `/mms-recipe-harness`, not raw builds. -- **No manufactured state.** Do not prove a user-visible AC by injecting state (`stateHooks`, - Redux/store writes, fiber/DOM mutation, controller/background calls). Valid proof is a real - UI-flow recipe or a harness-loaded pre-start fixture; otherwise mark the AC - `BLOCKED`/`PASS-WITH-GAPS` and name the missing fixture. -- **Fallback screenshots are not clean visual proof.** A PNG marked DOM-fallback / - native-capture-blank, or a `trace.json` `fallbackReason`, keeps that visual AC at - `PASS-WITH-GAPS` even when `summary.json` says pass. - -## Honest verdict - -Final verdict is `PASS` only when every AC proof target passed. Any unrun, blocked, or -fallback-only AC ⇒ `PASS-WITH-GAPS` or `PARTIAL`, listed by AC number. Never claim "all ACs -met" or "ready" from a code diff or unit tests alone. For visual/mixed ACs, "code-proven" is -not a valid status. - -## Ordered checklist - -`init-checklist.sh` copies the platform checklist into `CHECKLIST.md` **for the human to -follow** — it is their live progress view while they wait. Execute it in order and flip each -box `[ ]` → `[x]` (or `BLOCKED`/`N/A`) with its artifact path the moment that gate completes, -so the human watching the file always sees the true current state. The canonical sequence: - -```markdown -- [ ] 0. Coffee handoff sent. -- [ ] 1. Task captured: URL or pasted text, summary, ACs. -- [ ] 2. AC matrix: each AC numbered with proof mode (state/visual/mixed). -- [ ] 3. Target runtime selected (Mobile/Extension + env). -- [ ] 4. Proof plan written (before evidence meaningful, or Baseline: N/A). -- [ ] 5. /mms-recipe-doctor setup status recorded. -- [ ] 6. /mms-recipe-harness install/verify; manifest path recorded. -- [ ] 7. /mms-recipe-cook drafted the recipe + run command. -- [ ] 8. Smallest product change implemented. -- [ ] 9. Focused checks run (type/jest/lint). -- [ ] 10. Recipe run live; summary.json/trace.json/manifest paths recorded. -- [ ] 11. Screenshots read; claimed UI visible (not hidden/offscreen/wrong tab). -- [ ] 12. /mms-recipe-quality critique against AC matrix + artifacts. -- [ ] 13. One improve/rerun cycle, or quality says none needed. -- [ ] 14. /mms-recipe-evidence PR-ready package produced. -- [ ] 15. Final report: change, recipe evidence, quality loop, gaps. Offer PR on consent. -``` - -Steps 1–4 precede implementation. A runtime blocker is valid only after step 6 or the -relevant recipe run was actually attempted and the exact failure recorded. - -## Finish - -Ask whether to clean up runtime resources (Metro port, simulator/device, webpack/CDP, tmux -pane) now or leave them for review. Once evidence is packaged, ask whether to open a PR; if -yes, use `/mms-recipe-evidence` "Create PR + upload" (owner via `gh api user`, consent-gate -every outward step). Then return: - -1. `Change` — files changed and why. -2. `Recipe` — path and run command. -3. `Evidence` — artifacts and verdict. -4. `Quality Loop` — critique, fix/rerun, or why first pass is enough. -5. `Human Check` — what still needs reviewer/product validation. diff --git a/domains/agentic/skills/recipe-doctor/repos/metamask-extension.md b/domains/agentic/skills/recipe-doctor/repos/metamask-extension.md deleted file mode 100644 index 37e09a8..0000000 --- a/domains/agentic/skills/recipe-doctor/repos/metamask-extension.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-doctor ---- - -# Recipe Doctor - MetaMask Extension - -For Extension readiness, doctor must check: - -- `bash`, `node`, `git`, and `curl`; -- a reachable browser command hint when live validation is expected; -- installed `mms-recipe-harness`, `mms-recipe-wallet-control`, `mms-recipe-cook`, `mms-recipe-evidence`, and one high-level workflow skill; -- `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/extension/runner/bin/metamask-recipe`, `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/extension/action-manifest.json`, and `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/extension/runner/recipes` after harness install; -- `temp/runtime/agentic-runtime.json` for caller-provided CDP/runtime context; -- fixture/profile hints such as `temp/runtime/wallet-fixture.json`, `.agent/wallet-fixture.json`, `temp/runtime/extension.id`, `test/e2e/fixtures`, or `fixtures`; -- Playwright Chromium availability from the Extension checkout; this is the closest portable equivalent to an isolated Playwright Chromium launch; -- static no-start harness verify output when `mms-recipe-harness` is available. - -If fixture/profile hints are missing, report a warning with the shared-fixture-compatible shared shape: `temp/runtime/wallet-fixture.json` or `.agent/wallet-fixture.json` should include `password`, `accounts[0]` mnemonic named `Primary`, optional private-key accounts named `Trading` / `MYXTrading`, optional `selectedAccount`, and `settings.skipPerpsTutorial=true`, `settings.autoLockNever=true`. The Extension harness generates `address`, `vault`, and persisted controller state from this fixture before live launch. - -If browser/CDP readiness is missing, recommend an isolated browser command instead of the user's normal Chrome profile: - -```bash -.agents/skills/mms-recipe-harness/scripts/recipe-harness live \ - --cdp-port \ - --launch-existing-dist \ - --chrome-user-data-dir temp/runtime/chrome-profile-recipe -``` - -If Playwright Chromium is missing, tell the user that `mms-recipe-harness live` uses the repo's Playwright package but must not install Chromium automatically. Ask the user first; if they approve, run `yarn exec playwright install chromium` after dependencies are installed to populate the user-level Playwright browser cache without package.json changes, or set `RECIPE_HARNESS_CHROME_BIN` to a browser they explicitly chose. If `dist/chrome` is missing, add `--start-test-watch` only after the user accepts the build/watch cost. diff --git a/domains/agentic/skills/recipe-doctor/repos/metamask-mobile.md b/domains/agentic/skills/recipe-doctor/repos/metamask-mobile.md deleted file mode 100644 index ba97552..0000000 --- a/domains/agentic/skills/recipe-doctor/repos/metamask-mobile.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-doctor ---- - -# Recipe Doctor - MetaMask Mobile - -For Mobile readiness, doctor must check: - -- `bash`, `node`, `git`, and `curl`; -- iOS/Android host hints (`xcrun` on macOS, `adb` when Android validation is expected); -- installed `mms-recipe-harness`, `mms-recipe-wallet-control`, `mms-recipe-cook`, `mms-recipe-evidence`, and one high-level workflow skill; -- the v1 runner bin `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/mobile/runner/bin/metamask-recipe`, the installed `action-manifest.json`, and a successful `metamask-recipe manifest --adapter mobile --json` after harness install; -- fixture status for `.agent/wallet-fixture.json`, falling back to `scripts/perps/agentic/wallet-fixture.json`; -- static no-start harness verify output when `mms-recipe-harness` is available. - -If the fixture is missing, recommend copying `scripts/perps/agentic/wallet-fixture.example.json` to `.agent/wallet-fixture.json` and editing it with local development accounts. The message should be concrete and shared-fixture-compatible: include one mnemonic account for first vault setup, optionally include private-key accounts named `Trading` / `MYXTrading` for funded flows, and set `metametrics=true`, `skipGtmModals=true`, `skipPerpsTutorial=true`, `autoLockNever=true`, and `deviceAuthEnabled=true` (applied by the Mobile harness on Android). Do not ask the agent to repair wallet state manually until fixture setup is either declined or impossible. diff --git a/domains/agentic/skills/recipe-doctor/scripts/recipe-doctor b/domains/agentic/skills/recipe-doctor/scripts/recipe-doctor deleted file mode 100755 index 4042db7..0000000 --- a/domains/agentic/skills/recipe-doctor/scripts/recipe-doctor +++ /dev/null @@ -1,605 +0,0 @@ -#!/bin/sh -set -eu -# Enable pipefail when the shell supports it (bash/ksh invoked as /bin/sh) for -# parity with the adapter scripts; POSIX dash lacks it, so guard to keep this -# script portable — it is invoked via `sh` and verified with `sh -n`. -if ( set -o pipefail ) 2>/dev/null; then - set -o pipefail -fi - -SCRIPT_NAME="recipe-doctor" -TARGET="." -REPO="" -JSON=false -RUN_STATIC_VERIFY=true -ERRORS=0 -WARNINGS=0 -CHECKS="" - -usage() { - cat <<'USAGE' -Usage: - recipe-doctor [--target ] [--repo metamask-mobile|metamask-extension] [--json] [--no-static-verify] - -Diagnoses Recipe v1 setup readiness without starting live runtimes. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --target|-C) - [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; usage >&2; exit 2; } - TARGET="$2" - shift 2 - ;; - --repo) - [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; usage >&2; exit 2; } - REPO="$2" - shift 2 - ;; - --json) - JSON=true - shift - ;; - --no-static-verify) - RUN_STATIC_VERIFY=false - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown arg: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -if [ ! -d "$TARGET" ]; then - echo "Target does not exist: $TARGET" >&2 - exit 2 -fi - -TARGET="$(cd "$TARGET" && pwd -P)" - -# Resolve the configurable harness install root the same way the adapters do -# (recipe-harness scripts/lib/harness-path.sh): honor RECIPE_HARNESS_ROOT, else -# default to temp/agentic/recipe-harness. Validate it (relative, safe charset, no -# '.'/'..' components) so a hostile/typo'd value can't point checks outside the -# target. Replicated here so the doctor stays self-contained and does not depend -# on the harness skill being installed. -resolve_harness_root() { - root="${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}" - case "$root" in - ""|/*) echo "RECIPE_HARNESS_ROOT must be a non-empty relative path: '$root'" >&2; return 1 ;; - *[!A-Za-z0-9._/-]*) echo "RECIPE_HARNESS_ROOT may only contain A-Za-z0-9 and . _ / - : '$root'" >&2; return 1 ;; - esac - oldifs="$IFS" - IFS=/ - for part in $root; do - case "$part" in - .|..) IFS="$oldifs"; echo "RECIPE_HARNESS_ROOT must not contain '.' or '..' path components: '$root'" >&2; return 1 ;; - esac - done - IFS="$oldifs" - printf '%s' "$root" -} -HARNESS_ROOT="$(resolve_harness_root)" - -escape_json() { - awk 'BEGIN { ORS = "" } { - gsub(/\\/, "\\\\") - gsub(/"/, "\\\"") - gsub(/\t/, "\\t") - gsub(/\r/, "\\r") - if (NR > 1) { - printf "\\n" - } - printf "%s", $0 - }' -} - -record_check() { - status="$1" - name="$2" - message="$3" - - case "$status" in - FAIL) ERRORS=$((ERRORS + 1)) ;; - WARN) WARNINGS=$((WARNINGS + 1)) ;; - esac - - if [ "$JSON" = false ]; then - printf '%s %s: %s\n' "$status" "$name" "$message" - fi - - esc_name="$(printf '%s' "$name" | escape_json)" - esc_message="$(printf '%s' "$message" | escape_json)" - entry="{\"status\":\"$status\",\"name\":\"$esc_name\",\"message\":\"$esc_message\"}" - if [ -z "$CHECKS" ]; then - CHECKS="$entry" - else - CHECKS="$CHECKS,$entry" - fi -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -detect_repo() { - repo_hint="" - if command_exists git; then - repo_hint="$(git -C "$TARGET" config --get remote.origin.url 2>/dev/null || true)" - fi - package_name="" - if [ -f "$TARGET/package.json" ] && command_exists node; then - package_name="$(TARGET_PACKAGE="$TARGET/package.json" node -e 'const fs=require("fs"); const p=process.env.TARGET_PACKAGE; try { const data=JSON.parse(fs.readFileSync(p,"utf8")); if (data.name) process.stdout.write(String(data.name)); } catch (_error) { process.exit(0); }' 2>/dev/null || true)" - fi - - case "$repo_hint $package_name" in - *metamask-mobile*) printf '%s\n' "metamask-mobile"; return 0 ;; - *metamask-extension*) printf '%s\n' "metamask-extension"; return 0 ;; - esac - - if [ -f "$TARGET/scripts/skills-sync.mts" ] || { [ -d "$TARGET/ios" ] && [ -d "$TARGET/android" ] && [ -d "$TARGET/app/core" ]; }; then - printf '%s\n' "metamask-mobile" - return 0 - fi - if [ -f "$TARGET/development/skills-sync.ts" ] || { [ -d "$TARGET/ui" ] && [ -d "$TARGET/app/scripts" ]; }; then - printf '%s\n' "metamask-extension" - return 0 - fi - return 1 -} - -if [ -z "$REPO" ]; then - REPO="$(detect_repo || true)" -fi - -case "$REPO" in - metamask-mobile|metamask-extension) - record_check PASS "repo" "$REPO at $TARGET" - ;; - "") - record_check FAIL "repo" "could not detect repo type; pass --repo metamask-mobile or --repo metamask-extension" - ;; - *) - record_check FAIL "repo" "unsupported repo: $REPO" - ;; -esac - -check_tool() { - tool="$1" - required="$2" - if command_exists "$tool"; then - path="$(command -v "$tool")" - record_check PASS "tool:$tool" "$path" - else - if [ "$required" = "required" ]; then - record_check FAIL "tool:$tool" "missing; install or expose it on PATH before using recipe skills" - else - record_check WARN "tool:$tool" "missing; only needed for matching platform/runtime paths" - fi - fi -} - -check_tool bash required -if [ -x /bin/bash ]; then - record_check PASS "tool:/bin/bash" "/bin/bash exists" -else - record_check WARN "tool:/bin/bash" "not found; scripts use env bash, so PATH bash may still be enough" -fi -check_tool node required -check_tool git required -check_tool curl required - -if [ "$REPO" = "metamask-mobile" ]; then - if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ]; then - check_tool xcrun optional - fi - check_tool adb optional -fi - -if [ "$REPO" = "metamask-extension" ]; then - playwright_chromium="" - if command_exists node; then - playwright_chromium="$(cd "$TARGET" 2>/dev/null && node -e 'const fs = require("fs"); for (const pkg of ["@playwright/test", "playwright"]) { try { const { chromium } = require(pkg); const p = chromium.executablePath(); if (p && fs.existsSync(p)) { process.stdout.write(p); process.exit(0); } } catch (_error) { /* optional Playwright package unavailable; try the next package name */ } } process.exit(1);' 2>/dev/null || true)" - fi - if [ -n "$playwright_chromium" ]; then - record_check PASS "tool:chromium" "Playwright Chromium available at $playwright_chromium; preferred for isolated CDP recipe runs" - else - record_check WARN "tool:chromium" "Playwright Chromium executable not found from this checkout. Manual approval required: ask the user before installing anything. If they approve, they can run yarn exec playwright install chromium from the Extension checkout to populate the user-level Playwright browser cache without package.json changes, or explicitly set RECIPE_HARNESS_CHROME_BIN to a browser they choose" - fi - - if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then - record_check PASS "tool:chrome-explicit" "/Applications/Google Chrome.app exists; live.sh will not auto-use it, so set RECIPE_HARNESS_CHROME_BIN only if the user explicitly approves this browser" - elif command_exists chromium; then - record_check PASS "tool:chrome-explicit" "$(command -v chromium); live.sh will not auto-use it, so set RECIPE_HARNESS_CHROME_BIN only if the user explicitly approves this browser" - elif command_exists google-chrome; then - record_check PASS "tool:chrome-explicit" "$(command -v google-chrome); live.sh will not auto-use it, so set RECIPE_HARNESS_CHROME_BIN only if the user explicitly approves this browser" - else - record_check WARN "tool:chrome-explicit" "no explicitly selectable Chrome/Chromium command found; live extension validation needs Playwright Chromium or user-approved RECIPE_HARNESS_CHROME_BIN" - fi -fi - -find_skill_dir() { - skill="$1" - for dir in \ - "$TARGET/.agents/skills/$skill" \ - "$TARGET/.claude/skills/$skill" \ - "$TARGET/.cursor/rules/$skill" - do - if [ -d "$dir" ]; then - printf '%s\n' "$dir" - return 0 - fi - done - return 1 -} - -check_skill() { - skill="$1" - required="$2" - dir="$(find_skill_dir "$skill" || true)" - if [ -n "$dir" ]; then - record_check PASS "skill:$skill" "$dir" - else - if [ "$required" = "required" ]; then - record_check FAIL "skill:$skill" "missing; run yarn skills --domain agentic --maturity experimental or install with --include agentic/${skill#mms-}" - else - record_check WARN "skill:$skill" "missing; install it for the full high-level workflow" - fi - fi -} - -check_skill mms-recipe-doctor optional -check_skill mms-recipe-harness required -check_skill mms-recipe-wallet-control required -check_skill mms-recipe-cook required -check_skill mms-recipe-evidence required -check_skill mms-recipe-quality optional - -if find_skill_dir mms-recipe-dev >/dev/null 2>&1 || find_skill_dir mms-recipe-fix-ticket >/dev/null 2>&1; then - record_check PASS "skill:high-level" "mms-recipe-dev or mms-recipe-fix-ticket installed" -else - record_check WARN "skill:high-level" "install mms-recipe-dev or mms-recipe-fix-ticket before demoing the full workflow" -fi - -HARNESS_SKILL_DIR="$(find_skill_dir mms-recipe-harness || true)" -HARNESS_CMD="" -if [ -n "$HARNESS_SKILL_DIR" ] && [ -x "$HARNESS_SKILL_DIR/scripts/recipe-harness" ]; then - HARNESS_CMD="$HARNESS_SKILL_DIR/scripts/recipe-harness" - record_check PASS "harness:command" "$HARNESS_CMD" -elif [ -n "$HARNESS_SKILL_DIR" ]; then - record_check FAIL "harness:command" "missing executable scripts/recipe-harness under $HARNESS_SKILL_DIR" -fi - -# Mobile v1 readiness is reported by check_v1_runner below (the runner:* checks): -# the installed runner bin, the action-manifest, and a real manifest validation -# under the resolved harness root. The legacy scripts/perps/agentic/*.sh -# existence checks were removed — under the v1 runner model the product checkout -# no longer ships those scripts (they are bridge/harness-installed), so -# WARN-on-missing was spurious on a correct install. - -if [ "$REPO" = "metamask-extension" ]; then - # v1 manifest/runner presence + validation is covered once by check_v1_runner - # below (the runner:* checks); only the browser-isolation hint is extension-only - # here, so the manifest/runner existence checks are not duplicated. - if [ -n "${playwright_chromium:-}" ]; then - record_check PASS "browser:isolation" "isolated Chromium is available; use mms-recipe-harness live --cdp-port --launch-existing-dist --chrome-user-data-dir temp/runtime/chrome-profile-recipe; add --start-test-watch only after accepting build/watch cost" - else - record_check WARN "browser:isolation" "best local match to the local recipe workflow needs Playwright Chromium with a dedicated profile, e.g. mms-recipe-harness live --cdp-port --launch-existing-dist --chrome-user-data-dir temp/runtime/chrome-profile-recipe; if Chromium is missing, ask the user before installing the Playwright Chromium browser cache with yarn exec playwright install chromium" - fi -fi - -validate_mobile_fixture() { - fixture="" - mobile_fixture_setup="create .agent/wallet-fixture.json from scripts/perps/agentic/wallet-fixture.example.json; include password, accounts[0] as a local development mnemonic for first vault setup, optional privateKey accounts named Trading/MYXTrading for funded trading flows, and shared-fixture-compatible settings metametrics=true skipGtmModals=true skipPerpsTutorial=true autoLockNever=true deviceAuthEnabled=true (applied by the Mobile harness on Android)" - if [ -f "$TARGET/.agent/wallet-fixture.json" ]; then - fixture=".agent/wallet-fixture.json" - elif [ -f "$TARGET/scripts/perps/agentic/wallet-fixture.json" ]; then - fixture="scripts/perps/agentic/wallet-fixture.json" - fi - - if [ -z "$fixture" ]; then - if [ -f "$TARGET/scripts/perps/agentic/wallet-fixture.example.json" ]; then - record_check WARN "fixture:mobile" "missing; run: mkdir -p .agent && cp scripts/perps/agentic/wallet-fixture.example.json .agent/wallet-fixture.json; then edit it: $mobile_fixture_setup" - else - record_check WARN "fixture:mobile" "missing and no example found; $mobile_fixture_setup" - fi - return - fi - - if ! command_exists node; then - record_check WARN "fixture:mobile" "$fixture exists, but node is missing so schema was not checked" - return - fi - - result="$(TARGET="$TARGET" FIXTURE="$fixture" node <<'NODE' 2>/dev/null || true -const fs = require('fs'); -const path = require('path'); -const file = path.join(process.env.TARGET, process.env.FIXTURE); -const recommendedSettings = { - metametrics: true, - skipGtmModals: true, - skipPerpsTutorial: true, - autoLockNever: true, - deviceAuthEnabled: true, -}; -try { - const data = JSON.parse(fs.readFileSync(file, 'utf8')); - const accounts = Array.isArray(data.accounts) ? data.accounts : []; - const invalid = []; - if (typeof data.password !== 'string' || data.password.length === 0) invalid.push('password'); - if (accounts.length === 0) invalid.push('accounts'); - accounts.forEach((account, index) => { - if (!account || typeof account !== 'object') invalid.push(`accounts[${index}]`); - else { - if (account.type !== 'mnemonic' && account.type !== 'privateKey') invalid.push(`accounts[${index}].type`); - if (typeof account.value !== 'string' || account.value.length === 0) invalid.push(`accounts[${index}].value`); - } - }); - const settings = data.settings && typeof data.settings === 'object' ? data.settings : {}; - const missingSettings = Object.entries(recommendedSettings) - .filter(([key, value]) => settings[key] !== value) - .map(([key]) => key); - const types = accounts.map((account) => account && account.type).filter(Boolean).join(','); - if (invalid.length > 0) { - console.log(`INVALID ${invalid.join(',')}`); - } else if (missingSettings.length > 0) { - console.log(`READY accounts=${accounts.length} types=${types} settings-missing=${missingSettings.join(',')}`); - } else { - console.log(`READY accounts=${accounts.length} types=${types} settings=shared-fixture-compatible`); - } -} catch (error) { - const match = String(error && error.message ? error.message : '').match(/position\s+(\d+)/i); - console.log(`INVALID_JSON malformed JSON${match ? ` at position ${match[1]}` : ''}`); -} -NODE -)" - case "$result" in - READY*settings-missing=*) - record_check WARN "fixture:mobile" "$fixture $result; shared-fixture-compatible settings: metametrics=true skipGtmModals=true skipPerpsTutorial=true autoLockNever=true deviceAuthEnabled=true (Android-applied)" - ;; - READY*) - record_check PASS "fixture:mobile" "$fixture $result" - ;; - *) - record_check FAIL "fixture:mobile" "$fixture $result; expected: $mobile_fixture_setup" - ;; - esac -} - -validate_extension_fixture() { - extension_fixture_setup="for shared fixture parity, provide temp/runtime/wallet-fixture.json or .agent/wallet-fixture.json with password, accounts[0] mnemonic, optional privateKey accounts named Trading/MYXTrading, optional selectedAccount, and settings skipPerpsTutorial=true autoLockNever=true; the Extension harness generates address/vault/persisted state from this shared fixture shape" - wallet_fixture="" - if [ -f "$TARGET/temp/runtime/wallet-fixture.json" ]; then - wallet_fixture="temp/runtime/wallet-fixture.json" - elif [ -f "$TARGET/.agent/wallet-fixture.json" ]; then - wallet_fixture=".agent/wallet-fixture.json" - fi - - if [ -n "$wallet_fixture" ]; then - if ! command_exists node; then - record_check WARN "fixture:extension" "$wallet_fixture exists, but node is missing so schema was not checked" - return - fi - result="$(TARGET="$TARGET" FIXTURE="$wallet_fixture" node <<'NODE' 2>/dev/null || true -const fs = require('fs'); -const path = require('path'); -const file = path.join(process.env.TARGET, process.env.FIXTURE); -try { - const data = JSON.parse(fs.readFileSync(file, 'utf8')); - const accounts = Array.isArray(data.accounts) ? data.accounts : []; - const invalid = []; - if (typeof data.password !== 'string' || data.password.length === 0) invalid.push('password'); - if (accounts.length === 0) invalid.push('accounts'); - if (!accounts.some((account) => account && account.type === 'mnemonic')) invalid.push('accounts.mnemonic'); - accounts.forEach((account, index) => { - if (!account || typeof account !== 'object') invalid.push(`accounts[${index}]`); - else { - if (account.type !== 'mnemonic' && account.type !== 'privateKey') invalid.push(`accounts[${index}].type`); - if (typeof account.value !== 'string' || account.value.length === 0) invalid.push(`accounts[${index}].value`); - } - }); - const settings = data.settings && typeof data.settings === 'object' ? data.settings : {}; - const recommendedSettings = { skipPerpsTutorial: true, autoLockNever: true }; - const missingSettings = Object.entries(recommendedSettings) - .filter(([key, value]) => settings[key] !== value) - .map(([key]) => key); - const types = accounts.map((account) => account && account.type).filter(Boolean).join(','); - const importedNames = accounts - .filter((account) => account && account.type === 'privateKey') - .map((account) => account.name || '(unnamed)') - .join(','); - const generated = data.address && data.vault ? 'precomputed' : 'generated-by-harness'; - if (invalid.length > 0) { - console.log(`INVALID ${invalid.join(',')}`); - } else if (missingSettings.length > 0) { - console.log(`READY accounts=${accounts.length} types=${types} imported=${importedNames || 'none'} state=${generated} settings-missing=${missingSettings.join(',')}`); - } else { - console.log(`READY accounts=${accounts.length} types=${types} imported=${importedNames || 'none'} state=${generated} settings=shared-fixture-compatible`); - } -} catch (error) { - const match = String(error && error.message ? error.message : '').match(/position\s+(\d+)/i); - console.log(`INVALID_JSON malformed JSON${match ? ` at position ${match[1]}` : ''}`); -} -NODE -)" - case "$result" in - READY*settings-missing=*) - record_check WARN "fixture:extension" "$wallet_fixture $result; shared-fixture-compatible settings: skipPerpsTutorial=true autoLockNever=true" - ;; - READY*) - record_check PASS "fixture:extension" "$wallet_fixture $result" - ;; - *) - record_check FAIL "fixture:extension" "$wallet_fixture $result; expected: $extension_fixture_setup" - ;; - esac - return - fi - - found="" - for candidate in \ - temp/runtime/extension.id \ - test/e2e/fixtures \ - fixtures - do - if [ -e "$TARGET/$candidate" ]; then - if [ -z "$found" ]; then - found="$candidate" - else - found="$found, $candidate" - fi - fi - done - if [ -n "$found" ]; then - record_check WARN "fixture:extension" "$found; wallet fixture not found, but prepared profile/fixture hints exist. $extension_fixture_setup" - else - record_check WARN "fixture:extension" "no wallet fixture/profile hints found; $extension_fixture_setup" - fi -} - -if [ "$REPO" = "metamask-mobile" ]; then - validate_mobile_fixture -elif [ "$REPO" = "metamask-extension" ]; then - validate_extension_fixture -fi - -CONTEXT="$TARGET/temp/runtime/agentic-runtime.json" -if [ -f "$CONTEXT" ]; then - if command_exists node; then - summary="$(CONTEXT="$CONTEXT" node <<'NODE' 2>/dev/null || true -const fs = require('fs'); -try { - const data = JSON.parse(fs.readFileSync(process.env.CONTEXT, 'utf8')); - const parts = []; - if (data.slotId) parts.push(`slot=${data.slotId}`); - if (data.cdpPort) parts.push(`cdpPort=${data.cdpPort}`); - if (data.watcherPort) parts.push(`watcherPort=${data.watcherPort}`); - if (data.metroPort) parts.push(`metroPort=${data.metroPort}`); - if (data.strict !== undefined) parts.push(`strict=${Boolean(data.strict)}`); - if (data.runtimeStart && data.runtimeStart.approved !== undefined) parts.push(`runtimeStart.approved=${Boolean(data.runtimeStart.approved)}`); - console.log(parts.length ? parts.join(' ') : 'present'); -} catch (error) { - const match = String(error && error.message ? error.message : '').match(/position\s+(\d+)/i); - console.log(`invalid JSON${match ? ` at position ${match[1]}` : ''}`); -} -NODE -)" - case "$summary" in - invalid*) - record_check WARN "runtime-context" "temp/runtime/agentic-runtime.json $summary" - ;; - *) - record_check PASS "runtime-context" "temp/runtime/agentic-runtime.json $summary" - ;; - esac - else - record_check PASS "runtime-context" "temp/runtime/agentic-runtime.json exists" - fi -else - record_check WARN "runtime-context" "missing; runtime discovery will rely on explicit env/ports or local probing" -fi - - -check_v1_runner() { - adapter="" - case "$REPO" in - metamask-mobile) adapter="mobile" ;; - metamask-extension) adapter="extension" ;; - *) return 0 ;; - esac - v1_dir="$TARGET/$HARNESS_ROOT/$adapter" - manifest="$v1_dir/action-manifest.json" - runner_bin="$v1_dir/runner/bin/metamask-recipe" - if [ -f "$manifest" ]; then - record_check PASS "runner:v1-manifest" "$manifest" - else - record_check WARN "runner:v1-manifest" "missing; run mms-recipe-harness install to install the runner action manifest" - fi - if [ -x "$runner_bin" ]; then - record_check PASS "runner:v1-entrypoint" "$runner_bin" - if [ -f "$manifest" ] && command_exists node; then - # Validate through the runner the way verify.sh does: `manifest --adapter - # --json`. The old `--action-manifest ` flag is not a real runner - # flag — the runner ignores unknown flags and exits 0, so that check passed - # without validating anything. - if ( cd "$TARGET" && "$runner_bin" manifest --adapter "$adapter" --json ) >/dev/null 2>&1; then - record_check PASS "runner:v1-manifest-validation" "metamask-recipe manifest --adapter $adapter --json succeeds for the installed $adapter harness" - else - record_check FAIL "runner:v1-manifest-validation" "metamask-recipe manifest --adapter $adapter --json failed for the installed $adapter harness" - fi - elif command_exists node; then - record_check WARN "runner:v1-manifest-validation" "skipped because installed action manifest is missing" - fi - else - record_check WARN "runner:v1-entrypoint" "missing executable $runner_bin; run mms-recipe-harness install" - fi - - mode="unsupported/no bridge" - if [ "$adapter" = "mobile" ]; then - if [ -f "$TARGET/app/core/AgenticService/AgenticService.ts" ]; then - mode="bridge present" - elif [ -d "$TARGET/scripts/perps/agentic" ]; then - mode="product-local harness present" - elif [ "$REPO" = "metamask-mobile" ]; then - mode="bridge injectable" - fi - else - # Extension has no in-repo product bridge; the harness install dir only means - # the recipe harness is installed (not an in-repo bridge like mobile's). - if [ -d "$TARGET/$HARNESS_ROOT/extension" ]; then - mode="harness installed" - else - mode="harness installable" - fi - fi - case "$mode" in - unsupported*) record_check FAIL "runner:compatibility-mode" "$mode" ;; - *) record_check PASS "runner:compatibility-mode" "$mode" ;; - esac -} - -check_v1_runner - -if [ "$RUN_STATIC_VERIFY" = true ] && [ -n "$HARNESS_CMD" ] && [ "$REPO" != "" ]; then - tmp_log="${TMPDIR:-/tmp}/recipe-doctor-$$.log" - if "$HARNESS_CMD" --target "$TARGET" verify --static-only >"$tmp_log" 2>&1; then - last_line="$(tail -n 1 "$tmp_log" 2>/dev/null || true)" - record_check PASS "harness:static-verify" "${last_line:-static verify passed}" - else - last_lines="$(tail -n 6 "$tmp_log" 2>/dev/null | tr '\n' ' ' || true)" - record_check FAIL "harness:static-verify" "${last_lines:-static verify failed}" - fi - rm -f "$tmp_log" -elif [ "$RUN_STATIC_VERIFY" = false ]; then - record_check WARN "harness:static-verify" "skipped by --no-static-verify" -elif [ "$REPO" != "" ]; then - record_check WARN "harness:static-verify" "skipped because mms-recipe-harness command is unavailable" -fi - -if [ "$ERRORS" -gt 0 ]; then - OVERALL="FAIL" -elif [ "$WARNINGS" -gt 0 ]; then - OVERALL="WARN" -else - OVERALL="PASS" -fi - -if [ "$JSON" = true ]; then - esc_target="$(printf '%s' "$TARGET" | escape_json)" - esc_repo="$(printf '%s' "$REPO" | escape_json)" - printf '{"status":"%s","target":"%s","repo":"%s","errors":%s,"warnings":%s,"checks":[%s]}\n' "$OVERALL" "$esc_target" "$esc_repo" "$ERRORS" "$WARNINGS" "$CHECKS" -else - printf '\n%s summary: errors=%s warnings=%s target=%s repo=%s\n' "$OVERALL" "$ERRORS" "$WARNINGS" "$TARGET" "${REPO:-unknown}" -fi - -if [ "$ERRORS" -gt 0 ]; then - exit 1 -fi -exit 0 diff --git a/domains/agentic/skills/recipe-doctor/skill.md b/domains/agentic/skills/recipe-doctor/skill.md deleted file mode 100644 index 55be849..0000000 --- a/domains/agentic/skills/recipe-doctor/skill.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: recipe-doctor -description: Diagnose whether a MetaMask Mobile or Extension checkout is ready to use Recipe v1 skills, including installed skill bundles, harness scripts, local tools, runtime context, and wallet fixtures/profiles. Use before recipe-dev, recipe-fix-ticket, recipe-harness, recipe-wallet-control, or demo recording on a fresh machine or checkout. -maturity: experimental ---- - -# Recipe Doctor - -`recipe-doctor` is the first command to run when a fresh agent, machine, or checkout is about to use the Recipe v1 workflow. - -It does not prove product behavior. It answers: "Can this checkout run the recipe skills efficiently, and will wallet/account setup be automatic or manual?" - -## Rules - -- Run doctor before long recipe work on a new setup, before demos, and after a failed skill install. -- Treat failed required checks as setup blockers, not product failures. -- Report fixture/profile status early. If fixtures are missing, tell the human that the workflow can continue, but wallet/account setup may be manual and slower. -- Do not print raw fixture passwords, mnemonics, private keys, or full account material. Report counts, file paths, and schema status only. -- Doctor may run static harness verification. It must not start Metro, Chrome, simulators, emulators, builds, or live CDP sessions. - -## Agent Execution - -**Run the bash script directly — do not re-implement the checks manually as individual commands.** - -When invoked by an agent, locate and execute the script: - -```bash -# Installed in a consumer repo: -.agents/skills/mms-recipe-doctor/scripts/recipe-doctor --target . - -# Source checkout (developing this skill): -domains/agentic/skills/recipe-doctor/scripts/recipe-doctor --target -``` - -The script is self-contained and handles all checks sequentially. Running checks as individual parallel bash calls is wrong: a failed CDP/curl probe (exit 7 = browser not running) will cancel sibling parallel calls and produce spurious "Cancelled" errors. Let the script manage sequencing. - -## Command Shape - -From a consumer repo after installing agentic skills: - -```bash -.agents/skills/mms-recipe-doctor/scripts/recipe-doctor -.agents/skills/mms-recipe-doctor/scripts/recipe-doctor --target . --json -.agents/skills/mms-recipe-doctor/scripts/recipe-doctor --target --repo metamask-mobile -``` - -From a source checkout while developing this skill: - -```bash -domains/agentic/skills/recipe-doctor/scripts/recipe-doctor --target -domains/agentic/skills/recipe-doctor/scripts/recipe-doctor --target -``` - -Use `--no-static-verify` only when the caller explicitly wants a pure read-only scan. The default static verify is no-start/no-live; it may write ignored `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/.../summary.json` artifacts. - -## What It Checks - -- repo detection: `metamask-mobile` or `metamask-extension`; -- required local tools: `bash`, `node`, `git`, and `curl`; -- installed recipe skill bundles under `.agents/skills`, `.claude/skills`, or `.cursor/rules`; -- installed harness runner files in the target checkout; -- static harness verification through `mms-recipe-harness` when available; -- runtime context hints in `temp/runtime/agentic-runtime.json`; -- Extension browser isolation: prefer Playwright Chromium, or Chrome/Chromium with a dedicated `--user-data-dir`, never the user's normal profile; -- Mobile wallet fixture schema at `.agent/wallet-fixture.json` or `scripts/perps/agentic/wallet-fixture.json`; -- Extension wallet fixture/profile hints at `temp/runtime/wallet-fixture.json`, `.agent/wallet-fixture.json`, `temp/runtime/extension.id`, `test/e2e/fixtures`, or `fixtures`. - -## Expected Output - -- `PASS`: required setup is ready. -- `WARN`: workflow can continue, but setup may be slower or more manual. -- `FAIL`: missing required tool or harness state; fix before recipe runtime claims. - -For Mobile, a missing fixture should produce the exact setup hint: - -```bash -mkdir -p .agent -cp scripts/perps/agentic/wallet-fixture.example.json .agent/wallet-fixture.json -# edit .agent/wallet-fixture.json with local development password/accounts only: -# - accounts[0]: mnemonic for first vault setup -# - optional privateKey accounts named "Trading"/"MYXTrading" for funded flows -# - shared-fixture-compatible settings: metametrics=true, skipGtmModals=true, -# skipPerpsTutorial=true, autoLockNever=true, deviceAuthEnabled=true -``` - -For Extension, use the same human-authored account roles as Mobile. A shared-fixture-compatible Extension fixture is `temp/runtime/wallet-fixture.json` or `.agent/wallet-fixture.json` with `password`, `accounts[0]` mnemonic (name conventionally `Primary` for cross-platform shared fixture parity — any name is valid for single-platform setups; only warn if harness uses name-based account lookup), optional private-key accounts named `Trading` / `MYXTrading`, optional `selectedAccount`, and `settings.skipPerpsTutorial=true`, `settings.autoLockNever=true`. The Extension harness generates `address`, `vault`, and persisted controller state from this shape before live launch. - -## Shared Wallet Fixture Contract - -Mobile and Extension share this human-authored wallet fixture shape: - -```json -{ - "password": "local-dev-password", - "accounts": [ - { "type": "mnemonic", "value": "local development srp words", "name": "Primary" }, - { "type": "privateKey", "value": "0x...", "name": "Trading" }, - { "type": "privateKey", "value": "0x...", "name": "MYXTrading" } - ], - "selectedAccount": "Trading", - "settings": { - "skipPerpsTutorial": true, - "autoLockNever": true - } -} -``` - -Extension-specific `address`, `vault`, and persisted controller state are generated from that shared fixture by the Extension harness, not hand-authored by users. This lets agents import multiple account types and names consistently on both platforms, then start each wallet with a predictable selected account. - -For Extension browser launch, the isolated path is an isolated Chromium profile: - -```bash -.agents/skills/mms-recipe-harness/scripts/recipe-harness live \ - --cdp-port \ - --launch-existing-dist \ - --chrome-user-data-dir temp/runtime/chrome-profile-recipe -``` - -If no compatible `dist/chrome` exists and the human accepts build/watch cost, add `--start-test-watch`. Prefer Playwright Chromium over the user's normal Chrome profile. `mms-recipe-harness live` must not install Chromium automatically. When the browser binary is missing, stop and ask the user for approval; only after explicit approval should the user/agent run `yarn exec playwright install chromium` to populate the user-level Playwright browser cache without package.json changes, or the user can set `RECIPE_HARNESS_CHROME_BIN` to a browser they explicitly choose. diff --git a/domains/agentic/skills/recipe-evidence/references/examples.md b/domains/agentic/skills/recipe-evidence/references/examples.md deleted file mode 100644 index e89c6ae..0000000 --- a/domains/agentic/skills/recipe-evidence/references/examples.md +++ /dev/null @@ -1,27 +0,0 @@ -# Evidence Examples - -## Good - -```md -### Recipe validation - -Verdict: pass - -Proved: -- PT-1: The warning appears when the quote expires. -- PT-2: Refreshing the quote removes the warning and enables Continue. - -Artifacts: -- `summary.json` — pass status, branch, device, command. -- `trace.json` — ordered recipe nodes. -- `screenshots/quote-expired.png` — warning state after settle. -- `screenshots/quote-refreshed.png` — valid refreshed state after settle. -``` - -## Bad - -```md -Tested manually and it works. -``` - -This is not recipe evidence. It has no proof targets, no run status, no artifact links, and no reproducible command. diff --git a/domains/agentic/skills/recipe-evidence/repos/metamask-extension.md b/domains/agentic/skills/recipe-evidence/repos/metamask-extension.md deleted file mode 100644 index ded4694..0000000 --- a/domains/agentic/skills/recipe-evidence/repos/metamask-extension.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-evidence ---- - -# MetaMask Extension - -Mention browser/channel, extension build, fixture, dapp/network dependency, and context: popup, full-screen, service worker, controller, or dapp. - -Prefer screenshots or videos for visible UI claims, and trace/log/state artifacts for internal claims. diff --git a/domains/agentic/skills/recipe-evidence/repos/metamask-mobile.md b/domains/agentic/skills/recipe-evidence/repos/metamask-mobile.md deleted file mode 100644 index 4cc1239..0000000 --- a/domains/agentic/skills/recipe-evidence/repos/metamask-mobile.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-evidence ---- - -# MetaMask Mobile - -Mention platform, simulator/emulator, build type, wallet fixture, selected network, and feature flags when relevant. - -Prefer screenshots or videos for visible UI claims, and state JSON/logs for controller or wallet-state claims. diff --git a/domains/agentic/skills/recipe-evidence/scripts/package-pr-evidence.js b/domains/agentic/skills/recipe-evidence/scripts/package-pr-evidence.js deleted file mode 100755 index c553cb3..0000000 --- a/domains/agentic/skills/recipe-evidence/scripts/package-pr-evidence.js +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env node -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); - -function usage() { - console.error('Usage: package-pr-evidence.js --task [--out ]'); -} - -function parseArgs(argv) { - const args = { task: '', out: '' }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--task') args.task = argv[++i] || ''; - else if (arg === '--out') args.out = argv[++i] || ''; - else if (arg === '-h' || arg === '--help') { usage(); process.exit(0); } - else throw new Error(`Unknown arg: ${arg}`); - } - return args; -} - -function readText(file) { - try { - return fs.readFileSync(file, 'utf8'); - } catch (error) { - if (error && error.code === 'ENOENT') return ''; - throw error; - } -} - -function readJson(file) { - try { - return JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (error) { - if (error && error.code === 'ENOENT') return null; - throw error; - } -} - -function mkdirp(dir) { fs.mkdirSync(dir, { recursive: true }); } - -function walk(dir, acc = []) { - if (!fs.existsSync(dir)) return acc; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, entry.name); - if (entry.isDirectory()) walk(p, acc); - else if (entry.isFile()) acc.push(p); - } - return acc; -} - -function rel(from, to) { return path.relative(from, to).split(path.sep).join('/'); } - -function sanitizeName(s) { - return String(s || 'artifact') - .toLowerCase() - .replace(/\.png(?:-\d+)?(?:\.png)?$/i, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80) - .replace(/^-+|-+$/g, '') || 'artifact'; -} - -function stripRuntimePngSuffix(name) { - return name.replace(/\.png-\d+\.png$/i, '.png'); -} - -function nearestRunDir(file) { - let dir = path.dirname(file); - while (dir && dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, 'summary.json')) || fs.existsSync(path.join(dir, 'screenshots-captions.json'))) return dir; - dir = path.dirname(dir); - } - return path.dirname(file); -} - -function captionFor(file) { - const runDir = nearestRunDir(file); - const captions = readJson(path.join(runDir, 'screenshots-captions.json')) || {}; - const base = path.basename(file); - const stripped = stripRuntimePngSuffix(base); - return captions[base] || captions[stripped] || stripped.replace(/\.png$/i, '').replace(/[-_]+/g, ' '); -} - -function firstMatch(text, regex, fallback = '') { - const match = text.match(regex); - return match ? match[1].trim() : fallback; -} - -function getRepoRoot(taskDir) { - try { - return execFileSync('git', ['-C', taskDir, 'rev-parse', '--show-toplevel'], { text: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); - } catch { - let dir = taskDir; - while (dir && dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, '.git'))) return dir; - dir = path.dirname(dir); - } - return ''; - } -} - -function assertSafeOutDir(taskDir, outDir) { - const relPath = path.relative(taskDir, outDir); - if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) { - throw new Error(`Refusing --out outside task dir or equal to task dir: ${outDir}`); - } - const parts = relPath.split(path.sep).filter(Boolean); - if (parts[0] === 'artifacts' || parts[0] === 'harness') { - throw new Error(`Refusing --out inside task source/artifact dir: ${outDir}`); - } - if (!/^pr-package(?:[-_.][a-zA-Z0-9-]+)?$/.test(path.basename(outDir))) { - throw new Error(`Refusing --out that is not a generated pr-package directory: ${outDir}`); - } -} - -function findPrTemplate(repoRoot) { - if (!repoRoot) return null; - const candidates = [ - '.github/pull-request-template.md', - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - ]; - for (const relPath of candidates) { - const abs = path.join(repoRoot, relPath); - if (fs.existsSync(abs)) return { abs, relPath }; - } - return null; -} - -function insertAfterHeading(markdown, headingPattern, block) { - const match = markdown.match(headingPattern); - if (!match || match.index === undefined) return markdown; - const lineEnd = markdown.indexOf('\n', match.index); - const insertAt = lineEnd === -1 ? markdown.length : lineEnd + 1; - return `${markdown.slice(0, insertAt)}\n${block}\n${markdown.slice(insertAt)}`; -} - -function insertBeforeHeading(markdown, headingPattern, block) { - const match = markdown.match(headingPattern); - if (!match || match.index === undefined) return `${markdown}\n\n${block}\n`; - return `${markdown.slice(0, match.index)}${block}\n\n${markdown.slice(match.index)}`; -} - -function buildImageSlots(copied, headingLevel = '###') { - if (!copied.length) { - return '\n'; - } - return copied.map((item, i) => [ - `${headingLevel} ${i + 1}. ${item.caption}`, - '', - ``, - '', - `Local file: \`pr-package/images/${item.name}\``, - '', - ].join('\n')).join('\n'); -} - -function buildTemplatePrDesc({ templateText, templateRelPath, task, verdict, taskDir, outDir, copied, evidenceExists, qualityExists, checklistExists }) { - const descriptionBlock = [ - '', - task ? `This PR addresses ${task}.` : 'This PR addresses the linked task.', - 'See the validation and evidence sections below for recipe-backed proof.', - ].join('\n'); - - const validationBlock = [ - '', - verdict ? `Verdict: \`${verdict}\`` : 'Verdict: `TODO`', - '- See `pr-package/evidence.md` for recipe commands, summaries, traces, and artifact manifests.', - evidenceExists ? '- Full evidence package: `pr-package/evidence.md`' : '- Full evidence package: TODO (`PR-READY-EVIDENCE.md` was missing at package time).', - qualityExists ? '- Quality report: `pr-package/recipe-quality.md`' : '- Quality report: TODO (`artifacts/RECIPE-QUALITY.md` was missing at package time).', - ].join('\n'); - - const screenshotBlock = [ - '### **Recipe evidence**', - '', - ``, - '', - buildImageSlots(copied, '####'), - ].join('\n'); - - const artifactBlock = [ - '## **Recipe artifact package**', - '', - `Task path: \`${taskDir}\``, - `PR package: \`${outDir}\``, - evidenceExists ? '- Full evidence: `pr-package/evidence.md`' : '- Full evidence: missing `PR-READY-EVIDENCE.md` at package time', - qualityExists ? '- Quality report: `pr-package/recipe-quality.md`' : '- Quality report: missing `artifacts/RECIPE-QUALITY.md` at package time', - '- Image files: `pr-package/images/`', - checklistExists ? '- Checklist: `pr-package/checklist.md`' : '- Checklist: missing `CHECKLIST.md` at package time', - ].join('\n'); - - let out = templateText; - out = insertAfterHeading(out, /^## \*\*Description\*\*\s*$/m, descriptionBlock); - if (task) out = out.replace(/^Fixes:\s*$/m, `Fixes: ${task}\n`); - out = insertAfterHeading(out, /^## \*\*Manual testing steps\*\*\s*$/m, validationBlock); - out = insertBeforeHeading(out, /^## \*\*Pre-merge author checklist\*\*\s*$/m, screenshotBlock); - out = insertBeforeHeading(out, /^## \*\*Pre-merge author checklist\*\*\s*$/m, artifactBlock); - return out; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - if (!args.task) { - usage(); - process.exit(2); - } - const taskDir = path.resolve(args.task); - if (!fs.existsSync(taskDir) || !fs.statSync(taskDir).isDirectory()) { - throw new Error(`Task dir not found: ${taskDir}`); - } - const repoRoot = getRepoRoot(taskDir); - const prTemplate = findPrTemplate(repoRoot); - const outDir = path.resolve(args.out || path.join(taskDir, 'pr-package')); - assertSafeOutDir(taskDir, outDir); - const imagesDir = path.join(outDir, 'images'); - fs.rmSync(outDir, { recursive: true, force: true }); - mkdirp(imagesDir); - - const evidenceSrc = path.join(taskDir, 'PR-READY-EVIDENCE.md'); - const evidenceText = readText(evidenceSrc); - const checklist = readText(path.join(taskDir, 'CHECKLIST.md')); - const textSource = evidenceText || checklist; - const task = firstMatch(textSource, /Task:\s*`?([^`\n]+)`?/i, firstMatch(checklist, /Source:\s*`?([^`\n]+)`?/i, '')); - const branch = firstMatch(textSource, /Branch:\s*`?([^`\n]+)`?/i, firstMatch(checklist, /Run branch:\s*`?([^`\n]+)`?/i, '')); - const verdict = firstMatch(textSource, /Verdict:\s*`?([^`\n]+)`?/i, ''); - - const screenshots = walk(path.join(taskDir, 'artifacts')) - .filter((file) => /\.(png|jpg|jpeg|webp)$/i.test(file)) - .filter((file) => file.split(path.sep).includes('screenshots')) - .sort(); - - const copied = []; - const seen = new Set(); - screenshots.forEach((file, index) => { - const caption = captionFor(file); - const baseName = sanitizeName(caption || path.basename(file)); - let destName = `${String(index + 1).padStart(2, '0')}-${baseName}${path.extname(file).toLowerCase() || '.png'}`; - let n = 2; - while (seen.has(destName)) { - destName = `${String(index + 1).padStart(2, '0')}-${baseName}-${n}${path.extname(file).toLowerCase() || '.png'}`; - n += 1; - } - seen.add(destName); - const dest = path.join(imagesDir, destName); - fs.copyFileSync(file, dest); - copied.push({ source: file, dest, name: destName, caption, runDir: nearestRunDir(file) }); - }); - - if (fs.existsSync(evidenceSrc)) fs.copyFileSync(evidenceSrc, path.join(outDir, 'evidence.md')); - const qualitySrc = path.join(taskDir, 'artifacts', 'RECIPE-QUALITY.md'); - if (fs.existsSync(qualitySrc)) fs.copyFileSync(qualitySrc, path.join(outDir, 'recipe-quality.md')); - const checklistSrc = path.join(taskDir, 'CHECKLIST.md'); - const checklistExists = fs.existsSync(checklistSrc); - if (checklistExists) fs.copyFileSync(checklistSrc, path.join(outDir, 'checklist.md')); - - const imageReadme = [ - '# Evidence images', - '', - 'Copy or drag/drop these files into the GitHub PR description. Filenames are intentionally stable and reviewer-friendly.', - '', - ...copied.flatMap((item) => [ - `## ${item.name}`, - '', - item.caption, - '', - `Source: \`${rel(outDir, item.source)}\``, - '', - ]), - ].join('\n'); - fs.writeFileSync(path.join(imagesDir, 'README.md'), `${imageReadme}\n`); - - const genericPrDesc = [ - '# PR description draft', - '', - '## Description', - '', - '', - '', - '## Related Jira', - '', - task ? `- ${task}` : '- ', - '', - '## Changes', - '', - '', - '', - '## Validation', - '', - verdict ? `Verdict: \`${verdict}\`` : 'Verdict: `TODO`', - '', - '', - '', - '## Evidence', - '', - '', - '', - buildImageSlots(copied), - '## Artifact package', - '', - `Task path: \`${taskDir}\``, - `PR package: \`${outDir}\``, - fs.existsSync(evidenceSrc) ? '- Full evidence: `pr-package/evidence.md`' : '- Full evidence: missing `PR-READY-EVIDENCE.md` at package time', - fs.existsSync(qualitySrc) ? '- Quality report: `pr-package/recipe-quality.md`' : '- Quality report: missing `artifacts/RECIPE-QUALITY.md` at package time', - checklistExists ? '- Checklist: `pr-package/checklist.md`' : '- Checklist: missing `CHECKLIST.md` at package time', - '', - '## Notes / gaps', - '', - '', - ].join('\n'); - - const prDesc = prTemplate - ? buildTemplatePrDesc({ - templateText: readText(prTemplate.abs), - templateRelPath: prTemplate.relPath, - task, - verdict, - taskDir, - outDir, - copied, - evidenceExists: fs.existsSync(evidenceSrc), - qualityExists: fs.existsSync(qualitySrc), - checklistExists, - }) - : genericPrDesc; - - fs.writeFileSync(path.join(outDir, 'pr-desc.md'), `${prDesc}\n`); - - const manifest = { - taskDir, - outDir, - task, - branch, - verdict, - repoRoot, - prTemplate: prTemplate ? prTemplate.relPath : null, - files: { - prDescription: path.join(outDir, 'pr-desc.md'), - evidence: fs.existsSync(evidenceSrc) ? path.join(outDir, 'evidence.md') : null, - quality: fs.existsSync(qualitySrc) ? path.join(outDir, 'recipe-quality.md') : null, - checklist: fs.existsSync(checklistSrc) ? path.join(outDir, 'checklist.md') : null, - images: copied.map((item) => ({ path: path.join(outDir, 'images', item.name), caption: item.caption, source: item.source })), - }, - generatedAt: new Date().toISOString(), - }; - fs.writeFileSync(path.join(outDir, 'package-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - - const finalReport = [ - '# Final output report', - '', - `Task path: \`${taskDir}\``, - `PR package path: \`${outDir}\``, - `PR description draft: \`${path.join(outDir, 'pr-desc.md')}\``, - `Evidence images folder: \`${imagesDir}\``, - prTemplate ? `PR template source: \`${prTemplate.relPath}\`` : 'PR template source: not found; used generic fallback', - '', - copied.length ? 'Images:' : 'Images: none found', - ...copied.map((item) => `- \`${path.join(outDir, 'images', item.name)}\` — ${item.caption}`), - ].join('\n'); - fs.writeFileSync(path.join(outDir, 'final-report.md'), `${finalReport}\n`); - - console.log(finalReport); -} - -try { - main(); -} catch (error) { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); -} diff --git a/domains/agentic/skills/recipe-evidence/scripts/upload-pr-evidence.js b/domains/agentic/skills/recipe-evidence/scripts/upload-pr-evidence.js deleted file mode 100644 index 8776d0f..0000000 --- a/domains/agentic/skills/recipe-evidence/scripts/upload-pr-evidence.js +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env node -// upload-pr-evidence.js — push packaged PR evidence images to the CURRENT GitHub -// user's public artifacts repo and create/edit the PR with hosted image URLs. -// -// The artifacts owner is ALWAYS the logged-in `gh` user (never hard-coded), and -// every outward action is explicit-flag gated so the calling skill can ask the -// human first: -// --ensure-repo create /mm--artifacts (public) if missing -// --confirm-public-upload REQUIRED to upload screenshots to the public repo (alias --upload) -// --create-pr create the PR if none exists for the branch (else edit it) -// Use --dry-run to print the plan (owner/repo/branch/URLs) without any writes. -// -// The owner is ALWAYS the logged-in gh user — there is no owner override, so -// uploads/public-repo creation cannot be retargeted at another account/org. -// -// Usage: -// upload-pr-evidence.js --task [--adapter extension|mobile] -// [--ensure-repo] [--confirm-public-upload] [--create-pr] [--pr ] [--title ] [--dry-run] -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); - -function usage() { - console.error( - 'Usage: upload-pr-evidence.js --task [--adapter extension|mobile] [--ensure-repo] [--confirm-public-upload] [--create-pr] [--pr ] [--title ] [--dry-run]', - ); -} - -function parseArgs(argv) { - const a = { task: '', adapter: '', ensureRepo: false, createPr: false, upload: false, pr: '', title: '', dryRun: false }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--task') a.task = argv[++i] || ''; - else if (arg === '--adapter') a.adapter = argv[++i] || ''; - else if (arg === '--pr') a.pr = argv[++i] || ''; - else if (arg === '--title') a.title = argv[++i] || ''; - else if (arg === '--ensure-repo') a.ensureRepo = true; - else if (arg === '--create-pr') a.createPr = true; - else if (arg === '--confirm-public-upload' || arg === '--upload') a.upload = true; - else if (arg === '--dry-run') a.dryRun = true; - else if (arg === '-h' || arg === '--help') { usage(); process.exit(0); } - else throw new Error(`Unknown arg: ${arg}`); - } - if (!a.task) { usage(); throw new Error('--task is required'); } - return a; -} - -// gh with GH_TOKEN stripped: the fine-grained PAT in GH_TOKEN lacks write scope, -// so writes must fall back to the keyring token. Reads work either way. -function gh(args, { input } = {}) { - const env = { ...process.env }; - delete env.GH_TOKEN; - return execFileSync('gh', args, { encoding: 'utf8', env, input, maxBuffer: 64 * 1024 * 1024 }).trim(); -} - -function git(args, cwd) { - return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); -} - -function detectProductRepo(taskDir) { - // Prefer the product checkout (cwd), fall back to the task dir's repo. - for (const cwd of [process.cwd(), taskDir]) { - try { - const url = git(['remote', 'get-url', 'origin'], cwd); - const m = /[:/]([^/]+\/[^/]+?)(?:\.git)?$/u.exec(url); - if (m) return { slug: m[1], cwd }; - } catch { /* keep trying */ } - } - throw new Error('Could not detect the product GitHub repo from origin remote.'); -} - -function adapterFromSlug(slug) { - if (/metamask-extension$/u.test(slug)) return 'extension'; - if (/metamask-mobile$/u.test(slug)) return 'mobile'; - return ''; -} - -// Percent-encode each path segment so branch names with `/` (kept as separators) -// or spaces/special chars don't break the GitHub contents URL or the raw URL. -function encodePathSegments(p) { - return String(p) - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/'); -} - -function listImages(prPackage) { - const dir = path.join(prPackage, 'images'); - if (!fs.existsSync(dir)) return []; - return fs - .readdirSync(dir) - .filter((n) => /\.(png|jpe?g|gif|webp)$/iu.test(n)) - .sort() - .map((n) => ({ name: n, abs: path.join(dir, n) })); -} - -function ghRepoExists(ownerRepo) { - try { gh(['repo', 'view', ownerRepo, '--json', 'name']); return true; } catch { return false; } -} - -function defaultBranch(ownerRepo) { - try { return gh(['repo', 'view', ownerRepo, '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']) || 'main'; } - catch { return 'main'; } -} - -function existingSha(ownerRepo, repoPath) { - try { return gh(['api', `repos/${ownerRepo}/contents/${repoPath}`, '--jq', '.sha']); } - catch { return ''; } -} - -function uploadImage(ownerRepo, repoPath, abs, branch, dryRun) { - if (dryRun) return; - const content = fs.readFileSync(abs).toString('base64'); - const sha = existingSha(ownerRepo, repoPath); - const body = JSON.stringify({ - message: `evidence: ${repoPath}`, - content, - branch, - ...(sha ? { sha } : {}), - }); - gh(['api', '-X', 'PUT', `repos/${ownerRepo}/contents/${repoPath}`, '--input', '-'], { input: body }); -} - -// Replace (or append) the Screenshots/Recordings section of a PR body. -function injectScreenshots(body, block) { - const heading = /^#{1,3}\s*\*{0,2}Screenshots(?:\/Recordings)?\*{0,2}\s*$/imu; - if (heading.test(body)) { - const lines = body.split('\n'); - const out = []; - let i = 0; - while (i < lines.length) { - out.push(lines[i]); - if (heading.test(lines[i])) { - out.push('', block, ''); - i += 1; - while (i < lines.length && !/^#{1,3}\s/u.test(lines[i])) i += 1; // drop old section body - continue; - } - i += 1; - } - return out.join('\n'); - } - return `${body.trim()}\n\n## Screenshots/Recordings\n\n${block}\n`; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - const taskDir = path.resolve(args.task); - const prPackage = fs.existsSync(path.join(taskDir, 'pr-package')) ? path.join(taskDir, 'pr-package') : taskDir; - - const { slug, cwd } = detectProductRepo(taskDir); - const adapter = args.adapter || adapterFromSlug(slug); - if (adapter !== 'extension' && adapter !== 'mobile') { - throw new Error(`Unsupported product repo for evidence upload: ${slug} (expected metamask-extension or metamask-mobile).`); - } - // Owner is always the logged-in gh user — never overridable — so uploads and - // public-repo creation cannot be retargeted at another account/org. - const owner = gh(['api', 'user', '--jq', '.login']); - if (!owner) throw new Error('Could not resolve the logged-in GitHub user (gh api user). Run `gh auth login` first.'); - const artifactsRepo = `${owner}/mm-${adapter}-artifacts`; - const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd); - const images = listImages(prPackage); - - const plan = { - productRepo: slug, - adapter, - owner, - artifactsRepo, - branch, - images: images.map((x) => x.name), - evidencePath: `evidence/${branch}/`, - }; - console.error(`[upload] plan: ${JSON.stringify(plan, null, 2)}`); - - if (!images.length) console.error('[upload] WARN: no images under pr-package/images — only the PR text will be updated.'); - - // Upload consent gate: never push screenshots to the PUBLIC artifacts repo - // without an explicit per-run flag, even when the repo already exists (so a - // re-run can't silently re-upload). --ensure-repo only gates first-time repo - // creation; this gates the upload itself. - if (images.length && !args.dryRun && !args.upload) { - throw new Error(`Refusing to upload ${images.length} screenshot(s) to the PUBLIC repo ${artifactsRepo} without explicit consent. Re-run with --confirm-public-upload after confirming with the user (public repo; screenshots may show wallet state — addresses/balances/positions). Use --dry-run to preview without uploading.`); - } - - // 1. ensure artifacts repo - const repoExists = ghRepoExists(artifactsRepo); - if (!repoExists) { - // dry-run previews without writes and never requires repo-creation consent. - if (!args.ensureRepo && !args.dryRun) { - throw new Error(`Artifacts repo ${artifactsRepo} does not exist. Re-run with --ensure-repo ONLY after the user gives informed consent: this creates a PUBLIC GitHub repo and uploads the evidence screenshots there, and those screenshots may show wallet state (addresses, balances, positions). Anyone can view a public repo.`); - } - if (args.dryRun) { - console.error(`[upload] dry-run: artifacts repo ${artifactsRepo} does not exist${args.ensureRepo ? ' (would be created with --ensure-repo)' : ' (creating it needs --ensure-repo)'} — no writes.`); - } else { - console.error(`[upload] creating PUBLIC artifacts repo ${artifactsRepo} (screenshots may show wallet state — addresses/balances/positions — and are world-readable)`); - gh(['repo', 'create', artifactsRepo, '--public', '--description', `MetaMask ${adapter} farm validation artifacts`]); - } - } - - // 2. upload images - // The upload is gated above by --confirm-public-upload: it never runs without - // that explicit per-run flag (or --dry-run, which writes nothing), even when the - // artifacts repo already exists. The recipe-evidence skill also requires human - // consent before invoking this script. Do not remove the gate. - const branchDefault = repoExists || !args.dryRun ? defaultBranch(artifactsRepo) : 'main'; - const urls = []; - const encodedBranch = encodePathSegments(branch); - const encodedDefaultBranch = encodeURIComponent(branchDefault); - for (const img of images) { - const repoPath = `evidence/${encodedBranch}/${encodeURIComponent(img.name)}`; - uploadImage(artifactsRepo, repoPath, img.abs, branchDefault, args.dryRun); - urls.push({ name: img.name, url: `https://raw.githubusercontent.com/${artifactsRepo}/${encodedDefaultBranch}/${repoPath}` }); - } - - // 3. build the Screenshots block (images hosted; videos stay local drag/drop) - const block = urls.length - ? urls - .map((u) => { - // Escape Markdown link-text metacharacters so an odd artifact filename - // can't corrupt the PR body (e.g. `[`/`]`/`\` in the alt text). - const alt = u.name.replace(/\.[a-z0-9]+$/iu, '').replace(/([\\[\]])/gu, '\\$1'); - return `![${alt}](${u.url})`; - }) - .join('\n\n') - : '_No hosted screenshots; attach any local recordings by drag-and-drop._'; - - // 4. PR body from pr-desc.md (fallback to a minimal body) - const prDescPath = path.join(prPackage, 'pr-desc.md'); - let body = fs.existsSync(prDescPath) ? fs.readFileSync(prDescPath, 'utf8') : `## Summary\n\nSee evidence.\n`; - body = injectScreenshots(body, block); - const bodyFile = path.join(prPackage, 'pr-body.uploaded.md'); - if (!args.dryRun) fs.writeFileSync(bodyFile, body); - - // 5. create or edit the PR (only when --create-pr) - let prRef = args.pr; - if (!prRef) { - try { prRef = gh(['pr', 'view', '--json', 'number', '--jq', '.number']); } catch { prRef = ''; } - } - let prUrl = ''; - if (args.createPr) { - if (prRef) { - console.error(`[upload] editing PR #${prRef}`); - if (!args.dryRun) gh(['pr', 'edit', String(prRef), '--body-file', bodyFile]); - if (!args.dryRun) prUrl = gh(['pr', 'view', String(prRef), '--json', 'url', '--jq', '.url']); - } else { - const title = args.title || git(['log', '-1', '--pretty=%s'], cwd); - console.error(`[upload] creating PR "${title}"`); - if (!args.dryRun) prUrl = gh(['pr', 'create', '--title', title, '--body-file', bodyFile]); - } - } else { - console.error('[upload] --create-pr not set: uploaded assets + wrote pr-body.uploaded.md; PR not created/edited.'); - } - - console.log(JSON.stringify({ ...plan, defaultBranch: branchDefault, urls, prBodyFile: args.dryRun ? null : bodyFile, prUrl: prUrl || null, dryRun: args.dryRun }, null, 2)); -} - -try { main(); } catch (error) { - console.error(`[upload] FAILED: ${error && error.message ? error.message : error}`); - process.exit(1); -} diff --git a/domains/agentic/skills/recipe-evidence/skill.md b/domains/agentic/skills/recipe-evidence/skill.md deleted file mode 100644 index 4c0fcf2..0000000 --- a/domains/agentic/skills/recipe-evidence/skill.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: recipe-evidence -description: Format recipe run outputs into concise PR-ready validation evidence for MetaMask reviewers. Use when an agent has recipe artifacts, screenshots, logs, or trace output and needs a clear PR comment or description section. -maturity: experimental ---- - -# Recipe Evidence - -`recipe-evidence` turns recipe outputs into reviewer-facing text. It does not invent proof. If artifacts are missing or weak, say so and call `/mms-recipe-quality`. - -Load only what applies: - -- Evidence examples: `references/examples.md` -- Target-repo evidence notes are appended below when installed. - -## Inputs - -Use available files: - -- `recipe.json` -- `summary.json` -- `trace.json` -- `artifact-manifest.json` -- screenshots, videos, logs, reports -- `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//manifest.json` and verify artifacts when runtime proof is claimed -- command output -- PR acceptance criteria or proof targets - -## Rules - -- Keep it brief. -- Link each artifact to the claim it proves. -- Separate passed proof from unrun gaps. -- Include the harness verify artifact path when runtime proof is claimed. -- Include `summary.json`, `trace.json`, and - `artifact-manifest.json`/evidence-manifest paths for every recipe run used as - proof. -- For reviewer-visible UI claims, include screenshot/video paths. If they are - missing, the evidence section must say the visual claim is unproven. -- Do not paste long logs. -- Redact secrets and private account data. -- Never claim a recipe passed if the run did not complete. -- Never claim Mobile or Extension runtime proof without a passing `/mms-recipe-harness verify`; report missing harness proof as a gap. - -## Output - -Always create a reviewer-copyable package directory under the task folder: - -```bash -.agents/skills/mms-recipe-evidence/scripts/package-pr-evidence.js \ - --task temp/tasks// -``` - -The package must contain: - -- `pr-package/pr-desc.md` — GitHub PR description draft with explicit - drag/drop image markers. This draft must be based on the target repo's - `.github/pull-request-template.md` / `.github/pull_request_template.md` - when that file exists; only use the generic fallback if no repo template is - present. -- `pr-package/images/` — copied screenshot evidence with short, - stable, reviewer-friendly filenames such as - `01-ac1-no-btc-position-banner-absent.png`. -- `pr-package/evidence.md` — full evidence block copied from - `PR-READY-EVIDENCE.md` when present. -- `pr-package/recipe-quality.md` — quality verdict when present. -- `pr-package/checklist.md` — checklist snapshot when present. -- `pr-package/package-manifest.json` — machine-readable package index. -- `pr-package/final-report.md` — human summary with the task path and package - path. - -If the script cannot infer enough context, still write `pr-desc.md` with TODO -markers rather than skipping the package. Do not put generated task artifacts in -the product PR diff; the package is for copy/paste and drag/drop only. - -The final response from the high-level skill must print: - -- `Task path: ` -- `PR package path: /pr-package` -- `PR description draft: /pr-package/pr-desc.md` -- `Evidence images folder: /pr-package/images` - -```md -### Recipe validation - -Verdict: pass-with-gaps - -Proved: -- PT-1: Send amount error appears for insufficient balance. -- PT-2: Error clears after valid amount entry. - -Artifacts: -- `summary.json` — run status and environment. -- `trace.json` — node execution trace. -- `screenshots/send-valid-amount.png` — settled valid amount screen. - -Gaps: -- Android not run. -``` - -- Treat blank/black screenshots as missing visual evidence unless the artifact includes an explicit explanation and alternate reviewer-visible proof. -- DOM-rendered screenshot fallbacks are acceptable when native CDP/Playwright screenshots are blank or time out, but label them as fallbacks and keep the original blank-capture gap visible. - -## Create PR + upload evidence (opt-in, consent-gated) - -Offer after packaging. Artifacts owner = logged-in user (`gh api user`), never -hard-coded; images → `/mm--artifacts` at `evidence//`, -videos stay local. ASK before each outward step (repo create, upload, PR create/edit). -When asking to create the artifacts repo, state plainly that it is a **public** -repo and the uploaded screenshots **may show wallet state** (addresses, balances, -positions) so the human's consent is informed. - -```bash -S=.agents/skills/mms-recipe-evidence/scripts/upload-pr-evidence.js -node "$S" --task --dry-run # plan, no writes -node "$S" --task [--ensure-repo] --confirm-public-upload [--create-pr] # after consent -``` - -`--confirm-public-upload` is **required** to upload any screenshot to the public -repo — without it the script refuses to upload (even if the repo already exists). -`--ensure-repo` creates the public artifacts repo; `--create-pr` creates-or-edits the -PR and fills `## Screenshots/Recordings`; without `--create-pr`, assets upload + a -`pr-body.uploaded.md` draft is written. `gh` writes run with `GH_TOKEN` unset. diff --git a/domains/agentic/skills/recipe-fix-ticket/references/metamask-extension-checklist.md b/domains/agentic/skills/recipe-fix-ticket/references/metamask-extension-checklist.md deleted file mode 100644 index 800b1ad..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/references/metamask-extension-checklist.md +++ /dev/null @@ -1,36 +0,0 @@ -# Extension fix-ticket checklist - -Target checklist for `metamask-extension` bug tickets. Reproduce or understand the failure -before patching. - -This file is the human's live progress view. `init-checklist.sh` copies it to the task -folder as `CHECKLIST.md`. Execute top-to-bottom; the moment a gate completes, flip -`[ ]` → `[x]` (or `BLOCKED: ` / `N/A: `) and add the artifact path/result -under it. - -- [ ] 0. Coffee handoff sent, naming this CHECKLIST.md path to monitor. -- [ ] 1. Ticket captured — URL or pasted text, summary, ACs. -- [ ] 2. AC matrix — numbered ACs; proof mode state/visual/mixed; primary evidence. -- [ ] 3. Extension target selected — popup, sidepanel, fullscreen, dapp tab, or service-worker/controller + rationale. -- [ ] 4. Repro/baseline plan written before behavior edits — route/tab, fixture/profile, selectors/testIDs, expected before evidence. -- [ ] 5. /mms-recipe-doctor setup readiness recorded — fixtures/tools; malformed fixture or missing tool = BLOCKED. -- [ ] 6. /mms-recipe-harness install/verify — manifest + verify path. -- [ ] 7. /mms-recipe-cook baseline/no-state recipe — path + exact command, or `BLOCKED: `. -- [ ] 8. Baseline/no-state recipe run — summary.json, trace.json, screenshot/manifest paths, or blocked reason. -- [ ] 9. Minimal fix implemented — product diff (excl. harness/generated); every changed line maps to an AC, no unrelated refactor. -- [ ] 10. Focused checks run — changed-file typecheck/Jest/lint. Not a stop gate. -- [ ] 11. /mms-recipe-cook after/with-state recipe — path + exact command proving each AC. -- [ ] 12. Runtime recipe run — summary.json, trace.json, manifest, logs. -- [ ] 13. Visual evidence gate — read PNGs; claimed UI visible in viewport for visual/mixed ACs. -- [ ] 14. /mms-recipe-quality critique — verdict; gaps assigned to product/recipe/fixture/harness/evidence. -- [ ] 15. Improvement/rerun loop — one fix + rerun, or explicit no-rerun verdict. -- [ ] 16. /mms-recipe-evidence package — PR-ready evidence block/file with artifact paths and blocked gaps. -- [ ] 17. Final response — fix, tests, recipe evidence, quality loop, remaining risk. Ask about runtime cleanup; offer PR on consent. - -Extension notes: - -- Because fix-ticket was invoked, do not switch to the dev protocol or mark baseline/repro `N/A` just because the ticket says POC/debug. Author a before/no-state recipe or record the concrete `/mms-recipe-cook` blocker. Name the fixture/flow used to create no-position/open-position state — don't rely on inherited browser/wallet state. -- Runtime start (webpack/dev server/Chrome/CDP) is approval-gated: without approval, record `BLOCKED: pending runtime-start approval` with the exact command and wait; with approval, start through `/mms-recipe-harness`, not raw builds. A live recipe that can't reach CDP (`/json/version` unreachable, no extension target, no summary.json) is `BLOCKED: CDP bootstrap failed` — fix the root cause, don't downgrade to pass-with-gaps. -- Visual/mixed ACs need a viewport-visible screenshot (`ui.scroll` + `ui.wait_for visible`), not DOM/controller state or a passing recipe alone; without a runtime PNG it is `BLOCKED: no runtime visual evidence`, never `code-proven`. -- No manufactured state: don't inject via `stateHooks`, store/controller writes, or DOM mutation. Use a real UI flow or harness pre-start fixture, else mark the AC a fixture/runtime gap. -- A fallback screenshot (`DOM-rendered fallback` / `fallbackReason` in trace.json) keeps that visual AC at `PASS-WITH-GAPS` even if summary.json says pass. diff --git a/domains/agentic/skills/recipe-fix-ticket/references/metamask-mobile-checklist.md b/domains/agentic/skills/recipe-fix-ticket/references/metamask-mobile-checklist.md deleted file mode 100644 index 1ae782b..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/references/metamask-mobile-checklist.md +++ /dev/null @@ -1,36 +0,0 @@ -# Mobile fix-ticket checklist - -Target checklist for `metamask-mobile` bug tickets. Reproduce or understand the failure -before patching. - -This file is the human's live progress view. `init-checklist.sh` copies it to the task -folder as `CHECKLIST.md`. Execute top-to-bottom; the moment a gate completes, flip -`[ ]` → `[x]` (or `BLOCKED: ` / `N/A: `) and add the artifact path/result -under it. - -- [ ] 0. Coffee handoff sent, naming this CHECKLIST.md path to monitor. -- [ ] 1. Ticket captured — URL or pasted text, summary, ACs. -- [ ] 2. AC matrix — numbered ACs; proof mode state/visual/mixed; primary evidence. -- [ ] 3. Mobile target selected — ios, android, or both + rationale. -- [ ] 4. Repro/baseline plan written before behavior edits — route, fixture/state, selectors/testIDs, expected before evidence. -- [ ] 5. /mms-recipe-doctor setup readiness recorded — fixtures/tools; malformed fixture or missing tool = BLOCKED. -- [ ] 6. /mms-recipe-harness install/verify — manifest + verify path. -- [ ] 7. /mms-recipe-cook baseline/no-state recipe — path + exact command, or `BLOCKED: `. -- [ ] 8. Baseline/no-state recipe run — summary.json, trace.json, screenshot/manifest paths, or blocked reason. -- [ ] 9. Minimal fix implemented — product diff (excl. harness/generated); every changed line maps to an AC, no unrelated refactor. -- [ ] 10. Focused checks run — changed-file typecheck/Jest/lint. Not a stop gate. -- [ ] 11. /mms-recipe-cook after/with-state recipe — path + exact command proving each AC. -- [ ] 12. Runtime recipe run — summary.json, trace.json, manifest, logs. -- [ ] 13. Visual evidence gate — read PNGs; claimed UI visible in viewport for visual/mixed ACs. -- [ ] 14. /mms-recipe-quality critique — verdict; gaps assigned to product/recipe/fixture/harness/evidence. -- [ ] 15. Improvement/rerun loop — one fix + rerun, or explicit no-rerun verdict. -- [ ] 16. /mms-recipe-evidence package — PR-ready evidence block/file with artifact paths and blocked gaps. -- [ ] 17. Final response — fix, tests, recipe evidence, quality loop, remaining risk. Ask about runtime cleanup; offer PR on consent. - -Mobile notes: - -- Because fix-ticket was invoked, do not switch to the dev protocol or mark baseline/repro `N/A` just because the ticket says POC/debug. Author a before/no-state recipe or record the concrete `/mms-recipe-cook` blocker. Name the fixture/flow used to create no-position/open-position state — don't rely on inherited simulator state. -- Runtime start (Metro/simulator) is approval-gated: without approval, record `BLOCKED: pending runtime-start approval` with the exact command and wait; with approval, start through `/mms-recipe-harness`, not raw `yarn`/native rebuilds. -- Visual/mixed ACs need a viewport-visible screenshot (`ui.scroll` + `ui.wait_for visible`), not fiber-tree/controller state or a passing recipe alone; without a runtime PNG it is `BLOCKED: no runtime visual evidence`, never `code-proven`. -- No manufactured state: don't inject via `stateHooks`, store/controller writes, or DOM/fiber mutation. Use a real UI flow or harness pre-start fixture, else mark the AC a fixture/runtime gap. -- A fallback screenshot (`DOM-rendered fallback` / `fallbackReason` in trace.json) keeps that visual AC at `PASS-WITH-GAPS` even if summary.json says pass. diff --git a/domains/agentic/skills/recipe-fix-ticket/repos/metamask-extension.md b/domains/agentic/skills/recipe-fix-ticket/repos/metamask-extension.md deleted file mode 100644 index e69d723..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/repos/metamask-extension.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-fix-ticket ---- - -# MetaMask Extension - -For Extension tickets, first classify whether the bug is popup UI, full-screen UI, service worker/controller state, dapp interaction, permissions, network, transaction, migration, or build/config behavior. - -Use existing e2e fixtures and controller tests before adding new helpers. Runtime proof should name the browser context and fixture. - -For visible Extension UI tickets, the pass bar is a live CDP recipe run, not -only Jest/type/lint. Use the runner-appropriate `mms-recipe-harness` delegate -(Codex: `$mms-recipe-harness`; Claude/Cursor: `/mms-recipe-harness`) or its -installed portable `scripts/recipe-harness verify` wrapper, then run the recipe -with `--cdp-port ` and save artifacts under an ignored task directory. -Do not require personal shell aliases. Return the recipe path, -`summary.json`, `trace.json`, screenshots, evidence manifest, and any fixture -gap. If CDP is offline and runtime-start approval exists, try the harness -auto-prepare path. If approval is required and absent, run static/no-start -harness checks and record `BLOCKED: pending runtime-start approval` with the -exact command. - -Load `references/metamask-extension-checklist.md` and execute it as the ordered checklist for Extension bug fixes. Name the target context: popup, sidepanel, fullscreen, dapp tab, or service-worker/controller. diff --git a/domains/agentic/skills/recipe-fix-ticket/repos/metamask-mobile.md b/domains/agentic/skills/recipe-fix-ticket/repos/metamask-mobile.md deleted file mode 100644 index bd7e8c3..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/repos/metamask-mobile.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-fix-ticket ---- - -# MetaMask Mobile - -For Mobile tickets, first classify whether the bug is navigation, rendering, wallet state, controller state, network, transaction, notification, deeplink, or build/config behavior. - -Use existing fixtures and page objects before adding new helpers. Runtime proof should avoid inherited simulator state. - -For visible Mobile UI tickets, the pass bar is a live recipe run on the intended -simulator/device, not only Jest/type/lint. Use the runner-appropriate -`mms-recipe-harness` delegate (Codex: `$mms-recipe-harness`; Claude/Cursor: -`/mms-recipe-harness`) or its installed portable `scripts/recipe-harness verify` -wrapper, then run the recipe through the installed Mobile recipe runner and save -artifacts under an ignored task directory. Do not require personal shell aliases. Return the recipe path, `summary.json`, `trace.json`, -screenshots/video when available, evidence manifest, and any fixture/device gap. -If the app or simulator is not reachable and runtime-start approval exists, let -the harness preflight attempt to prepare it. If approval is required and absent, -run static/no-start harness checks and record `BLOCKED: pending runtime-start -approval` with the exact command. - -Load `references/metamask-mobile-checklist.md` and execute it as the ordered checklist for Mobile bug fixes. Runtime proof should avoid inherited simulator state and name the fixture/setup flow used. diff --git a/domains/agentic/skills/recipe-fix-ticket/scripts/init-checklist.sh b/domains/agentic/skills/recipe-fix-ticket/scripts/init-checklist.sh deleted file mode 100755 index 6604f1c..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/scripts/init-checklist.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat >&2 <<'USAGE' -Usage: init-checklist.sh --platform mobile|extension [--slug task-slug] [--task-dir path] - -Creates a live CHECKLIST.md copied from this skill's embedded -platform checklist. Prints the CHECKLIST.md path on stdout. -USAGE -} - -platform="" -slug="task" -task_dir="" -while [ "$#" -gt 0 ]; do - case "$1" in - --platform) platform="${2:-}"; shift 2 ;; - --slug) slug="${2:-}"; shift 2 ;; - --task-dir) task_dir="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; - esac -done - -case "$platform" in - mobile|extension) ;; - *) echo "--platform must be mobile or extension" >&2; usage; exit 2 ;; -esac - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -skill_dir="$(cd "$script_dir/.." && pwd)" -skill_name="$(basename "$skill_dir")" -ref="$skill_dir/references/metamask-${platform}-checklist.md" -if [ ! -f "$ref" ]; then - echo "Checklist reference not found: $ref" >&2 - exit 1 -fi - -safe_slug="$(printf '%s' "$slug" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')" -[ -n "$safe_slug" ] || safe_slug="task" -if [ -z "$task_dir" ]; then - ts="$(date -u +%Y%m%dT%H%M%SZ)" - task_dir="temp/tasks/${skill_name}/${ts}-${safe_slug}" -fi -mkdir -p "$task_dir/artifacts" -out="$task_dir/CHECKLIST.md" -{ - printf '# Live Recipe Workflow Checklist\n\n' - printf 'Generated: %s\n\n' "$(date -Iseconds)" - printf 'Skill: `%s`\n\n' "$skill_name" - printf 'Platform: `%s`\n\n' "$platform" - printf 'Task slug: `%s`\n\n' "$safe_slug" - printf '> Human progress file: monitor this file. The agent must mark each gate `[ ]` → `[x]` as work progresses and add artifact paths/results under the relevant gate.\n\n' - cat "$ref" -} > "$out" -printf '%s\n' "$out" diff --git a/domains/agentic/skills/recipe-fix-ticket/skill.md b/domains/agentic/skills/recipe-fix-ticket/skill.md deleted file mode 100644 index 055f74a..0000000 --- a/domains/agentic/skills/recipe-fix-ticket/skill.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: recipe-fix-ticket -description: Fix a MetaMask bug from a Jira/GitHub ticket and prove it with a recipe. The human hands off a ticket and walks away; this drives ticket → repro → minimal fix → recipe proof → evidence package, then stops for review. Use when an agent must reproduce or understand an existing failure before fixing it. For new behavior with no bug to reproduce, use /mms-recipe-dev. -maturity: experimental ---- - -# Recipe Fix Ticket - -A thin orchestrator over the recipe pipeline (`/mms-recipe-doctor` → `/mms-recipe-harness` -→ `/mms-recipe-cook` → `/mms-recipe-quality` → `/mms-recipe-evidence`). The human starts it -and leaves; you run autonomously to a finalized, reviewer-ready result. Unlike -`/mms-recipe-dev`, you reproduce or understand the failure before patching. - -The proof, runtime, and recipe rules live in the lower skills. This wrapper only orders the -delegates, keeps the run honest, and stops with an evidence package. Do **not** re-derive -harness/recipe/runtime detail here — invoke or follow the named delegate instead. - -## Handoff (do first) - -1. Create the progress file the human monitors, then continue without waiting: - ```bash - .agents/skills/mms-recipe-fix-ticket/scripts/init-checklist.sh --platform --slug - ``` - It prints the `CHECKLIST.md` path. Fallback: copy `references/metamask--checklist.md` - to `temp/tasks//-/CHECKLIST.md`. -2. Reply once: *"Go get a coffee ☕ — I'll take it from ticket → fix → recipe → evidence and - report back when it's done or concretely blocked. Live progress: ``."* Then run - autonomously; do not wait for the human after this. -3. `CHECKLIST.md` is the source of truth. Mark each gate `[x]` / `BLOCKED: ` / - `N/A: ` with its artifact path as you go — not in one batch at the end. - -## Source of truth - -The ticket text or pasted details are authoritative. If Jira/MCP/web returns a login wall, -empty issue, or ambiguous page, ask the human once for the summary + acceptance criteria, -then continue. Never infer or rewrite ACs from branch names, stale artifacts, or prior runs. -Before editing product code, print the numbered AC matrix: target surface, state -precondition, exact copy, and proof mode (`state` / `visual` / `mixed`). Label any inferred -field `UNKNOWN` and ask rather than guess. - -## Delegate chain (in order) - -Each gate must actually invoke or follow the named skill; ad-hoc scripts, controller evals, -or screenshots do not satisfy it. Record the delegate output path or blocker in `CHECKLIST.md`. - -1. `/mms-recipe-doctor` — setup/fixture readiness. A malformed fixture or missing tool/harness - is `BLOCKED`; fix before product edits. -2. `/mms-recipe-harness` — install/verify the runtime when live proof applies. -3. `/mms-recipe-cook` — author the baseline/no-state recipe that captures the failure first, - then the after/with-state recipe that proves the fix. recipe-cook owns recipe format, proof - modes, reuse, and the no-fake-state rule. -4. Implement the **smallest** fix that satisfies the ACs (every changed line traces to an AC; - no adjacent refactors). Run focused lint/type/unit checks — passing only unlocks proof, it - is not a stop point. -5. Run the recipe live and read the screenshots yourself before trusting `status: pass`. -6. `/mms-recipe-quality` — critique against the AC matrix; apply one improve/rerun cycle or - record that none is needed. -7. `/mms-recipe-evidence` — PR-ready package: product diff, recipe path, run command, - `summary.json`, `trace.json`, artifact manifest, screenshots, quality verdict, gaps. - -## Safety invariants - -- **Runtime start is approval-gated.** Do not start or restart Metro, a simulator, webpack, - or Chrome/CDP — including wrappers, aliases, `nohup`, or background tmux that do — without - approval. Missing approval → record `BLOCKED: pending runtime-start approval` with the exact - command and wait. With approval, drive starts through `/mms-recipe-harness`, not raw builds. -- **No manufactured state.** Do not prove a user-visible AC by injecting state (`stateHooks`, - Redux/store writes, fiber/DOM mutation, controller/background calls). Valid proof is a real - UI-flow recipe or a harness-loaded pre-start fixture; otherwise mark the AC - `BLOCKED`/`PASS-WITH-GAPS` and name the missing fixture. -- **Fallback screenshots are not clean visual proof.** A PNG marked DOM-fallback / - native-capture-blank, or a `trace.json` `fallbackReason`, keeps that visual AC at - `PASS-WITH-GAPS` even when `summary.json` says pass. - -## Honest verdict - -Final verdict is `PASS` only when every AC proof target passed. Any unrun, blocked, or -fallback-only AC ⇒ `PASS-WITH-GAPS` or `BLOCKED`, listed by AC number. Never claim "all ACs -met" or "ready" from a code diff or unit tests alone. For visual/mixed ACs, "code-proven" is -not a valid status. - -## Ordered checklist - -`init-checklist.sh` copies the platform checklist into `CHECKLIST.md` **for the human to -follow** — it is their live progress view while they wait. Execute it in order and flip each -box `[ ]` → `[x]` (or `BLOCKED`/`N/A`) with its artifact path the moment that gate completes, -so the human watching the file always sees the true current state. The canonical sequence: - -```markdown -- [ ] 0. Coffee handoff sent. -- [ ] 1. Ticket captured: URL or pasted text, summary, ACs. -- [ ] 2. AC matrix: each AC numbered with proof mode (state/visual/mixed). -- [ ] 3. Target runtime selected (Mobile/Extension + env). -- [ ] 4. Repro/baseline plan written before product edits. -- [ ] 5. /mms-recipe-doctor setup status recorded. -- [ ] 6. /mms-recipe-harness install/verify; manifest path recorded. -- [ ] 7. /mms-recipe-cook baseline/no-state recipe + command (or baseline BLOCKED with reason). -- [ ] 8. Baseline recipe run, or explicitly blocked. -- [ ] 9. Minimal fix implemented. -- [ ] 10. Focused checks run (type/jest/lint). -- [ ] 11. /mms-recipe-cook after/with-state recipe + command. -- [ ] 12. Recipe run live; summary.json/trace.json/manifest paths recorded. -- [ ] 13. Screenshots read; claimed UI visible (not hidden/offscreen/wrong tab). -- [ ] 14. /mms-recipe-quality critique against AC matrix + artifacts. -- [ ] 15. One improve/rerun cycle, or quality says none needed. -- [ ] 16. /mms-recipe-evidence PR-ready package produced. -- [ ] 17. Final report: fix, tests, recipe evidence, gaps. Offer PR on consent. -``` - -Steps 1–4 precede behavior edits (except a tiny locator-only `testID` add needed to make -baseline evidence executable). A runtime blocker is valid only after step 6 or the relevant -recipe run was actually attempted and the exact failure recorded. - -## Finish - -Ask whether to clean up runtime resources (Metro port, simulator/device, webpack/CDP, tmux -pane) now or leave them for review. Once evidence is packaged, ask whether to open a PR; if -yes, use `/mms-recipe-evidence` "Create PR + upload" (owner via `gh api user`, consent-gate -every outward step). Then return: - -1. `Root Cause` — concise explanation. -2. `Fix` — files changed and why. -3. `Tests` — commands run and result. -4. `Recipe Evidence` — recipe path, artifacts, verdict. -5. `Remaining Risk` — only if something is unproven. diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/manifest.json b/domains/agentic/skills/recipe-harness/adapters/extension/manifest.json deleted file mode 100644 index d5bf2e0..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "adapter": "extension", - "runtime": "MetaMask Extension recipe runtime", - "version": 3, - "defaultInstallPath": "temp/agentic/recipe-harness/extension", - "readinessProbe": "scripts/extension-readiness.js", - "recommendedCommandEnv": { - "unset": ["BUNDLED_DEBUGPY_PATH"] - }, - "patchedFiles": [] -} diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/cleanup.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/cleanup.sh deleted file mode 100755 index 2fae937..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/cleanup.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --out) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; shift 2 ;; - -h|--help) echo "Usage: cleanup.sh [--target ]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/path.sh" -TARGET="$(cd "$TARGET" && pwd)" -HARNESS_DIR="$(harness_dir "$TARGET" extension)" - -if [ -s "$HARNESS_DIR/added-git-exclude" ]; then - git_dir="$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null || true)" - if [ -n "$git_dir" ]; then - case "$git_dir" in - /*) ;; - *) git_dir="$TARGET/$git_dir" ;; - esac - exclude_file="$git_dir/info/exclude" - if [ -f "$exclude_file" ]; then - # Remove only the lines THIS install recorded, one occurrence per distinct - # ledger entry (the appended copy is the last match). A pre-existing - # duplicate copy, or a stale/duplicate ledger entry, must never drop a line - # we did not add this run. - tmp_file="$(mktemp)" - awk ' - NR==FNR { if (length($0)) want[$0]=1; next } - { lines[++n]=$0; if ($0 in want) last[$0]=n } - END { for (k in last) drop[last[k]]=1; for (i=1;i<=n;i++) if (!(i in drop)) print lines[i] } - ' "$HARNESS_DIR/added-git-exclude" "$exclude_file" > "$tmp_file" - mv "$tmp_file" "$exclude_file" - fi - fi -fi - -# Leave the consumer's .skills-cache/ alone: it is gitignored and owned by the -# product checkout, not the harness. -rm -rf "$HARNESS_DIR" -echo "Cleaned extension recipe harness from $TARGET" diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/extension-readiness.js b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/extension-readiness.js deleted file mode 100755 index c6acad3..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/extension-readiness.js +++ /dev/null @@ -1,379 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const http = require('http'); -const path = require('path'); - -function parseArgs(argv) { - const out = { target: process.cwd(), cdpPort: '', json: false }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--target') { - out.target = argv[++i]; - } else if (arg === '--cdp-port') { - out.cdpPort = argv[++i]; - } else if (arg === '--json') { - out.json = true; - } else if (arg === '-h' || arg === '--help') { - console.log('Usage: extension-readiness.js --target [--cdp-port ] [--json]'); - process.exit(0); - } else { - throw new Error(`Unknown arg: ${arg}`); - } - } - return out; -} - -function readManifest(target) { - const manifestPath = path.join(target, 'dist/chrome/manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - return { manifestPath, manifest }; -} - -function manifestExpectedFiles(manifest) { - const entries = new Set(['home.html']); - if (manifest.background?.service_worker) entries.add(manifest.background.service_worker); - for (const script of manifest.background?.scripts || []) entries.add(script); - const popup = - manifest.action?.default_popup || - manifest.browser_action?.default_popup || - manifest.page_action?.default_popup; - if (popup) entries.add(popup); - if (manifest.side_panel?.default_path) entries.add(manifest.side_panel.default_path); - return [...entries]; -} - -function manifestDefaultPage(manifest) { - return ( - manifest.action?.default_popup || - manifest.browser_action?.default_popup || - manifest.page_action?.default_popup || - manifest.side_panel?.default_path || - 'home.html' - ); -} - -function extensionIdPath(target) { - return path.join(target, 'temp/runtime/extension.id'); -} - -function readExpectedExtensionId(target) { - const idPath = path.join(target, 'temp/runtime/extension.id'); - if (!fs.existsSync(idPath)) return ''; - const id = fs.readFileSync(idPath, 'utf8').trim(); - return /^[a-p]{32}$/.test(id) ? id : ''; -} - -function writeExtensionId(target, extensionId) { - if (!/^[a-p]{32}$/.test(extensionId)) return false; - const idPath = extensionIdPath(target); - fs.mkdirSync(path.dirname(idPath), { recursive: true }); - const existing = fs.existsSync(idPath) ? fs.readFileSync(idPath, 'utf8').trim() : ''; - if (existing === extensionId) return false; - fs.writeFileSync(idPath, `${extensionId}\n`); - return true; -} - -function httpJson(url, timeoutMs = 3000) { - return new Promise((resolve, reject) => { - const req = http.get(url, (res) => { - let data = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch (err) { - reject(new Error(`invalid JSON from ${url}: ${err.message}`)); - } - }); - }); - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`timeout from ${url}`)); - }); - req.on('error', reject); - }); -} - -function httpJsonRequest(method, url, timeoutMs = 3000) { - return new Promise((resolve, reject) => { - const req = http.request(url, { method }, (res) => { - let data = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch (err) { - reject(new Error(`invalid JSON from ${method} ${url}: ${err.message}`)); - } - }); - }); - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`timeout from ${method} ${url}`)); - }); - req.on('error', reject); - req.end(); - }); -} - -function resolveWebSocket(target) { - try { - return require(require.resolve('ws', { paths: [target, process.cwd()] })); - } catch { - return typeof WebSocket === 'function' ? WebSocket : null; - } -} - -async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) { - const WebSocketImpl = resolveWebSocket(target); - if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable in this Node runtime' }; - return new Promise((resolve, reject) => { - const ws = new WebSocketImpl(webSocketDebuggerUrl); - const timer = setTimeout(() => { - try { - ws.close(); - } catch { - // Best effort timeout cleanup. - } - reject(new Error('timeout evaluating extension page via CDP')); - }, timeoutMs); - const onOpen = () => { - ws.send(JSON.stringify({ - id: 1, - method: 'Runtime.evaluate', - params: { expression, awaitPromise: true, returnByValue: true }, - })); - }; - const onMessage = (event) => { - const raw = event?.data ?? event; - const msg = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)); - if (msg.id !== 1) return; - clearTimeout(timer); - ws.close(); - if (msg.error) { - reject(new Error(msg.error.message || JSON.stringify(msg.error))); - return; - } - resolve(msg.result?.result?.value ?? null); - }; - const onError = (err) => { - clearTimeout(timer); - reject(new Error(`CDP websocket error while inspecting extension page: ${err?.message || err || 'unknown'}`)); - }; - if (typeof ws.on === 'function') { - ws.on('open', onOpen); - ws.on('message', onMessage); - ws.on('error', onError); - } else { - ws.addEventListener('open', onOpen); - ws.addEventListener('message', onMessage); - ws.addEventListener('error', onError); - } - }); -} - -function extensionIdFromTarget(target) { - const url = String(target.url || ''); - const match = url.match(/^chrome-extension:\/\/([a-p]{32})\//u); - return match ? match[1] : ''; -} - -function chooseExtensionId(targets, expectedExtensionId, expectedServiceWorker) { - const byId = new Map(); - for (const target of targets) { - const id = extensionIdFromTarget(target); - if (!id) continue; - const entries = byId.get(id) || []; - entries.push(target); - byId.set(id, entries); - } - const ids = [...byId.keys()]; - if (ids.length === 0) return { extensionIds: [], selectedExtensionId: '' }; - - const hasExpectedWorker = (id) => byId.get(id).some((target) => { - const url = String(target.url || ''); - return target.type === 'service_worker' && expectedServiceWorker && url.endsWith(`/${expectedServiceWorker}`); - }); - const hasAnyWorker = (id) => byId.get(id).some((target) => target.type === 'service_worker'); - - const selectedExtensionId = - ids.find((id) => id === expectedExtensionId && hasExpectedWorker(id)) || - ids.find((id) => hasExpectedWorker(id)) || - ids.find((id) => id === expectedExtensionId && hasAnyWorker(id)) || - ids.find((id) => hasAnyWorker(id)) || - (expectedExtensionId && ids.includes(expectedExtensionId) ? expectedExtensionId : ids[0]); - - return { extensionIds: ids, selectedExtensionId }; -} - -async function openExtensionPage(cdpPort, extensionId, pagePath) { - const normalizedPath = String(pagePath || 'home.html').replace(/^\/+/u, ''); - const url = `chrome-extension://${extensionId}/${normalizedPath}`; - try { - await httpJsonRequest('PUT', `http://127.0.0.1:${cdpPort}/json/new?${encodeURIComponent(url)}`, 3000); - return true; - } catch (err) { - // Some CDP implementations disable /json/new. Readiness can still pass - // when the caller already opened an extension page, so report this as a - // best-effort page-open miss instead of hiding a required check failure. - return false; - } -} - -function findPageTarget(targets, selectedExtensionId, preferredPagePath = '') { - const extensionPages = targets.filter((target) => { - const url = String(target.url || ''); - return ( - target.type === 'page' && - url.startsWith(`chrome-extension://${selectedExtensionId}/`) && - typeof target.webSocketDebuggerUrl === 'string' - ); - }); - const normalizedPreferred = String(preferredPagePath || '').replace(/^\/+/u, ''); - return ( - extensionPages.find((target) => normalizedPreferred && String(target.url || '').endsWith(`/${normalizedPreferred}`)) || - extensionPages.find((target) => !String(target.url || '').includes('/popup-init.html')) || - extensionPages[0] - ); -} - -async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceWorker, extensionPagePath) { - const version = await httpJson(`http://127.0.0.1:${cdpPort}/json/version`); - let targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`); - if (!Array.isArray(targets)) throw new Error('/json/list did not return an array'); - let { extensionIds, selectedExtensionId } = chooseExtensionId(targets, expectedExtensionId, expectedServiceWorker); - if (!selectedExtensionId) { - throw new Error('CDP is reachable but no chrome-extension:// targets are present'); - } - let pageTarget = findPageTarget(targets, selectedExtensionId, extensionPagePath); - let openedPage = false; - if (!pageTarget) { - openedPage = await openExtensionPage(cdpPort, selectedExtensionId, extensionPagePath); - await new Promise((resolve) => setTimeout(resolve, 500)); - targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`); - ({ extensionIds, selectedExtensionId } = chooseExtensionId(targets, expectedExtensionId, expectedServiceWorker)); - pageTarget = findPageTarget(targets, selectedExtensionId, extensionPagePath); - } - let ui = null; - if (pageTarget) { - ui = await cdpEvaluate( - target, - pageTarget.webSocketDebuggerUrl, - `(() => { - const text = document.body?.innerText || ''; - const cell = (sel) => document.querySelector(sel)?.innerText?.trim() || ''; - // React error-boundary screen (ui/pages/error-page); deterministic testids. - const errorBoundaryMessage = cell('[data-testid="error-page-error-message"]'); - const errorBoundaryName = cell('[data-testid="error-page-error-name"]'); - // Testid-only trigger: ui/pages/error-page reliably renders these testids. - // Avoid matching the visible phrase in body text (false positives). - const hasErrorBoundary = Boolean(errorBoundaryMessage) || Boolean(errorBoundaryName); - return { - title: document.title, - url: location.href, - textSample: text.slice(0, 500), - hasStartupError: /MetaMask had trouble starting|Background connection unresponsive|Unknown Infura network/i.test(text), - hasErrorBoundary, - errorBoundaryName, - errorBoundaryMessage: errorBoundaryMessage || (hasErrorBoundary ? text.slice(0, 300) : ''), - }; - })()`, - ); - if (ui && !ui.skipped && ui.hasStartupError) { - throw Object.assign(new Error('MetaMask extension page loaded startup error UI'), { - report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } }, - }); - } - if (ui && !ui.skipped && ui.hasErrorBoundary) { - const detail = [ui.errorBoundaryName, ui.errorBoundaryMessage].filter(Boolean).join(': '); - throw Object.assign(new Error(`MetaMask UI crashed (React error boundary)${detail ? `: ${detail}` : ''}`), { - report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } }, - }); - } - if (ui && !ui.skipped && String(ui.url || '').startsWith('chrome-error://')) { - throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), { - report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } }, - }); - } - } - return { - browser: version.Browser || 'unknown', - extensionIds, - selectedExtensionId, - markerMatched: Boolean(expectedExtensionId && selectedExtensionId === expectedExtensionId), - targetCount: targets.length, - openedPage, - openedPagePath: extensionPagePath, - ui, - }; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - const target = path.resolve(args.target); - const checks = []; - const { manifestPath, manifest } = readManifest(target); - const expectedFiles = manifestExpectedFiles(manifest); - const missingFiles = []; - for (const rel of expectedFiles) { - const exists = fs.existsSync(path.join(target, 'dist/chrome', rel)); - checks.push({ name: `dist/chrome/${rel}`, status: exists ? 'pass' : 'fail' }); - if (!exists) missingFiles.push(rel); - } - if (missingFiles.length > 0) { - throw Object.assign( - new Error(`extension build incomplete; missing ${missingFiles.join(', ')}`), - { report: { target, manifestPath, expectedFiles, checks } }, - ); - } - - const defaultPage = fs.existsSync(path.join(target, 'dist/chrome/home.html')) - ? 'home.html' - : manifestDefaultPage(manifest); - const report = { - target, - manifestPath, - manifestVersion: manifest.manifest_version || null, - defaultPage, - expectedFiles, - checks, - }; - - if (args.cdpPort) { - const expectedExtensionId = readExpectedExtensionId(target); - report.cdp = await inspectCdp( - target, - args.cdpPort, - expectedExtensionId, - manifest.background?.service_worker || '', - report.defaultPage, - ); - report.cdp.markerRepaired = writeExtensionId(target, report.cdp.selectedExtensionId); - checks.push({ name: `CDP ${args.cdpPort} extension targets`, status: 'pass' }); - } - - if (args.json) { - console.log(JSON.stringify(report, null, 2)); - } else { - console.log(`Extension readiness OK: ${expectedFiles.join(', ')}`); - if (report.cdp) { - console.log(`CDP OK: ${report.cdp.browser}; extensions=${report.cdp.extensionIds.join(',')}`); - } - } -} - -main().catch((err) => { - const report = err && err.report ? err.report : null; - if (process.argv.includes('--json')) { - console.log(JSON.stringify({ status: 'fail', error: err.message, ...(report || {}) }, null, 2)); - } else { - console.error(`Extension readiness failed: ${err.message}`); - } - process.exit(1); -}); diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/install.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/install.sh deleted file mode 100755 index 6a30a26..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/install.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --out) echo "install does not support --out (it never isolated recipes). Install normally, then pass --out to 'verify'/'live' for task-local recipe artifacts." >&2; exit 2 ;; - -h|--help) echo "Usage: install.sh [--target ]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -dir_content_hash() { - find "$1" -type f -print0 2>/dev/null | sort -z | xargs -0 shasum -a 256 2>/dev/null | shasum -a 256 | awk '{print $1}' -} - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ADAPTER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -SKILL_DIR="$(cd "${ADAPTER_DIR}/../.." && pwd)" -AGENTIC_DIR="$(cd "$SKILL_DIR/../.." && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/path.sh" -# shellcheck disable=SC1091 -. "$SKILL_DIR/scripts/resolve-runner-source.sh" -TARGET="$(cd "$TARGET" && pwd)" -resolve_metamask_recipe_runner_source "$SKILL_DIR" "$AGENTIC_DIR" "$TARGET" -HARNESS_ROOT="$(harness_root)" -HARNESS_REL="$HARNESS_ROOT/extension" -HARNESS_DIR="$(harness_dir "$TARGET" extension)" - -refuse_symlink_destination() { - local rel="$1" - local path_so_far="$TARGET" - IFS='/' read -r -a parts <<< "$rel" - for part in "${parts[@]}"; do - [ -n "$part" ] || continue - path_so_far="$path_so_far/$part" - if [ -L "$path_so_far" ]; then - echo "Refusing extension recipe harness install: $rel contains symlink component $path_so_far." >&2 - return 1 - fi - done -} - -make_executable() { - local file="$1" - chmod +x "$file" - if [ ! -x "$file" ]; then - echo "Refusing extension recipe harness install: failed to make executable: $file" >&2 - return 1 - fi -} - -# refuse_symlink_destination walks every path component, so the deepest paths -# also guard their parents ($HARNESS_REL covers the root segments). -refuse_symlink_destination "$HARNESS_REL" -refuse_symlink_destination "$HARNESS_REL/runner/bin/metamask-recipe" -refuse_symlink_destination "$HARNESS_REL/action-manifest.json" - -mkdir -p "$HARNESS_DIR" - -rm -rf "$HARNESS_DIR/runner" -mkdir -p "$HARNESS_DIR/runner/bin" "$HARNESS_DIR/runner/manifests" "$HARNESS_DIR/runner/recipes" -# Emit shell-safe lines: %q-quote the interpolated paths (like CLEANUP_COMMAND -# below) so a FARMSLOT_ROOT/runner path containing a space — or $()/backtick/quote -# — cannot break the generated wrapper or inject at runtime. -runner_farmslot_root_q="$(printf '%q' "$METAMASK_RUNNER_FARMSLOT_ROOT")" -runner_exec_q="$(printf '%q' "$METAMASK_RUNNER_DIR/bin/metamask-recipe")" -{ - printf '%s\n' '#!/usr/bin/env bash' - printf '%s\n' 'set -euo pipefail' - if [ -n "$METAMASK_RUNNER_FARMSLOT_ROOT" ]; then - printf 'export FARMSLOT_ROOT=${FARMSLOT_ROOT:-%s}\n' "$runner_farmslot_root_q" - fi - printf 'exec %s "$@"\n' "$runner_exec_q" -} > "$HARNESS_DIR/runner/bin/metamask-recipe" -if [ -n "$METAMASK_RUNNER_FARMSLOT_ROOT" ]; then - printf '%s\n' "$METAMASK_RUNNER_FARMSLOT_ROOT" > "$HARNESS_DIR/runner/.farmslot-root" -fi -printf '%s\n' "$METAMASK_RUNNER_DIR" > "$HARNESS_DIR/runner/.runner-source" -cp "$METAMASK_RUNNER_DIR/manifests/mobile.action-manifest.json" "$HARNESS_DIR/runner/manifests/mobile.action-manifest.json" -cp "$METAMASK_RUNNER_DIR/manifests/extension.action-manifest.json" "$HARNESS_DIR/runner/manifests/extension.action-manifest.json" -if [ -d "$METAMASK_RUNNER_DIR/recipes" ]; then - rsync -a --delete "$METAMASK_RUNNER_DIR/recipes/" "$HARNESS_DIR/runner/recipes/" -fi -cp "$METAMASK_RUNNER_DIR/manifests/extension.action-manifest.json" "$HARNESS_DIR/action-manifest.json" -make_executable "$HARNESS_DIR/runner/bin/metamask-recipe" -mkdir -p "$HARNESS_DIR/scripts" -rsync -a --delete "$ADAPTER_DIR/scripts/" "$HARNESS_DIR/scripts/" -for executable in "$HARNESS_DIR/scripts/"*.sh "$HARNESS_DIR/scripts/"*.js; do - [ -e "$executable" ] || continue - make_executable "$executable" -done -# Co-locate the shared JSON helper (lives in the skill's generic scripts/lib) so the -# installed adapter scripts are self-contained when run from .agent. -mkdir -p "$HARNESS_DIR/scripts/lib" -cp "$SKILL_DIR/scripts/lib/json-field.sh" "$HARNESS_DIR/scripts/lib/json-field.sh" -cp "$SKILL_DIR/scripts/lib/harness-path.sh" "$HARNESS_DIR/scripts/lib/harness-path.sh" -dir_content_hash "$HARNESS_DIR/scripts" > "$HARNESS_DIR/installed-scripts.sha256" - -add_git_exclude() { - local entry="$1" - local git_dir - local exclude_file - if ! git_dir="$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null)"; then - return 0 - fi - # Skip if the path is already gitignored (e.g. a temp/-rooted harness under an - # existing temp/ rule) — no redundant info/exclude entry needed. - if git -C "$TARGET" check-ignore -q "${entry%/}" 2>/dev/null; then - return 0 - fi - case "$git_dir" in - /*) ;; - *) git_dir="$TARGET/$git_dir" ;; - esac - exclude_file="$git_dir/info/exclude" - mkdir -p "$(dirname "$exclude_file")" - touch "$exclude_file" - if ! grep -qxF "$entry" "$exclude_file"; then - echo "$entry" >> "$exclude_file" - echo "$entry" >> "$HARNESS_DIR/added-git-exclude" - fi -} - -# "$HARNESS_ROOT/" already covers the active root (the default IS -# temp/agentic/recipe-harness); adding the default literal too would leave a stray -# exclude entry when RECIPE_HARNESS_ROOT is customized, so it is not added here. -add_git_exclude "$HARNESS_ROOT/" -add_git_exclude ".skills-cache/" - -SOURCE_REV="$(git -C "$SKILL_DIR" rev-parse HEAD 2>/dev/null || echo unknown)" -# Build the cleanup hint with shell-safe quoting here (target/script paths may -# contain spaces); HARNESS_ROOT is charset-validated so it needs no quoting. -CLEANUP_COMMAND="RECIPE_HARNESS_ROOT=$HARNESS_ROOT $(printf '%q' "$SCRIPT_DIR/cleanup.sh") --target $(printf '%q' "$TARGET")" -node -e ' - const fs = require("fs"); - const m = { - adapter: "extension", - installedAt: new Date().toISOString(), - source: { - skillDir: process.argv[1], - skillRevision: process.argv[2], - runnerDir: process.argv[3], - runnerRevision: process.argv[4], - runnerSourceKind: process.argv[5], - adapterRuntime: process.argv[6] - }, - target: process.argv[7], - protocolVersion: "v1", - actionManifestPath: process.argv[11] + "/action-manifest.json", - runnerEntrypoint: process.argv[11] + "/runner/bin/metamask-recipe", - installedPaths: [process.argv[11] + "/scripts", process.argv[11] + "/runner", process.argv[11] + "/action-manifest.json"], - patchedFiles: [], - recommendedCommandEnv: { unset: ["BUNDLED_DEBUGPY_PATH"] }, - backupDir: null, - cleanupCommand: process.argv[12], - productDiffExcludes: [":(exclude)" + process.argv[10], ":(exclude).skills-cache"] - }; - fs.writeFileSync(process.argv[9], JSON.stringify(m, null, 2) + "\n"); -' "$SKILL_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND" - -echo "Installed extension recipe harness: $HARNESS_DIR/manifest.json" diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/launch.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/launch.sh deleted file mode 100755 index 9884673..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/launch.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -CDP_PORT="" -ARTIFACTS="" -PREPARE_CMD="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --cdp-port) CDP_PORT="$2"; shift 2 ;; - --artifacts-dir) ARTIFACTS="$2"; shift 2 ;; - --prepare-cmd) PREPARE_CMD="$2"; shift 2 ;; - -h|--help) echo "Usage: launch.sh [--target ] [--cdp-port ] [--prepare-cmd ] [--artifacts-dir ]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -# Reject a non-numeric --cdp-port before it reaches the prepare/launch command. -if [ -n "$CDP_PORT" ]; then - case "$CDP_PORT" in - *[!0-9]*) echo "Invalid --cdp-port (must be numeric): $CDP_PORT" >&2; exit 2 ;; - esac -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/path.sh" -TARGET="$(cd "$TARGET" && pwd)" -HARNESS_DIR="$(harness_dir "$TARGET" extension)" -ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/launch/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS/logs" - -if [ ! -f "$HARNESS_DIR/manifest.json" ]; then - echo "Extension recipe harness is not installed in $TARGET. Run recipe-harness extension install --target $TARGET first." >&2 - exit 1 -fi - -if [ -n "$PREPARE_CMD" ]; then - echo "Launching Extension harness runtime with caller-supplied prepare command" | tee "$ARTIFACTS/logs/launch.log" - set +e - ( - cd "$TARGET" - bash -lc "$PREPARE_CMD" - ) 2>&1 | tee -a "$ARTIFACTS/logs/launch.log" - prepare_status=${PIPESTATUS[0]} - set -e -else - echo "No Extension prepare command supplied; reusing existing CDP runtime if reachable." | tee "$ARTIFACTS/logs/launch.log" - prepare_status=0 -fi - -status="pass" -if [ "$prepare_status" -ne 0 ]; then - status="fail" -elif [ -z "$CDP_PORT" ]; then - echo "Missing --cdp-port; cannot confirm Extension app-control runtime." | tee -a "$ARTIFACTS/logs/launch.log" - status="fail" -elif node "$(dirname "$0")/extension-readiness.js" --target "$TARGET" --cdp-port "$CDP_PORT" --json > "$ARTIFACTS/logs/extension-readiness.json" 2>&1; then - : -else - status="fail" -fi - -TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" STATUS_FOR_SUMMARY="$status" CDP_PORT_FOR_SUMMARY="$CDP_PORT" PREPARE_SUPPLIED="$([ -n "$PREPARE_CMD" ] && echo true || echo false)" PREPARE_STATUS="$prepare_status" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const target = process.env.TARGET_FOR_SUMMARY; -const artifacts = process.env.ARTIFACTS_FOR_SUMMARY; -let readiness = null; -try { readiness = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/extension-readiness.json'), 'utf8')); } catch {} -const appControlStatus = - process.env.STATUS_FOR_SUMMARY === 'pass' && readiness && readiness.status !== 'fail' ? 'pass' : 'fail'; -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'extension', - action: 'launch', - status: process.env.STATUS_FOR_SUMMARY, - target, - cdpPort: process.env.CDP_PORT_FOR_SUMMARY || null, - prepare: { - commandSupplied: process.env.PREPARE_SUPPLIED === 'true', - status: Number(process.env.PREPARE_STATUS) === 0 ? 'pass' : 'fail', - exitCode: Number(process.env.PREPARE_STATUS), - logPath: path.join(artifacts, 'logs/launch.log'), - }, - runtimePolicy: { - runtimeReusePolicy: 'reuse a running harness-compatible CDP target when possible; caller-supplied startup commands must use cached/watch-only paths unless the human explicitly permits a rebuild', - }, - appControl: { - status: appControlStatus, - readiness, - }, - cleanupCommand: `recipe-harness extension cleanup --target ${target}`, - note: 'Launch starts/reuses the harness runtime only; it does not run a recipe or claim evidence validation. Extension startup commands are caller-supplied so the skill does not encode local farm aliases.', - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -NODE - -echo "Extension harness launch $status: $ARTIFACTS/summary.json" -[ "$status" = "pass" ] diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/live.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/live.sh deleted file mode 100755 index ef4efbd..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/live.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -CDP_PORT="" -ARTIFACTS="" -OUT="" -PREPARE_CMD="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}" -LAUNCH_EXISTING_DIST=false -START_WATCH=false -DIST_DIR="dist/chrome" -CHROME_USER_DATA_DIR="" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; TARGET="$2"; shift 2 ;; - --out) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; OUT="$2"; shift 2 ;; - --cdp-port) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CDP_PORT="$2"; shift 2 ;; - --artifacts-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; ARTIFACTS="$2"; shift 2 ;; - --prepare-cmd) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; PREPARE_CMD="$2"; shift 2 ;; - --launch-existing-dist) LAUNCH_EXISTING_DIST=true; shift ;; - --start-watch|--start-test-watch) START_WATCH=true; LAUNCH_EXISTING_DIST=true; shift ;; - --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;; - --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;; - -h|--help) echo "Usage: live.sh [--target ] [--out ] --cdp-port [--launch-existing-dist|--start-watch|--prepare-cmd ] [--dist-dir dist/chrome] [--artifacts-dir ]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -[ -n "$CDP_PORT" ] || { echo "Missing --cdp-port for Extension live validation" >&2; exit 2; } -# Reject a non-numeric port before it is interpolated into the Chrome launch -# command and the CDP HTTP probes. -case "$CDP_PORT" in - *[!0-9]*) echo "Invalid --cdp-port (must be numeric): $CDP_PORT" >&2; exit 2 ;; -esac - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/path.sh" -TARGET="$(cd "$TARGET" && pwd)" -# Runner bin (installed wrapper → source runner). Used by the watch prepare path -# to defer the cache-clear DECISION to `runtime-decision` (single source) and to -# record the deps/cache baseline after a confirmed-good build. -RUNNER_BIN="$(harness_dir "$TARGET" extension)/runner/bin/metamask-recipe" -OUT="${OUT:-$(harness_root)/extension/runner/recipes}" -ARTIFACTS="${ARTIFACTS:-$(harness_dir "$TARGET" extension)/live/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS/logs" - -# Prune stale live artifact dirs (configurable harness root, not a hardcoded path) -# so old runtime-dist snapshots can't be loaded and don't accumulate to GBs. -# Keep the most recent few; override count with RECIPE_HARNESS_LIVE_KEEP. -LIVE_ROOT="$(harness_dir "$TARGET" extension)/live" -LIVE_KEEP="${RECIPE_HARNESS_LIVE_KEEP:-5}" -# Must be a positive integer: a non-numeric value would break the arithmetic and a -# 0 would `tail -n +1` and delete every dir including this run's fresh $ARTIFACTS. -case "$LIVE_KEEP" in ''|*[!0-9]*) LIVE_KEEP=5 ;; esac -[ "$LIVE_KEEP" -ge 1 ] 2>/dev/null || LIVE_KEEP=5 -if [ -d "$LIVE_ROOT" ]; then - # `|| true`: an empty live/ (no child dirs) makes ls exit non-zero, which would - # abort the script under `set -euo pipefail`. Pruning is best-effort. - ls -1dt "$LIVE_ROOT"/*/ 2>/dev/null | tail -n "+$((LIVE_KEEP + 1))" | while IFS= read -r _old; do rm -rf "$_old"; done || true -fi - -if $LAUNCH_EXISTING_DIST && [ -z "$PREPARE_CMD" ]; then - DIST_ABS="$TARGET/$DIST_DIR" - RUNTIME_DIST_ABS="$ARTIFACTS/runtime-dist" - PROFILE_ABS="${CHROME_USER_DATA_DIR:-$ARTIFACTS/chrome-profile}" - WALLET_FIXTURE_ABS="" - if [ -f "$TARGET/temp/runtime/wallet-fixture.json" ]; then - WALLET_FIXTURE_ABS="$TARGET/temp/runtime/wallet-fixture.json" - elif [ -f "$TARGET/.agent/wallet-fixture.json" ]; then - WALLET_FIXTURE_ABS="$TARGET/.agent/wallet-fixture.json" - fi - FIXTURE_STATE_ABS="$ARTIFACTS/fixture-state.json" - FIXTURE_VALIDATION_ABS="$ARTIFACTS/logs/fixture-account-parity.json" - mkdir -p "$PROFILE_ABS" - quoted_dist="$(printf '%q' "$DIST_ABS")" - quoted_runtime_dist="$(printf '%q' "$RUNTIME_DIST_ABS")" - quoted_profile="$(printf '%q' "$PROFILE_ABS")" - quoted_fixture_script="$(printf '%q' "$SCRIPT_DIR/wallet-fixture-state.cjs")" - quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")" - quoted_fixture_validation="$(printf '%q' "$FIXTURE_VALIDATION_ABS")" - quoted_extension_id_file="$(printf '%q' "$TARGET/temp/runtime/extension.id")" - quoted_target="$(printf '%q' "$TARGET")" - quoted_runner="$(printf '%q' "$RUNNER_BIN")" - if [ -n "${RECIPE_HARNESS_CHROME_BIN:-}" ]; then - CHROME_BIN="$RECIPE_HARNESS_CHROME_BIN" - if [ ! -f "$CHROME_BIN" ] || [ ! -x "$CHROME_BIN" ]; then - echo "[recipe-harness] RECIPE_HARNESS_CHROME_BIN is not an executable file: $CHROME_BIN" >&2 - exit 1 - fi - else - CHROME_BIN="$(cd "$TARGET" && node <<'NODE' || true -const fs = require('fs'); - -let chromium = null; -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; -} -for (const pkg of ['@playwright/test', 'playwright']) { - try { - chromium = require(pkg).chromium; - if (chromium) break; - } catch (_error) { - // Optional Playwright package unavailable; try the next package name. - } -} -if (!chromium) { - console.error('[recipe-harness] Playwright is not available from this checkout; install dependencies first, or set RECIPE_HARNESS_CHROME_BIN to an explicitly approved browser.'); - process.exit(1); -} - -let executable = ''; -try { - executable = chromium.executablePath(); -} catch (error) { - const message = error && error.message ? error.message : String(error); - console.error(`[recipe-harness] Could not resolve Playwright Chromium executable: ${message}. Manual approval required before installing the Playwright Chromium browser cache (no package.json changes); ask the user before running yarn exec playwright install chromium.`); - process.exit(1); -} -if (!fs.existsSync(executable)) { - console.error(`[recipe-harness] Playwright Chromium is not installed at ${executable}. Manual approval required before installing the Playwright Chromium browser cache (no package.json changes). Ask the user for approval; if they agree, run: cd ${shellQuote(process.cwd())} && yarn exec playwright install chromium`); - console.error('[recipe-harness] To use a browser that is already installed, set RECIPE_HARNESS_CHROME_BIN=/path/to/chrome explicitly.'); - process.exit(1); -} - -process.stdout.write(executable); -NODE -)" - if [ -z "$CHROME_BIN" ]; then - echo "[recipe-harness] No approved Chromium binary selected; stopping before live Extension launch." >&2 - exit 1 - fi - fi - quoted_chrome="$(printf '%q' "$CHROME_BIN")" - quoted_chrome_log="$(printf '%q' "$ARTIFACTS/logs/chrome.log")" - quoted_chrome_pid="$(printf '%q' "$ARTIFACTS/logs/chrome.pid")" - prepare_parts=() - if $START_WATCH; then - prepare_parts+=("mkdir -p temp/runtime") - # Deterministic self-heal, single-source: ask the runner whether the webpack - # cache is poisoned (a post-install dedup leaves ENOENT-on-a-deduped-module - # builds). `runtime-decision` owns this logic now (matching farmslot - # preflight's fingerprint), so the skill no longer hand-rolls an mtime check. - # `clean:true` means clear the cache + restart any watch built on it. - prepare_parts+=("clean=\$(${quoted_runner} runtime-decision --adapter extension --target . --json 2>/dev/null | node -e 'let d=\"\";process.stdin.on(\"data\",c=>d+=c).on(\"end\",()=>{try{process.stdout.write(JSON.parse(d).clean?\"1\":\"0\")}catch{process.stdout.write(\"0\")}})'); if [ \"\$clean\" = \"1\" ]; then echo '[recipe-harness] runtime-decision: webpack cache stale — clearing + restarting watch'; rm -rf node_modules/.cache/webpack; if [ -f temp/runtime/recipe-harness-webpack.pid ]; then kill \"\$(cat temp/runtime/recipe-harness-webpack.pid 2>/dev/null)\" >/dev/null 2>&1 || true; rm -f temp/runtime/recipe-harness-webpack.pid; fi; fi") - # Scope watcher reuse to this checkout. A machine-global pgrep can match an - # unrelated repo and leave this target validating stale dist/chrome output. - prepare_parts+=("watch_pid_file=temp/runtime/recipe-harness-webpack.pid; watch_log=temp/runtime/recipe-harness-webpack.log; if [ -f \"\$watch_pid_file\" ]; then watch_pid=\$(cat \"\$watch_pid_file\" 2>/dev/null || true); else watch_pid=; fi; if [ -z \"\$watch_pid\" ] || ! kill -0 \"\$watch_pid\" >/dev/null 2>&1; then rm -f \"\$watch_pid_file\"; : > \"\$watch_log\"; echo '[recipe-harness] Starting yarn start; streaming temp/runtime/recipe-harness-webpack.log'; nohup env -u BUNDLED_DEBUGPY_PATH yarn start > \"\$watch_log\" 2>&1 & echo \$! > \"\$watch_pid_file\"; else echo \"[recipe-harness] Reusing existing yarn start pid \$watch_pid; streaming temp/runtime/recipe-harness-webpack.log\"; fi") - prepare_parts+=("tail -n +1 -F temp/runtime/recipe-harness-webpack.log & watch_tail_pid=\$!") - prepare_parts+=("compiled=false; for i in {1..240}; do if grep -Eq 'Module build failed|^ERROR in |compiled with [1-9][0-9]* error' temp/runtime/recipe-harness-webpack.log 2>/dev/null; then kill \"\$watch_tail_pid\" >/dev/null 2>&1 || true; wait \"\$watch_tail_pid\" 2>/dev/null || true; echo '[recipe-harness] webpack BUILD FAILED (not waiting for timeout):' >&2; grep -E -A3 'Module build failed|^ERROR in ' temp/runtime/recipe-harness-webpack.log 2>/dev/null | tail -30 >&2; echo '[recipe-harness] If it is a stale-cache ENOENT it should have been auto-cleared; a recurring error here is a real source/build issue to fix.' >&2; exit 1; fi; if grep -Eq 'compiled successfully|compiled with [0-9]+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init' temp/runtime/recipe-harness-webpack.log 2>/dev/null; then compiled=true; break; fi; sleep 2; done; kill \"\$watch_tail_pid\" >/dev/null 2>&1 || true; wait \"\$watch_tail_pid\" 2>/dev/null || true; if [ \"\$compiled\" != true ]; then echo 'Timed out waiting for target-scoped yarn start compilation marker' >&2; tail -80 temp/runtime/recipe-harness-webpack.log >&2 || true; exit 1; fi; echo '[recipe-harness] yarn start compiled successfully'; ${quoted_runner} runtime-decision --adapter extension --target . --record --json >/dev/null 2>&1 || true; echo '[recipe-harness] recorded deps/cache baseline (runtime-decision --record)'") - fi - prepare_parts+=("for i in {1..180}; do [ -f ${quoted_dist}/manifest.json ] && break; sleep 2; done") - prepare_parts+=("test -f ${quoted_dist}/manifest.json || exit 1") - prepare_parts+=("rm -rf ${quoted_runtime_dist} && mkdir -p ${quoted_runtime_dist} && rsync -a --delete --exclude _metadata ${quoted_dist}/ ${quoted_runtime_dist}/ || exit 1") - # Freshness guard: the loaded runtime-dist must match dist/chrome's git id. A - # mismatch means the rsync caught a mid-rebuild dist; abort rather than load - # an inconsistent bundle (the "Element type is invalid: undefined" class of crash). - prepare_parts+=("node -e 'const fs=require(\"fs\");const id=p=>{try{return (JSON.parse(fs.readFileSync(p,\"utf8\")).description||\"\").match(/from git id: *([0-9a-f]+)/i)?.[1]||\"\"}catch{return\"\"}};const d=id(process.argv[1]),r=id(process.argv[2]);if(d&&d!==r){console.error(\"runtime-dist git id \"+r+\" != dist \"+d+\" (mid-rebuild?); aborting\");process.exit(1)}' ${quoted_dist}/manifest.json ${quoted_runtime_dist}/manifest.json") - if [ -n "$WALLET_FIXTURE_ABS" ]; then - quoted_wallet_fixture="$(printf '%q' "$WALLET_FIXTURE_ABS")" - prepare_parts+=("node ${quoted_fixture_script} generate --target ${quoted_target} --fixture ${quoted_wallet_fixture} --out ${quoted_fixture_state}") - prepare_parts+=("node ${quoted_fixture_script} prefill-profile --target ${quoted_target} --state ${quoted_fixture_state} --profile ${quoted_profile} --extension-dir ${quoted_runtime_dist} --extension-id-file ${quoted_extension_id_file}") - fi - chrome_launch_cmd="nohup env -u BUNDLED_DEBUGPY_PATH -u PYTHONHOME -u PYTHONPATH -u DYLD_LIBRARY_PATH -u DYLD_FALLBACK_LIBRARY_PATH -u DYLD_INSERT_LIBRARIES ${quoted_chrome} --user-data-dir=${quoted_profile}" - chrome_launch_cmd+=" --remote-debugging-address=127.0.0.1 --remote-debugging-port=${CDP_PORT}" - chrome_launch_cmd+=" --no-first-run --disable-first-run-ui --disable-default-apps --disable-popup-blocking" - chrome_launch_cmd+=" --disable-extensions-file-access-check --disable-extensions-content-verification" - chrome_launch_cmd+=" --disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch" - chrome_launch_cmd+=" --disable-extensions-except=${quoted_runtime_dist}" - chrome_launch_cmd+=" --load-extension=${quoted_runtime_dist} chrome://extensions/" - chrome_launch_cmd+=" > ${quoted_chrome_log} 2>&1 & echo \$! > ${quoted_chrome_pid}" - prepare_parts+=("$chrome_launch_cmd") - prepare_parts+=("for i in {1..60}; do curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null 2>&1 && break; sleep 1; done; curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null") - if [ -n "$WALLET_FIXTURE_ABS" ]; then - prepare_parts+=("node ${quoted_fixture_script} seed-cdp --target ${quoted_target} --fixture ${quoted_wallet_fixture} --state ${quoted_fixture_state} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --extension-id-file ${quoted_extension_id_file} --out ${quoted_fixture_validation}") - fi - PREPARE_CMD="$(IFS='; '; printf '%s' "${prepare_parts[*]}")" -fi - -echo "Extension live validation command:" -display_args=(recipe-harness live --cdp-port "$CDP_PORT") -$LAUNCH_EXISTING_DIST && display_args+=(--launch-existing-dist) -$START_WATCH && display_args+=(--start-watch) -printf ' ' -printf '%q ' "${display_args[@]}" -printf '\n' -echo "Launch artifacts: $ARTIFACTS/launch" -echo "Verify artifacts: $ARTIFACTS/verify" - -launch_args=(--target "$TARGET" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/launch") -[ -n "$PREPARE_CMD" ] && launch_args+=(--prepare-cmd "$PREPARE_CMD") - -set +e -"$SCRIPT_DIR/launch.sh" "${launch_args[@]}" -launch_status=$? -set -e - -verify_status=1 -if [ "$launch_status" -eq 0 ]; then - set +e - "$SCRIPT_DIR/verify.sh" --target "$TARGET" --out "$OUT" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/verify" - verify_status=$? - set -e -else - echo "Skipping Extension live verify because launch failed; see $ARTIFACTS/launch/summary.json" >&2 -fi - -TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" CDP_PORT_FOR_SUMMARY="$CDP_PORT" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" LAUNCH_EXISTING_DIST="$LAUNCH_EXISTING_DIST" START_WATCH="$START_WATCH" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const artifacts = process.env.ARTIFACTS_FOR_SUMMARY; -const launchSummary = path.join(artifacts, 'launch', 'summary.json'); -const verifySummary = path.join(artifacts, 'verify', 'summary.json'); -const launchStatus = Number(process.env.LAUNCH_STATUS); -const verifyStatus = Number(process.env.VERIFY_STATUS); -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'extension', - action: 'live', - status: launchStatus === 0 && verifyStatus === 0 ? 'pass' : 'fail', - target: process.env.TARGET_FOR_SUMMARY, - cdpPort: process.env.CDP_PORT_FOR_SUMMARY, - launchExistingDist: process.env.LAUNCH_EXISTING_DIST === 'true', - startWatch: process.env.START_WATCH === 'true', - launch: { exitCode: launchStatus, summaryPath: fs.existsSync(launchSummary) ? launchSummary : null }, - verify: { exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null }, - easyCommand: `/scripts/recipe-harness live --cdp-port ${process.env.CDP_PORT_FOR_SUMMARY} --launch-existing-dist`, - note: 'Runs launch then live verify so a developer can validate browser startup, CDP readiness, recipe bridge, screenshots/fallback classification, and sample recipes from one skill-owned command.', - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -NODE - -echo "Extension live validation summary: $ARTIFACTS/summary.json" -[ "$launch_status" -eq 0 ] && [ "$verify_status" -eq 0 ] diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/path.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/path.sh deleted file mode 100644 index 9e3d7ac..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/path.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -# harness_root() / harness_dir() live in the shared lib so both adapters and the -# wrapper share one definition. Source it from the installed co-located copy -# ($SCRIPT_DIR/lib) else the skill-tree canonical (scripts/lib). -_harness_path_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -for _hp in "$_harness_path_dir/lib/harness-path.sh" "$_harness_path_dir/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp _harness_path_dir -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi - -resolve_harness_out() { - local target="$1" - local out="$2" - node - "$target" "$out" <<'NODE' -const path = require('path'); - -const target = path.resolve(process.argv[2]); -const out = process.argv[3]; -if (!out) process.exit(1); -if (out.split(/[\\/]+/).includes('..')) process.exit(1); - -const resolved = path.resolve(target, out); -const relative = path.relative(target, resolved); -if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { - process.exit(1); -} - -process.stdout.write(resolved); -NODE -} diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/verify.sh b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/verify.sh deleted file mode 100755 index 5bc30fe..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/verify.sh +++ /dev/null @@ -1,447 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -CDP_PORT="" -ARTIFACTS="" -STATIC_ONLY=false -OUT="" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --out) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; OUT="$2"; shift 2 ;; - --cdp-port) CDP_PORT="$2"; shift 2 ;; - --artifacts-dir) ARTIFACTS="$2"; shift 2 ;; - --static-only) STATIC_ONLY=true; shift ;; - -h|--help) echo "Usage: verify.sh [--target ] [--out ] [--cdp-port ] [--static-only]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -# Reject a non-numeric --cdp-port before it is interpolated into shell/HTTP/CDP -# strings. Empty is allowed (static-only verify needs no port). -if [ -n "$CDP_PORT" ]; then - case "$CDP_PORT" in - *[!0-9]*) echo "Invalid --cdp-port (must be numeric): $CDP_PORT" >&2; exit 2 ;; - esac -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/path.sh" -TARGET="$(cd "$TARGET" && pwd)" -HARNESS_ROOT="$(harness_root)" -HARNESS_REL="$HARNESS_ROOT/extension" -HARNESS_DIR="$(harness_dir "$TARGET" extension)" -RUNNER_BIN="$HARNESS_DIR/runner/bin/metamask-recipe" -# --out (optional): a task-local recipes dir. Resolve it safely within the target -# (resolve_harness_out rejects absolute/.. escapes) and prefer its smoke recipe so -# `live --out ` does not silently fall back to the installed default. -SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/smoke.extension.recipe.json" -if [ -n "$OUT" ]; then - # Fail fast on a task-local --out that does not contain the requested recipe. - # Silently falling back to the installed default would validate a different - # (possibly stale) recipe than the caller explicitly asked for. - OUT_ABS="$(resolve_harness_out "$TARGET" "$OUT" 2>/dev/null || true)" - if [ -z "$OUT_ABS" ]; then - echo "recipe-harness verify: --out '$OUT' did not resolve to a safe path under the target." >&2 - exit 2 - fi - if [ ! -f "$OUT_ABS/smoke.extension.recipe.json" ]; then - echo "recipe-harness verify: --out '$OUT' (resolved: $OUT_ABS) has no smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2 - exit 2 - fi - SMOKE_RECIPE="$OUT_ABS/smoke.extension.recipe.json" -fi -ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/verify/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS/logs" -EXTENSION_ID_FILE="$TARGET/temp/runtime/extension.id" -# Shared JSON reader: $SCRIPT_DIR/lib when running from the installed copy, -# else the skill-tree canonical at scripts/lib. -# shellcheck disable=SC1091 -for _lib in "$SCRIPT_DIR/lib/json-field.sh" "$SCRIPT_DIR/../../../scripts/lib/json-field.sh"; do - [ -f "$_lib" ] && { . "$_lib"; break; } -done -unset _lib -# Fail fast with an actionable message if neither path loaded (e.g. an install that -# predates the lib co-location), instead of a cryptic later `set -e` abort. -if ! command -v read_runtime_context_field >/dev/null 2>&1; then - echo "recipe-harness verify: json-field helper missing (scripts/lib/json-field.sh). Reinstall: /recipe-harness extension install --target $TARGET" >&2 - exit 1 -fi -refresh_extension_id() { - if [ -f "$EXTENSION_ID_FILE" ]; then - RECIPE_HARNESS_EXTENSION_ID="$(tr -d '[:space:]' < "$EXTENSION_ID_FILE")" - export RECIPE_HARNESS_EXTENSION_ID - else - unset RECIPE_HARNESS_EXTENSION_ID || true - fi -} -CONTEXT_PATH="${RECIPE_RUNTIME_CONTEXT:-$TARGET/temp/runtime/agentic-runtime.json}" -if [ -f "$CONTEXT_PATH" ]; then - CONTEXT_EXTENSION_ID="$(read_runtime_context_field "$CONTEXT_PATH" extensionId || true)" - if [[ "$CONTEXT_EXTENSION_ID" =~ ^[a-p]{32}$ ]]; then - mkdir -p "$(dirname "$EXTENSION_ID_FILE")" - printf '%s\n' "$CONTEXT_EXTENSION_ID" > "$EXTENSION_ID_FILE" - RECIPE_HARNESS_EXTENSION_ID="$CONTEXT_EXTENSION_ID" - export RECIPE_HARNESS_EXTENSION_ID - else - refresh_extension_id - fi - unset CONTEXT_EXTENSION_ID -else - refresh_extension_id -fi - -status="pass" -checks=() - -fixture_status_json() { - TARGET_FOR_FIXTURE="$TARGET" node <<'NODE' -const fs = require('fs'); -const crypto = require('crypto'); -const path = require('path'); -const target = process.env.TARGET_FOR_FIXTURE; -const candidates = [ - 'temp/runtime/wallet-fixture.json', - '.agent/wallet-fixture.json', - 'test/e2e/seeder/withFixtures.js', - 'test/e2e/fixtures', - 'fixtures', -].map((rel) => path.join(target, rel)); -const found = candidates.find((file) => fs.existsSync(file)); -const extensionId = path.join(target, 'temp/runtime/extension.id'); -const profileHints = []; -if (fs.existsSync(extensionId)) profileHints.push({ path: 'temp/runtime/extension.id', type: 'extension-id' }); -if (!found) { - console.log(JSON.stringify({ - status: 'MISSING_FIXTURES', - message: 'Fixture status: MISSING_FIXTURES. This run may depend on an inherited browser/profile state. Prefer a prepared debug profile or fixture seed before spending time repairing state manually.', - profileHints, - })); - process.exit(0); -} -const stat = fs.statSync(found); -const isFile = stat.isFile(); -let sha256 = null; -let validJson = null; -let hasWalletPassword = false; -let mobileAccountShape = false; -const rel = path.relative(target, found); -const isWalletFixture = rel === 'temp/runtime/wallet-fixture.json' || rel === '.agent/wallet-fixture.json'; -if (isFile) { - const bytes = fs.readFileSync(found); - sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); - if (found.endsWith('.json')) { - try { - const parsed = JSON.parse(bytes.toString('utf8')); - validJson = true; - const accounts = Array.isArray(parsed.accounts) ? parsed.accounts : []; - hasWalletPassword = typeof parsed.password === 'string' && parsed.password.length > 0; - mobileAccountShape = accounts.some((account) => account?.type === 'mnemonic') && - accounts.filter((account) => account?.type === 'privateKey').length >= 2; - } catch { - validJson = false; - } - } -} -const status = validJson === false - ? 'STALE_OR_INVALID' - : isWalletFixture && hasWalletPassword - ? 'READY' - : 'PROFILE_HINTS'; -console.log(JSON.stringify({ - status, - path: rel, - type: isFile ? 'file' : 'directory', - sha256, - modifiedAt: stat.mtime.toISOString(), - profileHints, - hasWalletPassword, - mobileAccountShape, - message: validJson === false - ? `Fixture status: STALE_OR_INVALID (${rel}). Fix before relying on a clean sandbox.` - : status === 'READY' - ? `Fixture status: READY (${rel}).` - : `Fixture status: PROFILE_HINTS (${rel}); no Mobile-shaped wallet fixture was found for automatic account parity validation.`, -})); -NODE -} - -cdp_holder_json() { - local port="$1" - PORT_FOR_STATUS="$port" node <<'NODE' -const cp = require('child_process'); -const http = require('http'); -const port = process.env.PORT_FOR_STATUS; -function run(cmd) { - try { return cp.execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } - catch { return ''; } -} -function getJson(path) { - return new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${port}${path}`, { timeout: 1000 }, (res) => { - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => { - try { resolve(JSON.parse(data)); } catch { resolve(null); } - }); - }); - req.on('timeout', () => { req.destroy(); resolve(null); }); - req.on('error', () => resolve(null)); - }); -} -(async () => { - const pid = run(`lsof -iTCP:${port} -sTCP:LISTEN -t | head -1`); - // Validate pid is numeric before interpolating it into the `ps -p` shell string. - const safePid = /^[0-9]+$/.test(pid) ? pid : ''; - const command = safePid ? run(`ps -p ${safePid} -o command=`) : ''; - const version = await getJson('/json/version'); - const targets = await getJson('/json/list'); - const extensionTargets = Array.isArray(targets) - ? targets.filter((target) => String(target.url || '').startsWith('chrome-extension://')).length - : 0; - console.log(JSON.stringify({ - port, - listening: Boolean(pid), - pid: pid || null, - command: command || null, - cdpReachable: Boolean(version), - browser: version?.Browser || null, - targetCount: Array.isArray(targets) ? targets.length : null, - extensionTargets, - })); -})(); -NODE -} - -check_file() { - local rel="$1" - if [ -e "$TARGET/$rel" ]; then - checks+=("{\"name\":\"$rel\",\"status\":\"pass\"}") - else - checks+=("{\"name\":\"$rel\",\"status\":\"fail\"}") - status="fail" - fi -} - -check_file "$HARNESS_REL/manifest.json" -check_file "$HARNESS_REL/action-manifest.json" -check_file "$HARNESS_REL/runner/bin/metamask-recipe" -check_file "$HARNESS_REL/runner/manifests/extension.action-manifest.json" - -# dist-freshness + build-health now come from the runner's single source of -# truth (`runtime-decision`), which subsumes the probes this skill used to -# hand-roll — so the dist-id/source-dirty + webpack-log logic lives in ONE place -# (matching farmslot preflight's algorithm) and the three layers cannot disagree. -# git/fs only (no --cdp-port) so this stays harness-independent. The derived -# dist-freshness.json / build-health.json keep the same status vocab the case -# mappings + summary below already consume, so behavior is unchanged. -"$RUNNER_BIN" runtime-decision --adapter extension --target "$TARGET" --json \ - > "$ARTIFACTS/logs/runtime-decision.json" 2>/dev/null \ - || printf '%s' '{}' > "$ARTIFACTS/logs/runtime-decision.json" -# Surface an empty/{} decision loudly: without it, dist-freshness/build-health -# silently degrade to WARN and the runtime-proof gate is effectively bypassed. -if [ ! -s "$ARTIFACTS/logs/runtime-decision.json" ] || [ "$(cat "$ARTIFACTS/logs/runtime-decision.json")" = "{}" ]; then - echo "recipe-harness verify: runtime-decision returned empty/{} — dist-freshness and build-health cannot be derived from the runner and will degrade to WARN (runtime-proof gate NOT fully validated). Check the runner/build state." >&2 -fi -node -e ' -const fs = require("fs"); -const dir = process.argv[1]; -let r = {}; -try { r = JSON.parse(fs.readFileSync(dir + "/runtime-decision.json", "utf8")); } catch {} -const c = r.checks || {}; -const dist = c.dist || { status: "unknown" }; -const distMsg = dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source." - : dist.status === "stale" ? (dist.reason === "uncommitted-source" - ? ((dist.modified ? dist.modified.length : "some") + " uncommitted source file(s); rebuild or commit.") - : ("dist id " + (dist.distGitId || "?") + " != HEAD " + (dist.head || "?") + "; rebuild.")) - : dist.status === "no-build" ? "no dist/chrome build." - : "no git id in dist or not a git checkout; cannot prove parity."; -fs.writeFileSync(dir + "/dist-freshness.json", JSON.stringify({ ...dist, message: distMsg })); -const bl = c.buildLog || { status: "unknown" }; -const blMsg = bl.status === "ok" ? "webpack compiled." - : bl.status === "no-watch" ? "no webpack watch log; build-health n/a (e.g. one-shot build)." - : bl.status === "building" ? "webpack has not reported a successful compile yet." - : bl.status === "errors" ? (bl.reason === "stale-cache" - ? "webpack build failing on a stale cache (ENOENT on a deduped module). Run `recipe-harness extension live --cdp-port --start-watch` to auto-clear the cache and rebuild." - : "webpack build has errors; fix the source/build before validating.") - : "build-health unknown."; -fs.writeFileSync(dir + "/build-health.json", JSON.stringify({ status: bl.status, reason: bl.reason, excerpt: bl.excerpt, message: blMsg })); -' "$ARTIFACTS/logs" 2>/dev/null \ - || { printf '%s' '{"status":"unknown","message":"dist-freshness probe error"}' > "$ARTIFACTS/logs/dist-freshness.json"; printf '%s' '{"status":"unknown","message":"build-health probe error"}' > "$ARTIFACTS/logs/build-health.json"; } -df_read() { node -e 'const fs=require("fs");try{const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v[process.argv[2]]??""))}catch{process.stdout.write("")}' "$ARTIFACTS/logs/dist-freshness.json" "$1"; } -df_status="$(df_read status)" -echo "dist-freshness: ${df_status:-unknown} — $(df_read message)" >&2 -case "$df_status" in - fresh) checks+=("{\"name\":\"dist-freshness\",\"status\":\"pass\"}") ;; - stale) - # A stale dist only fails a LIVE verify (runtime proof would use the wrong - # build). --static-only checks install/idempotency shape, not runtime, so - # there it is a warning — don't regress install-only verification. - if [ "$STATIC_ONLY" = false ]; then - checks+=("{\"name\":\"dist-freshness\",\"status\":\"fail\",\"detail\":\"see logs/dist-freshness.json\"}"); status="fail" - else - checks+=("{\"name\":\"dist-freshness\",\"status\":\"warn\",\"detail\":\"stale (static-only); see logs/dist-freshness.json\"}") - fi - ;; - *) checks+=("{\"name\":\"dist-freshness\",\"status\":\"warn\",\"detail\":\"${df_status:-unknown}; see logs/dist-freshness.json\"}") ;; -esac - -# build-health.json was produced by the runtime-decision derivation above (the -# runner reads the webpack watch log; same status vocab as before). -bh_read() { node -e 'const fs=require("fs");try{process.stdout.write(String(JSON.parse(fs.readFileSync(process.argv[1],"utf8"))[process.argv[2]]??""))}catch{process.stdout.write("")}' "$ARTIFACTS/logs/build-health.json" "$1"; } -bh_status="$(bh_read status)" -echo "build-health: ${bh_status:-unknown} — $(bh_read message)" >&2 -case "$bh_status" in - ok|no-watch) checks+=("{\"name\":\"build-health\",\"status\":\"pass\"}") ;; - errors) - # A broken build only fails a LIVE verify; in --static-only (install-shape only) - # it is a loud warning so install/idempotency checks aren't regressed. - if [ "$STATIC_ONLY" = false ]; then - checks+=("{\"name\":\"build-health\",\"status\":\"fail\",\"detail\":\"see logs/build-health.json\"}"); status="fail" - else - checks+=("{\"name\":\"build-health\",\"status\":\"warn\",\"detail\":\"build errors (static-only); see logs/build-health.json\"}") - fi - ;; - building) checks+=("{\"name\":\"build-health\",\"status\":\"warn\",\"detail\":\"still compiling; see logs/build-health.json\"}") ;; - *) checks+=("{\"name\":\"build-health\",\"status\":\"warn\",\"detail\":\"${bh_status:-unknown}; see logs/build-health.json\"}") ;; -esac - -live_mode="static-only" -if [ "$STATIC_ONLY" = false ]; then - fixture_status_json > "$ARTIFACTS/logs/fixture-status.json" - node -e 'const fs=require("fs"); const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); console.error(v.message || v.status);' "$ARTIFACTS/logs/fixture-status.json" - checks+=("{\"name\":\"fixture/profile status\",\"status\":\"$(node -e 'const fs=require("fs"); const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); console.log(v.status === "READY" ? "pass" : "warn");' "$ARTIFACTS/logs/fixture-status.json")\"}") - - if [ -z "$CDP_PORT" ]; then - echo "Live extension verify requires --cdp-port. Static checks may pass, but runtime proof is missing." > "$ARTIFACTS/logs/live-missing-cdp.log" - checks+=("{\"name\":\"live runtime CDP port\",\"status\":\"fail\",\"detail\":\"missing --cdp-port\"}") - status="fail" - live_mode="missing-cdp" - else - live_mode="live" - cdp_holder_json "$CDP_PORT" > "$ARTIFACTS/logs/cdp-holder.json" - if node "$SCRIPT_DIR/extension-readiness.js" --target "$TARGET" --cdp-port "$CDP_PORT" --json > "$ARTIFACTS/logs/extension-readiness.json" 2>&1; then - checks+=("{\"name\":\"live extension readiness\",\"status\":\"pass\"}") - # extension-readiness.js may repair temp/runtime/extension.id when the - # supplied Chrome profile loads a fresh extension ID. Reload it before - # running recipe smoke checks so the recipe bridge targets the live - # extension instead of a stale marker from a previous browser profile. - refresh_extension_id - else - checks+=("{\"name\":\"live extension readiness\",\"status\":\"fail\",\"detail\":\"see logs/extension-readiness.json\"}") - status="fail" - fi - - if ( - cd "$TARGET" - "$RUNNER_BIN" manifest --adapter extension --json - ) > "$ARTIFACTS/logs/runner-manifest.json" 2> "$ARTIFACTS/logs/runner-manifest.err"; then - checks+=("{\"name\":\"runner manifest\",\"status\":\"pass\"}") - else - checks+=("{\"name\":\"runner manifest\",\"status\":\"fail\",\"detail\":\"see logs/runner-manifest.err\"}") - status="fail" - fi - - if ( - cd "$TARGET" - "$RUNNER_BIN" run "$SMOKE_RECIPE" --adapter extension --project-root "$TARGET" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/runner-smoke" --json - ) > "$ARTIFACTS/logs/runner-smoke.log" 2>&1; then - checks+=("{\"name\":\"runner v1 smoke\",\"status\":\"pass\"}") - else - checks+=("{\"name\":\"runner v1 smoke\",\"status\":\"fail\",\"detail\":\"see logs/runner-smoke.log\"}") - status="fail" - fi - fi -fi - -if git -C "$TARGET" rev-parse --git-dir >/dev/null 2>&1; then - git -C "$TARGET" status --short -- . ":(exclude)$HARNESS_ROOT" ":(exclude).skills-cache" > "$ARTIFACTS/logs/product-diff-excluding-harness.log" 2>&1 || true -fi - -RECIPE_HARNESS_LIVE_MODE="$live_mode" RECIPE_HARNESS_ROOT_EXCLUDE="$HARNESS_ROOT" node - "$ARTIFACTS" "$TARGET" "$status" "${checks[@]}" <<'NODE' -const fs = require('fs'); -const path = require('path'); -const cp = require('child_process'); -const [artifacts, target, status, ...checks] = process.argv.slice(2); -const parsedChecks = checks.map((entry) => JSON.parse(entry)); -const liveMode = process.env.RECIPE_HARNESS_LIVE_MODE || 'unknown'; -let fixtureStatus = null; -let cdpHolder = null; -let readinessReport = null; -let distFreshness = null; -try { distFreshness = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/dist-freshness.json'), 'utf8')); } catch {} -try { fixtureStatus = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/fixture-status.json'), 'utf8')); } catch {} -try { cdpHolder = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/cdp-holder.json'), 'utf8')); } catch {} -try { readinessReport = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/extension-readiness.json'), 'utf8')); } catch {} -function runGit(args) { - try { - return cp.execFileSync('git', ['-C', target, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); - } catch (error) { - // Git metadata is diagnostic-only; non-git targets still produce a usable verify summary. - return null; - } -} -const harnessRootExclude = process.env.RECIPE_HARNESS_ROOT_EXCLUDE || 'temp/agentic/recipe-harness'; -const statusShort = runGit(['status', '--short', '--', '.', `:(exclude)${harnessRootExclude}`, ':(exclude).skills-cache']); -const gitStatus = { - branch: runGit(['branch', '--show-current']), - head: runGit(['rev-parse', '--short', 'HEAD']), - dirtyCount: statusShort ? statusShort.split('\n').filter(Boolean).length : 0, - dirtyPreview: statusShort ? statusShort.split('\n').filter(Boolean).slice(0, 25) : [], -}; -const readiness = parsedChecks.find((check) => check.name === 'live extension readiness'); -const extensionIdPath = path.join(target, 'temp/runtime/extension.id'); -let markerExtensionId = null; -try { - const value = fs.readFileSync(extensionIdPath, 'utf8').trim(); - if (/^[a-p]{32}$/.test(value)) markerExtensionId = value; -} catch {} -const cdpTarget = readinessReport?.cdp ? { - selectedExtensionId: readinessReport.cdp.selectedExtensionId || null, - markerExtensionId, - markerMatched: readinessReport.cdp.markerMatched ?? null, - markerRepaired: readinessReport.cdp.markerRepaired ?? null, - extensionIds: readinessReport.cdp.extensionIds || [], - targetCount: readinessReport.cdp.targetCount ?? null, - browser: readinessReport.cdp.browser || cdpHolder?.browser || null, -} : null; -const runtimeOwner = liveMode === 'static-only' - ? 'static-only' - : liveMode === 'missing-cdp' - ? 'none' - : cdpHolder?.cdpReachable - ? (readiness?.status === 'pass' ? 'compatible-external-or-harness' : 'incompatible-external-or-stale') - : 'none'; -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'extension', - status, - liveMode, - runtimeClassification: { - runtimeOwner, - recipeControllable: readiness?.status === 'pass', - startedByVerify: false, - }, - cleanupOwnership: { - mayStop: false, - reason: 'extension verify inspects the supplied CDP runtime; wrapper/preflight ownership must be recorded by the caller before stopping processes', - }, - gitStatus, - distFreshness, - runtimePolicy: { - runtimeReusePolicy: 'reuse a running harness-compatible CDP target when possible; wrapper auto-start must use a cached/watch-only prepare path unless the human explicitly permits a rebuild', - }, - fixtureStatus, - cdpHolder, - cdpTarget, - checks: parsedChecks, - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -fs.writeFileSync(path.join(artifacts, 'artifact-manifest.json'), `${JSON.stringify({ - artifacts: fs.readdirSync(artifacts).map((name) => ({ path: name })), -}, null, 2)}\n`); -NODE - -echo "Extension harness verify $status: $ARTIFACTS/summary.json" -[ "$status" = "pass" ] diff --git a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/wallet-fixture-state.cjs b/domains/agentic/skills/recipe-harness/adapters/extension/scripts/wallet-fixture-state.cjs deleted file mode 100755 index 265b675..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/extension/scripts/wallet-fixture-state.cjs +++ /dev/null @@ -1,1049 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const http = require('node:http'); -const path = require('node:path'); - -const EOA_METHODS = [ - 'personal_sign', - 'eth_sign', - 'eth_signTransaction', - 'eth_signTypedData_v1', - 'eth_signTypedData_v3', - 'eth_signTypedData_v4', -]; - -function usage() { - console.error(`Usage: - wallet-fixture-state.cjs generate --target --fixture --out - wallet-fixture-state.cjs prefill-profile --target --state --profile --extension-dir [--extension-id-file ] - wallet-fixture-state.cjs seed-cdp --target --fixture --state --cdp-port --extension-dir --extension-id-file --out `); -} - -function parseArgs(argv) { - const [command, ...rest] = argv; - const args = { command }; - for (let index = 0; index < rest.length; index += 1) { - const arg = rest[index]; - if (!arg.startsWith('--')) { - throw new Error(`Unknown positional argument: ${arg}`); - } - if (index + 1 >= rest.length) { - throw new Error(`Missing value for ${arg}`); - } - args[arg.slice(2)] = rest[index + 1]; - index += 1; - } - return args; -} - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function requireFromTarget(target, moduleName) { - return require(require.resolve(moduleName, { paths: [target] })); -} - -function normalizePrivateKey(value, label) { - const raw = String(value || '').replace(/^0x/u, '').toLowerCase(); - if (!/^[0-9a-f]{64}$/u.test(raw)) { - throw new Error(`Invalid private key for ${label}`); - } - return raw; -} - -function deterministicUuid(input) { - const bytes = crypto.createHash('sha256').update(input).digest(); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.subarray(0, 16).toString('hex'); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; -} - -function deterministicEntropyId(input) { - const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - const bytes = crypto.createHash('sha256').update(input).digest(); - let id = '01'; - for (let index = 0; id.length < 26; index += 1) { - id += alphabet[bytes[index % bytes.length] % alphabet.length]; - } - return id; -} - -function getFixtureAccounts(wallet) { - const accounts = Array.isArray(wallet.accounts) ? wallet.accounts : []; - if (accounts.length === 0) { - throw new Error('wallet fixture must include accounts[]'); - } - const supported = accounts.filter( - (account) => account && (account.type === 'mnemonic' || account.type === 'privateKey'), - ); - if (supported.length !== accounts.length) { - throw new Error('wallet fixture accounts must use type mnemonic or privateKey'); - } - if (!supported.some((account) => account.type === 'mnemonic')) { - throw new Error('wallet fixture must include at least one mnemonic account for Extension vault setup'); - } - return supported; -} - -function readMnemonicCount(account, label) { - const raw = account.count ?? account.numberOfAccounts ?? 1; - const count = Number(raw); - if (!Number.isInteger(count) || count < 1 || count > 100) { - throw new Error(`Invalid mnemonic account count for ${label}: ${raw}`); - } - return count; -} - -function readAccountNames(account, fallbackName, count) { - const names = Array.isArray(account.names) ? account.names : []; - return Array.from({ length: count }, (_unused, index) => { - const explicitName = names[index]; - if (typeof explicitName === 'string' && explicitName.trim()) { - return explicitName.trim(); - } - if (index === 0 && typeof account.name === 'string' && account.name.trim()) { - return account.name.trim(); - } - if (index === 0) { - return fallbackName; - } - return `Account ${index + 1}`; - }); -} - -async function buildKeyringEntries(target, wallet) { - const { HdKeyring } = requireFromTarget(target, '@metamask/eth-hd-keyring'); - const SimpleKeyring = requireFromTarget(target, '@metamask/eth-simple-keyring').default; - const { privateToAddress, bytesToHex } = requireFromTarget(target, '@ethereumjs/util'); - const accounts = getFixtureAccounts(wallet); - const entries = []; - - for (const [index, account] of accounts.entries()) { - const name = - typeof account.name === 'string' && account.name.trim() - ? account.name.trim() - : account.type === 'mnemonic' - ? index === 0 - ? 'Primary' - : `SRP ${index + 1}` - : `Imported ${index + 1}`; - - if (account.type === 'mnemonic') { - const mnemonic = String(account.value || '').trim(); - if (!mnemonic) { - throw new Error(`Missing mnemonic value for ${name}`); - } - const count = readMnemonicCount(account, name); - const names = readAccountNames(account, name, count); - const keyring = new HdKeyring(); - await keyring.deserialize({ mnemonic, numberOfAccounts: count }); - const addresses = await keyring.getAccounts(); - const keyringId = deterministicEntropyId(`mnemonic:${mnemonic}:${index}`); - const serializedKeyring = { - type: 'HD Key Tree', - data: await keyring.serialize(), - metadata: { id: keyringId, name: '' }, - }; - addresses.forEach((address, accountIndex) => { - entries.push({ - fixtureType: 'mnemonic', - groupIndex: accountIndex, - keyringId, - keyring: accountIndex === 0 ? serializedKeyring : null, - keyringType: serializedKeyring.type, - address, - name: names[accountIndex], - }); - }); - continue; - } - - const rawPrivateKey = normalizePrivateKey(account.value, name); - const keyring = new SimpleKeyring(); - await keyring.deserialize([rawPrivateKey]); - const [address] = await keyring.getAccounts(); - const derivedAddress = bytesToHex(privateToAddress(Buffer.from(rawPrivateKey, 'hex'))); - entries.push({ - fixtureType: 'privateKey', - keyring: { - type: 'Simple Key Pair', - data: await keyring.serialize(), - metadata: { id: deterministicEntropyId(`privateKey:${derivedAddress}:${index}`), name: '' }, - }, - address: address || derivedAddress, - name, - }); - } - - return entries; -} - -function patchAccountTracker(data, addresses) { - if (!data.AccountTracker?.accountsByChainId) { - return; - } - for (const chain of Object.values(data.AccountTracker.accountsByChainId)) { - if (!chain || typeof chain !== 'object') { - continue; - } - for (const oldAddress of Object.keys(chain)) { - delete chain[oldAddress]; - } - for (const address of addresses) { - chain[address] = { balance: '0x0' }; - } - } -} - -function patchNetworkState(data) { - const mainnetChainId = '0x1'; - const mainnetClientId = 'mainnet'; - - if (data.NetworkController) { - data.NetworkController.selectedNetworkClientId = mainnetClientId; - - const configs = data.NetworkController.networkConfigurationsByChainId || {}; - for (const chainId of Object.keys(configs)) { - if (chainId !== mainnetChainId) { - delete configs[chainId]; - } - } - - const metadata = data.NetworkController.networksMetadata || {}; - for (const clientId of Object.keys(metadata)) { - if (clientId !== mainnetClientId) { - delete metadata[clientId]; - } - } - } - - if (data.NetworkEnablementController?.enabledNetworkMap?.eip155) { - data.NetworkEnablementController.enabledNetworkMap.eip155 = { [mainnetChainId]: true }; - } - - if (Array.isArray(data.NetworkOrderController?.orderedNetworkList)) { - data.NetworkOrderController.orderedNetworkList = - data.NetworkOrderController.orderedNetworkList.filter((entry) => entry?.networkId === 'eip155:1'); - } -} - -function patchSyncState(data) { - data.UserStorageController = { - ...(data.UserStorageController || {}), - isAccountSyncingEnabled: false, - isBackupAndSyncEnabled: false, - isContactSyncingEnabled: false, - }; - - if (data.ProfileMetricsController) { - data.ProfileMetricsController.syncQueue = {}; - data.ProfileMetricsController.initialEnqueueCompleted = false; - } -} - -function resolveSelectedAccount(wallet, accountRows) { - const wanted = wallet.selectedAccount || wallet.selectedAddress || wallet.address; - if (typeof wanted !== 'string' || !wanted.trim()) { - return accountRows[0]; - } - const normalized = wanted.trim().toLowerCase(); - return ( - accountRows.find( - (account) => - (account.metadata?.name || '').toLowerCase() === normalized || - account.address.toLowerCase() === normalized, - ) || accountRows[0] - ); -} - -function accountGroupId(account) { - if (account.fixtureType === 'mnemonic') { - return `entropy:${account.keyringId}/${account.groupIndex}`; - } - return `keyring:${account.metadata.keyring.type}/${account.address}`; -} - -function patchAccountTree(data, accountRows, selected) { - const wallets = {}; - const accountGroupsMetadata = {}; - const accountWalletsMetadata = {}; - - for (const account of accountRows) { - const groupId = accountGroupId(account); - accountGroupsMetadata[groupId] = { - name: { - value: account.metadata.name, - lastUpdatedAt: account.metadata.importTime || 0, - }, - lastSelected: account.metadata.lastSelected || 0, - }; - - if (account.fixtureType === 'mnemonic') { - const walletId = `entropy:${account.keyringId}`; - wallets[walletId] ??= { - id: walletId, - type: 'entropy', - status: 'ready', - groups: {}, - metadata: { - name: 'Wallet 1', - entropy: { id: account.keyringId }, - }, - }; - wallets[walletId].groups[groupId] = { - id: groupId, - type: 'multichain-account', - accounts: [account.id], - metadata: { - name: account.metadata.name, - pinned: false, - hidden: false, - lastSelected: account.metadata.lastSelected || 0, - entropy: { groupIndex: account.groupIndex }, - }, - }; - continue; - } - - const walletId = `keyring:${account.metadata.keyring.type}`; - wallets[walletId] ??= { - id: walletId, - type: 'keyring', - status: 'ready', - groups: {}, - metadata: { - name: 'Imported accounts', - keyring: { type: account.metadata.keyring.type }, - }, - }; - wallets[walletId].groups[groupId] = { - id: groupId, - type: 'single-account', - accounts: [account.id], - metadata: { - name: account.metadata.name, - pinned: false, - hidden: false, - lastSelected: account.metadata.lastSelected || 0, - }, - }; - } - - data.AccountTreeController = { - accountGroupsMetadata, - accountTree: { wallets }, - accountWalletsMetadata, - hasAccountTreeSyncingSyncedAtLeastOnce: true, - selectedAccountGroup: accountGroupId(selected), - }; -} - -async function generate(args) { - // Validate raw arg VALUES before path.resolve — path.resolve('') returns the - // cwd, so a missing --fixture/--out would otherwise silently resolve to cwd. - if (!args.fixture || !args.out) { - throw new Error('generate requires --fixture and --out '); - } - const target = path.resolve(args.target || process.cwd()); - const fixturePath = path.resolve(args.fixture); - const outputPath = path.resolve(args.out); - const wallet = readJson(fixturePath); - if (typeof wallet.password !== 'string' || wallet.password.length === 0) { - throw new Error('wallet fixture must include password'); - } - - const defaultFixturePath = path.join(target, 'test/e2e/fixtures/default-fixture.json'); - if (!fs.existsSync(defaultFixturePath)) { - throw new Error(`default-fixture.json not found at ${defaultFixturePath}`); - } - const fixture = readJson(defaultFixturePath); - const data = fixture.data || fixture; - const browserPassworder = requireFromTarget(target, '@metamask/browser-passworder'); - let keyringEntries = await buildKeyringEntries(target, wallet); - const hasExplicitMnemonic = getFixtureAccounts(wallet).some( - (account) => account.type === 'mnemonic' && typeof account.value === 'string' && account.value.trim(), - ); - // NOTE: currently unreachable. getFixtureAccounts()/buildKeyringEntries() above - // require an explicit mnemonic and throw earlier when none is present, so - // `!hasExplicitMnemonic` is never true at this point. Retained as the intended - // future path for reusing an existing encrypted vault when a fixture supplies a - // `vault` blob instead of a mnemonic; relax the upstream mnemonic requirement - // before relying on it. - if (!hasExplicitMnemonic && typeof wallet.vault === 'string' && wallet.vault.length > 0) { - const existingKeyrings = await browserPassworder.decrypt(wallet.password, wallet.vault); - const existingHd = existingKeyrings.find((keyring) => keyring?.type === 'HD Key Tree'); - if (existingHd) { - let replacedPrimary = false; - keyringEntries = keyringEntries.map((entry) => { - if (entry.fixtureType !== 'mnemonic' || replacedPrimary) { - return entry; - } - replacedPrimary = true; - return { - ...entry, - keyring: existingHd, - address: - typeof wallet.address === 'string' && wallet.address - ? wallet.address - : entry.address, - }; - }); - } - } - data.KeyringController = { - vault: await browserPassworder.encrypt( - wallet.password, - keyringEntries.filter((entry) => entry.keyring).map((entry) => entry.keyring), - ), - }; - - if (!data.AccountsController) { - data.AccountsController = { internalAccounts: { accounts: {}, selectedAccount: null } }; - } - if (!data.AccountsController.internalAccounts) { - data.AccountsController.internalAccounts = { accounts: {}, selectedAccount: null }; - } - const internalAccounts = data.AccountsController.internalAccounts; - internalAccounts.accounts = {}; - const now = Date.now(); - const accountRows = keyringEntries.map((entry, index) => { - const id = deterministicUuid(`${entry.fixtureType}:${entry.address}:${index}`); - const row = { - id, - address: entry.address.toLowerCase(), - fixtureType: entry.fixtureType, - groupIndex: entry.groupIndex ?? 0, - keyringId: entry.keyringId || '', - metadata: { - name: entry.name, - importTime: now + index, - keyring: { type: entry.keyringType || entry.keyring.type }, - lastSelected: 0, - }, - options: - entry.fixtureType === 'mnemonic' - ? { - entropySource: entry.keyringId, - derivationPath: `m/44'/60'/0'/0/${entry.groupIndex ?? 0}`, - groupIndex: entry.groupIndex ?? 0, - entropy: { - type: 'mnemonic', - id: entry.keyringId, - derivationPath: `m/44'/60'/0'/0/${entry.groupIndex ?? 0}`, - groupIndex: entry.groupIndex ?? 0, - }, - } - : {}, - methods: EOA_METHODS, - scopes: ['eip155:0'], - type: 'eip155:eoa', - }; - internalAccounts.accounts[id] = row; - return row; - }); - const selected = resolveSelectedAccount(wallet, accountRows); - selected.metadata.lastSelected = now + accountRows.length; - internalAccounts.selectedAccount = selected.id; - patchAccountTree(data, accountRows, selected); - - data.OnboardingController = { - completedOnboarding: true, - firstTimeFlowType: 'import', - seedPhraseBackedUp: true, - }; - data.PreferencesController ??= {}; - data.PreferencesController.useExternalServices = true; - data.PreferencesController.preferences ??= {}; - data.PreferencesController.preferences.useSidePanelAsDefault = true; - if (wallet.settings?.autoLockNever) { - data.PreferencesController.autoLockTimeLimit = 0; - } - data.PerpsController ??= {}; - data.PerpsController.isFirstTimeUser = { mainnet: false, testnet: false }; - data.PerpsController.hasPlacedFirstOrder = { mainnet: true, testnet: true }; - patchAccountTracker( - data, - accountRows.map((account) => account.address), - ); - patchNetworkState(data); - patchSyncState(data); - - writeJson(outputPath, fixture); - const summary = { - status: 'READY', - accountCount: accountRows.length, - selectedAccount: { name: selected.metadata.name, address: selected.address }, - accounts: accountRows.map((account) => ({ - name: account.metadata.name, - address: account.address, - type: account.fixtureType, - keyringType: account.metadata.keyring.type, - })), - }; - writeJson(`${outputPath}.summary.json`, summary); - console.error( - `[fixture] Generated Extension fixture state: accounts=${summary.accountCount} selected=${summary.selectedAccount.name}`, - ); -} - -function httpJson(port, pathname) { - return new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1000 }, (res) => { - let body = ''; - res.on('data', (chunk) => { - body += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(body)); - } catch (_error) { - // CDP can briefly return a non-JSON error page while Chrome is still - // starting. Treat that as "not ready yet" so waitForCdp can retry. - resolve(null); - } - }); - }); - req.on('timeout', () => { - req.destroy(); - resolve(null); - }); - req.on('error', () => resolve(null)); - }); -} - -async function waitForCdp(port) { - const deadline = Date.now() + 30000; - while (Date.now() < deadline) { - const version = await httpJson(port, '/json/version'); - if (version) { - return version; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`CDP not reachable on port ${port}`); -} - -function extensionIdFromUrl(url) { - if (!String(url || '').startsWith('chrome-extension://')) { - return ''; - } - return String(url).split('/')[2] || ''; -} - -function extensionIdFromManifestKey(key) { - if (!key) { - return ''; - } - const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest(); - return [...digest.subarray(0, 16)] - .map((byte) => `${'abcdefghijklmnop'[byte >> 4]}${'abcdefghijklmnop'[byte & 0x0f]}`) - .join(''); -} - -function versionedStorageState(fixtureState) { - return fixtureState.data - ? { data: fixtureState.data, meta: { ...(fixtureState.meta || {}), storageKind: 'data' } } - : fixtureState; -} - -async function prefillProfile(args) { - // Validate raw arg VALUES before path.resolve (path.resolve('') === cwd). - if (!args.state || !args.profile || !args['extension-dir']) { - throw new Error('prefill-profile requires --state, --profile, and --extension-dir'); - } - const target = path.resolve(args.target || process.cwd()); - const statePath = path.resolve(args.state); - const profilePath = path.resolve(args.profile); - const extensionDir = path.resolve(args['extension-dir']); - const extensionIdFile = args['extension-id-file'] ? path.resolve(args['extension-id-file']) : ''; - const manifest = readJson(path.join(extensionDir, 'manifest.json')); - const candidateIds = new Set(); - const manifestId = extensionIdFromManifestKey(manifest.key); - if (manifestId) { - candidateIds.add(manifestId); - } - if (extensionIdFile && fs.existsSync(extensionIdFile)) { - const marker = fs.readFileSync(extensionIdFile, 'utf8').trim(); - if (/^[a-p]{32}$/u.test(marker)) { - candidateIds.add(marker); - } - } - if (candidateIds.size === 0) { - console.error('[fixture] No deterministic extension id available for profile prefill; CDP seeding will run after launch.'); - return; - } - const { ClassicLevel } = requireFromTarget(target, 'classic-level'); - const stateEntries = Object.entries(versionedStorageState(readJson(statePath))); - const settingsRoot = path.join(profilePath, 'Default', 'Local Extension Settings'); - for (const extensionId of candidateIds) { - const dbPath = path.join(settingsRoot, extensionId); - fs.mkdirSync(dbPath, { recursive: true }); - const db = new ClassicLevel(dbPath, { valueEncoding: 'json' }); - await db.open(); - try { - for (const [key, value] of stateEntries) { - await db.put(key, value); - } - } finally { - await db.close(); - } - console.error(`[fixture] Prefilled Extension profile storage for ${extensionId} (${stateEntries.length} keys)`); - } -} - -async function detectExtension(context, extensionDir, extensionIdFile) { - const manifest = readJson(path.join(extensionDir, 'manifest.json')); - const expectedServiceWorker = manifest.background?.service_worker || ''; - const manifestId = extensionIdFromManifestKey(manifest.key); - const rejected = new Set(); - - for (let attempt = 0; attempt < 30; attempt += 1) { - const candidates = []; - const push = (id, reason) => { - if (!id || rejected.has(id) || candidates.some((candidate) => candidate.id === id)) { - return; - } - candidates.push({ id, reason }); - }; - if (extensionIdFile && fs.existsSync(extensionIdFile)) { - push(fs.readFileSync(extensionIdFile, 'utf8').trim(), 'extension id marker'); - } - push(manifestId, 'manifest key'); - for (const worker of context.serviceWorkers()) { - const id = extensionIdFromUrl(worker.url()); - if (expectedServiceWorker && worker.url().endsWith(`/${expectedServiceWorker}`)) { - push(id, `manifest service worker ${expectedServiceWorker}`); - } else { - push(id, 'extension service worker'); - } - } - for (const page of context.pages()) { - push(extensionIdFromUrl(page.url()), `extension page ${page.url()}`); - } - - for (const candidate of candidates) { - const page = await context.newPage(); - try { - await page.goto(`chrome-extension://${candidate.id}/home.html`, { - waitUntil: 'load', - timeout: 10000, - }); - if (page.url().startsWith('chrome-error://')) { - throw new Error('candidate resolved to chrome-error page'); - } - return { extensionId: candidate.id, page }; - } catch (error) { - rejected.add(candidate.id); - await page.close().catch((closeError) => { - console.error(`[fixture] WARN: failed to close rejected extension page: ${closeError.message}`); - }); - console.error(`[fixture] Rejected extension id ${candidate.id} (${candidate.reason}): ${error.message}`); - } - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new Error('Could not detect MetaMask extension ID from CDP targets'); -} - -async function locatorVisible(locator) { - try { - return await locator.first().isVisible(); - } catch (_error) { - // During startup the Extension page can navigate between onboarding, lock, - // and home. A stale/missing locator means "not visible in this poll", not - // a fixture failure; the caller keeps polling until the deadline. - return false; - } -} - -async function waitForWalletScreen(page) { - const readySelectors = [ - '[data-testid="account-menu-icon"]', - '[data-testid="account-options-menu-button"]', - '[data-testid="account-overview__asset-tab"]', - '.wallet-overview', - '.home__container', - ]; - const unlockSelector = '[data-testid="unlock-password"]'; - const deadline = Date.now() + 45000; - while (Date.now() < deadline) { - for (const selector of readySelectors) { - if (await locatorVisible(page.locator(selector))) { - return { state: 'unlocked', selector }; - } - } - if (await locatorVisible(page.locator(unlockSelector))) { - return { state: 'locked', selector: unlockSelector }; - } - if (page.url().includes('/onboarding')) { - return { state: 'onboarding', selector: null }; - } - await page.waitForTimeout(500); - } - return { state: 'unknown', selector: null }; -} - -async function unlockIfNeeded(page, password) { - let state = await waitForWalletScreen(page); - if (state.state === 'locked') { - await page.fill('[data-testid="unlock-password"]', password); - try { - await page.locator('[data-testid="unlock-submit"]').first().click({ timeout: 15000 }); - } catch (error) { - const message = error && error.message ? error.message : String(error); - if (!message.includes('Timeout')) throw error; - const clicked = await page.evaluate(() => { - const button = document.querySelector('[data-testid="unlock-submit"]'); - if (!button) return false; - button.click(); - return true; - }); - if (!clicked) throw new Error(`Unlock submit timed out and DOM fallback could not find the button: ${message}`); - } - const deadline = Date.now() + 45000; - while (Date.now() < deadline) { - state = await waitForWalletScreen(page); - if (state.state === 'unlocked') { - break; - } - await page.waitForTimeout(750); - } - } - if (state.state !== 'unlocked') { - throw new Error(`Wallet did not reach unlocked home screen after fixture seeding (state=${state.state})`); - } - return state; -} - -async function readLiveAccounts(page) { - const raw = await page.evaluate(() => { - const metamask = (window.stateHooks?.store?.getState?.() || {}).metamask || {}; - const accts = metamask.internalAccounts || {}; - const byId = accts.accounts || {}; - const selectedId = accts.selectedAccount || null; - const groupNamesByAccountId = {}; - const wallets = metamask.accountTree?.wallets || {}; - for (const wallet of Object.values(wallets)) { - for (const group of Object.values(wallet?.groups || {})) { - for (const accountId of group?.accounts || []) { - groupNamesByAccountId[accountId] = group?.metadata?.name || ''; - } - } - } - const list = Object.keys(byId).map((id) => { - const account = byId[id] || {}; - const meta = account.metadata || {}; - return { - id, - name: meta.name || '', - groupName: groupNamesByAccountId[id] || '', - address: account.address || '', - keyringType: (meta.keyring || {}).type || '', - type: account.type || '', - }; - }); - const selected = selectedId && byId[selectedId] ? byId[selectedId] : null; - return JSON.stringify({ - selectedAccountId: selectedId, - selectedAddress: selected?.address || null, - selectedName: selected?.metadata?.name || null, - accounts: list, - }); - }); - return JSON.parse(raw); -} - -function expectedFixtureFromState(state) { - const internalAccounts = state.data?.AccountsController?.internalAccounts || {}; - const accounts = internalAccounts.accounts || {}; - const rows = Object.values(accounts).map((account) => ({ - id: account.id || '', - name: account.metadata?.name || '', - address: String(account.address || '').toLowerCase(), - keyringType: account.metadata?.keyring?.type || '', - })); - const selected = accounts[internalAccounts.selectedAccount] || rows[0] || null; - return { - accounts: rows, - selected: selected - ? { - id: selected.id || '', - name: selected.metadata?.name || selected.name || '', - address: String(selected.address || '').toLowerCase(), - } - : null, - }; -} - -function getEvmAccounts(live) { - return live.accounts.filter((account) => String(account.address || '').startsWith('0x')); -} - -function compareImportParity(live, expectedFixture) { - const evmAccounts = getEvmAccounts(live); - const missing = expectedFixture.accounts.filter( - (expectedAccount) => - !evmAccounts.some( - (actual) => - actual.address.toLowerCase() === expectedAccount.address && - actual.keyringType === expectedAccount.keyringType, - ), - ); - const unexpectedEvm = evmAccounts.filter( - (actual) => - !expectedFixture.accounts.some( - (expectedAccount) => expectedAccount.address === actual.address.toLowerCase(), - ), - ); - const selectedMatches = expectedFixture.selected - ? String(live.selectedAddress || '').toLowerCase() === expectedFixture.selected.address - : true; - return { - status: - missing.length === 0 && - unexpectedEvm.length === 0 && - evmAccounts.length === expectedFixture.accounts.length - ? 'PASS' - : 'FAIL', - expectedEvmAccountCount: expectedFixture.accounts.length, - liveEvmAccountCount: evmAccounts.length, - missing, - unexpectedEvm, - selectedExpected: expectedFixture.selected, - selectedActual: { - id: live.selectedAccountId, - name: live.selectedName, - address: live.selectedAddress, - }, - selectedMatches, - }; -} - -function compareAccountNames(live, expectedFixture) { - const evmAccounts = getEvmAccounts(live); - const mismatched = expectedFixture.accounts.filter( - (expectedAccount) => - !evmAccounts.some( - (actual) => - actual.address.toLowerCase() === expectedAccount.address && - (actual.groupName || actual.name) === expectedAccount.name, - ), - ); - return { - status: mismatched.length === 0 ? 'PASS' : 'FAIL', - mismatched, - }; -} - -async function applyAccountNames(page, expectedAccounts) { - for (const account of expectedAccounts) { - if (!account.name || !account.address.startsWith('0x')) { - continue; - } - await page.evaluate( - async ({ address, name }) => { - const metamask = window.stateHooks?.store?.getState?.()?.metamask || {}; - const normalizedAddress = String(address || '').toLowerCase(); - const accountsById = metamask.internalAccounts?.accounts || {}; - const accountId = Object.keys(accountsById).find( - (id) => String(accountsById[id]?.address || '').toLowerCase() === normalizedAddress, - ); - let groupId = ''; - const wallets = metamask.accountTree?.wallets || {}; - for (const wallet of Object.values(wallets)) { - for (const group of Object.values(wallet?.groups || {})) { - if (Array.isArray(group?.accounts) && group.accounts.includes(accountId)) { - groupId = group.id || ''; - } - } - } - await window.stateHooks.submitRequestToBackground('setAccountLabel', [address, name]); - if (!groupId) { - throw new Error(`No account group found for fixture account ${address}`); - } - await window.stateHooks.submitRequestToBackground('setAccountGroupName', [groupId, name]); - }, - { address: account.address, name: account.name }, - ); - } -} - -async function applySelectedAccount(page, expectedSelected) { - if (!expectedSelected?.address) { - return; - } - await page.evaluate( - async ({ address }) => { - const accounts = window.stateHooks?.store?.getState?.()?.metamask?.internalAccounts?.accounts || {}; - const accountId = Object.keys(accounts).find( - (id) => String(accounts[id]?.address || '').toLowerCase() === String(address || '').toLowerCase(), - ); - if (!accountId) { - throw new Error(`Could not find live account to select for ${address}`); - } - await window.stateHooks.submitRequestToBackground('setSelectedInternalAccount', [accountId]); - }, - { address: expectedSelected.address }, - ); -} - -async function waitForFixtureSetup(page, expectedFixture) { - const deadline = Date.now() + 15000; - let live = await readLiveAccounts(page); - while (Date.now() < deadline) { - const names = compareAccountNames(live, expectedFixture); - const importParity = compareImportParity(live, expectedFixture); - if (names.status === 'PASS' && importParity.selectedMatches) { - return { live, names, importParity }; - } - await page.waitForTimeout(500); - live = await readLiveAccounts(page); - } - return { - live, - names: compareAccountNames(live, expectedFixture), - importParity: compareImportParity(live, expectedFixture), - }; -} - -async function disconnectCdpBrowser(browser) { - try { - if (typeof browser.disconnect === 'function') { - await browser.disconnect(); - } else { - await browser.close(); - } - } catch (error) { - console.error(`[fixture] WARN: failed to disconnect CDP session cleanly: ${error.message}`); - } -} - -async function seedCdp(args) { - // Validate raw arg VALUES before path.resolve (path.resolve('') === cwd). - const port = Number(args['cdp-port']); - if (!args.fixture || !args.state || !args['extension-dir'] || !port) { - throw new Error('seed-cdp requires --fixture, --state, --extension-dir, and --cdp-port'); - } - const target = path.resolve(args.target || process.cwd()); - const fixturePath = path.resolve(args.fixture); - const statePath = path.resolve(args.state); - const extensionDir = path.resolve(args['extension-dir']); - const extensionIdFile = args['extension-id-file'] ? path.resolve(args['extension-id-file']) : ''; - const outPath = path.resolve(args.out || path.join(target, 'temp/runtime/fixture-state-validation.json')); - const wallet = readJson(fixturePath); - const fixtureState = readJson(statePath); - const versionedState = versionedStorageState(fixtureState); - - await waitForCdp(port); - const playwright = requireFromTarget(target, 'playwright'); - const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${port}`); - const context = browser.contexts()[0] || (await browser.newContext()); - const { extensionId, page } = await detectExtension(context, extensionDir, extensionIdFile); - if (extensionIdFile) { - fs.mkdirSync(path.dirname(extensionIdFile), { recursive: true }); - fs.writeFileSync(extensionIdFile, `${extensionId}\n`); - } - - await page.evaluate(async (state) => { - await chrome.storage.local.set(state); - }, versionedState); - await page.goto(`chrome-extension://${extensionId}/home.html`, { - waitUntil: 'load', - timeout: 30000, - }); - await page.waitForTimeout(1500); - const screen = await unlockIfNeeded(page, wallet.password); - const expectedFixture = expectedFixtureFromState(fixtureState); - const liveBeforeSetup = await readLiveAccounts(page); - const importParityBeforeSetup = compareImportParity(liveBeforeSetup, expectedFixture); - const namesBeforeSetup = compareAccountNames(liveBeforeSetup, expectedFixture); - - // Name and selected-account calls are an explicit fixture setup phase, not - // import-parity proof. The report records account import parity before these - // calls so validation cannot pass by repairing the imported account set it - // claims to prove. The setup phase only applies user-facing labels/selection - // after the expected EVM accounts and keyring types already exist. - if (importParityBeforeSetup.status === 'PASS' && namesBeforeSetup.status !== 'PASS') { - await applyAccountNames(page, expectedFixture.accounts); - } - if (importParityBeforeSetup.status === 'PASS' && !importParityBeforeSetup.selectedMatches) { - await applySelectedAccount(page, expectedFixture.selected); - } - const setupResult = await waitForFixtureSetup(page, expectedFixture); - const finalImportParity = compareImportParity(setupResult.live, expectedFixture); - const finalNames = compareAccountNames(setupResult.live, expectedFixture); - const fixtureSetupStatus = - importParityBeforeSetup.status === 'PASS' && finalImportParity.selectedMatches && finalNames.status === 'PASS' - ? 'PASS' - : 'FAIL'; - const report = { - status: fixtureSetupStatus, - extensionId, - unlockedVia: screen.selector, - importParity: importParityBeforeSetup, - fixtureSetup: { - status: fixtureSetupStatus, - namesBeforeSetup, - namesAfterSetup: finalNames, - selectedAfterSetup: finalImportParity.selectedActual, - selectedExpected: finalImportParity.selectedExpected, - note: - 'Account-label/selection calls are setup-time fixture finalization only; account importParity is measured before these calls, and final selected account/name setup is validated separately.', - }, - expectedAccountCount: expectedFixture.accounts.length, - liveAccountCount: setupResult.live.accounts.length, - liveEvmAccountCount: getEvmAccounts(setupResult.live).length, - selected: { - name: setupResult.live.selectedName, - address: setupResult.live.selectedAddress, - }, - expectedAccounts: expectedFixture.accounts, - liveAccounts: setupResult.live.accounts.map((account) => ({ - name: account.name, - groupName: account.groupName, - address: account.address, - keyringType: account.keyringType, - type: account.type, - })), - missing: importParityBeforeSetup.missing, - unexpectedEvm: importParityBeforeSetup.unexpectedEvm, - generatedAt: new Date().toISOString(), - }; - writeJson(outPath, report); - await disconnectCdpBrowser(browser); - if (report.status !== 'PASS') { - throw new Error(`Extension fixture account parity failed; see ${outPath}`); - } - console.error( - `[fixture] CDP validated Extension wallet fixture: accounts=${report.liveAccountCount} selected=${report.selected.name || report.selected.address}`, - ); -} - -(async () => { - try { - const args = parseArgs(process.argv.slice(2)); - if (args.command === 'generate') { - await generate(args); - } else if (args.command === 'prefill-profile') { - await prefillProfile(args); - } else if (args.command === 'seed-cdp') { - await seedCdp(args); - } else { - usage(); - process.exit(2); - } - } catch (error) { - console.error(`FAIL: ${error.message || error}`); - process.exit(1); - } -})(); diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.test.tsx.patch b/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.test.tsx.patch deleted file mode 100644 index 7e7a7de..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.test.tsx.patch +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react'; -import { render, act } from '@testing-library/react-native'; -import AgentStepHud from './AgentStepHud'; -import { registerStepHudCallback } from './AgenticService'; - -jest.mock('./AgenticService', () => ({ - registerStepHudCallback: jest.fn(), -})); - -const mockRegister = jest.mocked(registerStepHudCallback); - -type StepCallback = ( - step: { - id: string; - intent: string; - status?: string; - progress?: { current?: number; total?: number }; - detail?: string; - error?: string; - } | null, -) => void; - -function getLatestCallback(): StepCallback { - const calls = mockRegister.mock.calls; - for (let i = calls.length - 1; i >= 0; i--) { - if (typeof calls[i][0] === 'function') return calls[i][0] as StepCallback; - } - throw new Error('No callback registered'); -} - -describe('AgentStepHud', () => { - const originalDev = (globalThis as unknown as { __DEV__: boolean }).__DEV__; - - beforeEach(() => { - jest.clearAllMocks(); - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = true; - }); - - afterAll(() => { - (globalThis as unknown as { __DEV__: boolean }).__DEV__ = originalDev; - }); - - it('renders nothing when no step is active', () => { - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - - it('registers callback on mount and deregisters on unmount', () => { - const { unmount } = render(); - - expect(mockRegister).toHaveBeenCalledWith(expect.any(Function)); - - unmount(); - - expect(mockRegister).toHaveBeenCalledWith(null); - }); - - it('displays status, progress, and intent when callback fires', () => { - const { getByText } = render(); - const callback = getLatestCallback(); - - act(() => { - callback({ - id: 'validate/open-market', - status: 'running', - progress: { current: 2, total: 10 }, - intent: 'Open BTC position', - }); - }); - - expect(getByText('RUN 2/10')).toBeOnTheScreen(); - expect(getByText(/Open BTC position/)).toBeOnTheScreen(); - }); - - it('renders failed status in red instead of success green', () => { - const { getByText } = render(); - const callback = getLatestCallback(); - - act(() => { - callback({ - id: 'validate/close', - status: 'fail', - intent: 'Close failed', - }); - }); - - expect(getByText('FAIL')).toHaveStyle({ color: '#FF4D4F' }); - }); - - it('shows one intent line and hides node metadata', () => { - const { getAllByText, queryByText } = render(); - const callback = getLatestCallback(); - - act(() => { - callback({ - id: 'run 1/2', - intent: 'Prepare clean state', - }); - }); - - expect(getAllByText(/Prepare clean state/)).toHaveLength(1); - expect(queryByText('run 1/2')).toBeNull(); - }); - - it('shows error and explicit detail lines only', () => { - const { getByText } = render(); - const callback = getLatestCallback(); - - act(() => { - callback({ - id: 'fail 1/2', - intent: 'Complete the validation checkpoint', - detail: 'Prepare scenario', - error: 'Timed out waiting for checkpoint', - }); - }); - - expect(getByText(/Complete the validation checkpoint/)).toBeOnTheScreen(); - expect(getByText('Prepare scenario')).toBeOnTheScreen(); - expect( - getByText('error: Timed out waiting for checkpoint'), - ).toBeOnTheScreen(); - }); - - it('hides overlay when callback fires with null', () => { - const { toJSON } = render(); - const callback = getLatestCallback(); - - act(() => { - callback({ id: 'step-1', intent: 'test' }); - }); - - act(() => { - callback(null); - }); - - expect(toJSON()).toBeNull(); - }); -}); diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.tsx.patch b/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.tsx.patch deleted file mode 100644 index 164c5f6..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgentStepHud.tsx.patch +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { registerStepHudCallback } from './AgenticService'; - -interface Step { - id: string; - intent: string; - status?: string; - progress?: { current?: number; total?: number }; - detail?: string; - error?: string; - nodeId?: string; - debug?: { nodeId?: string; proofTarget?: unknown }; -} - -function statusForStep(step: Step) { - return String(step.status ?? step.id.split(/\s+/)[0] ?? '').toLowerCase(); -} - -function progressForStep(step: Step) { - if ( - typeof step.progress?.current === 'number' && - typeof step.progress?.total === 'number' - ) { - return `${step.progress.current}/${step.progress.total}`; - } - const progressPattern = /\b\d+\s*\/\s*\d+\b/; - const match = progressPattern.exec(step.id); - return match ? match[0].replace(/\s+/g, '') : null; -} - -function badgeTextForStep(step: Step) { - const rawStatus = statusForStep(step); - const status = rawStatus === 'running' ? 'run' : rawStatus; - const progress = progressForStep(step); - return [status || 'run', progress].filter(Boolean).join(' ').toUpperCase(); -} - -function statusToneForStep(step: Step) { - const status = statusForStep(step); - if (status === 'fail' || status === 'failed' || status === 'error') { - return 'fail'; - } - if (status === 'pass' || status === 'passed' || status === 'success') { - return 'pass'; - } - return 'running'; -} - -function displayStateForStep(step: Step) { - const intent = step.intent.trim(); - const secondary = [ - step.error ? `error: ${step.error}` : null, - step.detail && step.detail !== intent ? step.detail : null, - ].filter((part): part is string => Boolean(part)); - return { - intent, - secondary: secondary.filter( - (part, index) => secondary.indexOf(part) === index, - ), - }; -} - -// Debug-only overlay — intentionally uses hardcoded colors for guaranteed -// contrast on both light and dark themes. Design tokens would defeat the purpose. -/* eslint-disable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ -const styles = StyleSheet.create({ - container: { - position: 'absolute', - left: 0, - right: 0, - zIndex: 9999, - backgroundColor: 'rgba(0, 0, 0, 0.58)', - paddingVertical: 3, - }, - line: { - color: '#FFFFFF', - fontSize: 11, - fontWeight: '700', - lineHeight: 14, - }, - badgeText: { - fontFamily: 'Courier', - fontSize: 9, - fontWeight: '800', - }, - badgeTextRunning: { - color: '#00FF88', - }, - badgeTextPass: { - color: '#00FF88', - }, - badgeTextFail: { - color: '#FF4D4F', - }, - secondary: { - color: '#E6E6E6', - fontSize: 10, - fontWeight: '400', - lineHeight: 12, - }, -}); -/* eslint-enable react-native/no-color-literals, @metamask/design-tokens/color-no-hex */ - -// Inner component — hooks always called unconditionally, per rules of React. -const AgentStepHudInner = () => { - const [step, setStep] = useState(null); - const insets = useSafeAreaInsets(); - - const containerStyle = useMemo( - () => [ - styles.container, - { - bottom: Math.max(insets.bottom, 0), - paddingLeft: Math.max(insets.left, 10), - paddingRight: Math.max(insets.right, 10), - }, - ], - [insets.left, insets.right, insets.bottom], - ); - - useEffect(() => { - registerStepHudCallback(setStep); - return () => { - registerStepHudCallback(null); - }; - }, []); - - if (!step) return null; - - const { intent, secondary } = displayStateForStep(step); - const badge = badgeTextForStep(step); - const tone = statusToneForStep(step); - const badgeTextStyle = - tone === 'fail' - ? styles.badgeTextFail - : tone === 'pass' - ? styles.badgeTextPass - : styles.badgeTextRunning; - - return ( - - - {badge} - {intent ? ` ${intent}` : ''} - - {secondary.map((detail) => ( - - {detail} - - ))} - - ); -}; - -// Outer guard — never calls hooks, so the __DEV__ early return is fine. -const AgentStepHud = () => { - if (!__DEV__) return null; - return ; -}; - -export default AgentStepHud; diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.test.ts.patch b/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.test.ts.patch deleted file mode 100644 index 04bb047..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.test.ts.patch +++ /dev/null @@ -1,981 +0,0 @@ -import AgenticService, { - walkFiber, - findFiberByTestId, - walkFiberRoots, - tryScroll, - toAccountSummary, - registerStepHudCallback, - getFixtureMnemonicCount, - getFixtureAccountNames, - type FiberNode, - type ReactDevToolsHook, -} from './AgenticService'; -import Engine from '../Engine'; -import { Platform } from 'react-native'; -import type { - NavigationContainerRef, - ParamListBase, -} from '@react-navigation/native'; - -const mockCreateWallet = jest.fn().mockResolvedValue(undefined); -const mockImportAccount = jest.fn().mockResolvedValue(undefined); - -jest.mock('../Engine', () => ({ - context: { - AccountsController: { - listAccounts: jest.fn(() => []), - getSelectedAccount: jest.fn(() => ({ - id: 'acc-1', - address: '0xabc', - metadata: { name: 'Account 1' }, - })), - state: { - internalAccounts: { - accounts: { - a1: { - id: 'a1', - address: '0xABC', - metadata: { name: 'Account 1' }, - }, - }, - }, - }, - }, - AccountTreeController: { - state: { accountTree: { wallets: {} } }, - setAccountGroupName: jest.fn(), - }, - MultichainAccountService: { - createMultichainAccountWallet: (...args: unknown[]) => - mockCreateWallet(...args), - init: jest.fn().mockResolvedValue(undefined), - }, - KeyringController: { - importAccountWithStrategy: (...args: unknown[]) => - mockImportAccount(...(args as [string, string[]])), - }, - PerpsController: { - markTutorialCompleted: jest.fn(), - getPositions: jest.fn().mockResolvedValue([]), - }, - }, - setSelectedAddress: jest.fn(), - setAccountLabel: jest.fn(), -})); - -// AgenticService imports the Engine *class* (for the disableAutomaticVaultBackup -// static) separately from the ../Engine facade. Stub it so the test does not -// pull in the full Engine/RewardsController/SecureKeychain stack. -jest.mock('../Engine/Engine', () => ({ - Engine: class { - static disableAutomaticVaultBackup = false; - }, -})); - -const mockEnsureConnected = jest.fn().mockResolvedValue(undefined); -const mockClearAllChannels = jest.fn(); - -jest.mock('../../components/UI/Perps/services/PerpsConnectionManager', () => ({ - __esModule: true, - default: { - ensureConnected: (...args: unknown[]) => mockEnsureConnected(...args), - }, -})); - -jest.mock('../../components/UI/Perps/providers/PerpsStreamManager', () => ({ - getStreamManagerInstance: () => ({ - clearAllChannels: (...args: unknown[]) => mockClearAllChannels(...args), - }), -})); - -// Authentication pulls in the full auth/keychain stack; stub the singleton. -jest.mock('../Authentication', () => ({ - __esModule: true, - default: { - unlockWallet: jest.fn().mockResolvedValue(undefined), - }, -})); - -// addNewHdAccount/importNewSecretRecoveryPhrase pull in a sentry/selector chain -// that cannot load in the unit-test env; stub them directly. -const mockAddNewHdAccount = jest.fn().mockResolvedValue(undefined); -const mockImportNewSecretRecoveryPhrase = jest - .fn() - .mockResolvedValue(undefined); -jest.mock('../../actions/multiSrp', () => ({ - addNewHdAccount: (...args: unknown[]) => mockAddNewHdAccount(...args), - importNewSecretRecoveryPhrase: (...args: unknown[]) => - mockImportNewSecretRecoveryPhrase(...args), -})); - -const mockDispatch = jest.fn(); -jest.mock('../redux', () => ({ - store: { dispatch: (...args: unknown[]) => mockDispatch(...args) }, -})); - -jest.mock('../../store', () => ({ persistor: {} })); -jest.mock('../../actions/user', () => ({ - passwordSet: () => ({ type: 'PASSWORD_SET' }), - setExistingUser: () => ({ type: 'SET_EXISTING_USER' }), - logIn: () => ({ type: 'LOG_IN' }), - seedphraseBackedUp: () => ({ type: 'SEED_PHRASE_BACKED_UP' }), - setMultichainAccountsIntroModalSeen: (seen: boolean) => ({ - type: 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN', - payload: { seen }, - }), -})); -jest.mock('../../actions/onboarding', () => ({ - setCompletedOnboarding: () => ({ type: 'SET_COMPLETED_ONBOARDING' }), -})); -jest.mock('../../actions/security', () => ({ - setDataCollectionForMarketing: () => ({ - type: 'SET_DATA_COLLECTION_FOR_MARKETING', - }), - setOsAuthEnabled: (enabled: boolean) => ({ - type: 'SET_OS_AUTH_ENABLED', - enabled, - }), -})); -jest.mock('../../actions/settings', () => ({ - setLockTime: (lockTime: number) => ({ type: 'SET_LOCK_TIME', lockTime }), -})); -jest.mock('@metamask/key-tree', () => ({ - mnemonicPhraseToBytes: jest.fn((s: string) => new Uint8Array(s.length)), -})); -jest.mock('../../store/storage-wrapper', () => { - const storageWrapper = { - getItem: jest.fn().mockResolvedValue(null), - setItem: jest.fn().mockResolvedValue(undefined), - }; - return { - __esModule: true, - default: storageWrapper, - getItem: storageWrapper.getItem, - setItem: storageWrapper.setItem, - }; -}); -jest.mock('../../constants/storage', () => ({ - OPTIN_META_METRICS_UI_SEEN: 'optin_meta_metrics_ui_seen', - PERPS_GTM_MODAL_SHOWN: 'perps_gtm', - PREDICT_GTM_MODAL_SHOWN: 'predict_gtm', - REWARDS_GTM_MODAL_SHOWN: 'rewards_gtm', -})); -jest.mock('../../util/analytics/analytics', () => ({ - analytics: { - optOut: jest.fn().mockResolvedValue(undefined), - optIn: jest.fn().mockResolvedValue(undefined), - }, -})); -jest.mock('../../multichain-accounts/AccountTreeInitService', () => ({ - initializeAccountTree: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('../NavigationService', () => ({ - navigation: { reset: jest.fn() }, -})); -jest.mock('../../constants/navigation/Routes', () => ({ - ONBOARDING: { HOME_NAV: 'HomeNav' }, -})); -jest.mock('../SecureKeychain', () => ({ - setGenericPassword: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('../../constants/userProperties', () => ({ - __esModule: true, - default: { DEVICE_AUTHENTICATION: 'device_authentication' }, -})); -jest.mock('../SDKConnect/utils/DevLogger', () => ({ - log: jest.fn(), -})); - -const MockEngine = jest.mocked(Engine); - -// ─── Test helpers ─────────────────────────────────────────────────────────── - -function makeFiber( - overrides: Partial & { - testID?: string; - onPress?: () => void; - } = {}, -): FiberNode { - const { testID, onPress, ...rest } = overrides; - return { - child: null, - sibling: null, - return: null, - memoizedProps: testID || onPress ? { testID, onPress } : null, - stateNode: null, - ...rest, - }; -} - -function bridge() { - const b = globalThis.__AGENTIC__; - if (!b) throw new Error('__AGENTIC__ not installed'); - return b; -} - -function installFiberHook(rootFiber: FiberNode) { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - renderers: new Map([[1, {}]]), - getFiberRoots: () => new Set([{ current: rootFiber }]), - }; -} - -// ─── Fiber tree helper tests ──────────────────────────────────────────────── - -describe('walkFiber', () => { - it('returns false for null fiber', () => { - expect(walkFiber(null, () => true)).toBe(false); - }); - - it('calls visitor on root and returns true when visitor matches', () => { - const fiber = makeFiber({ testID: 'a' }); - const result = walkFiber(fiber, (f) => f.memoizedProps?.testID === 'a'); - expect(result).toBe(true); - }); - - it('walks child nodes depth-first', () => { - const child = makeFiber({ testID: 'target' }); - const root = makeFiber({ child }); - const result = walkFiber(root, (f) => f.memoizedProps?.testID === 'target'); - expect(result).toBe(true); - }); - - it('walks sibling nodes', () => { - const sibling = makeFiber({ testID: 'target' }); - const child = makeFiber({ sibling }); - const root = makeFiber({ child }); - const result = walkFiber(root, (f) => f.memoizedProps?.testID === 'target'); - expect(result).toBe(true); - }); - - it('returns false when no node matches', () => { - const root = makeFiber({ child: makeFiber({ testID: 'other' }) }); - const result = walkFiber(root, (f) => f.memoizedProps?.testID === 'nope'); - expect(result).toBe(false); - }); -}); - -describe('findFiberByTestId', () => { - it('returns null for null fiber', () => { - expect(findFiberByTestId(null, 'any')).toBeNull(); - }); - - it('finds a fiber by testID', () => { - const target = makeFiber({ testID: 'btn' }); - const root = makeFiber({ child: target }); - expect(findFiberByTestId(root, 'btn')).toBe(target); - }); - - it('returns null when testID not found', () => { - const root = makeFiber({ child: makeFiber({ testID: 'other' }) }); - expect(findFiberByTestId(root, 'missing')).toBeNull(); - }); -}); - -describe('walkFiberRoots', () => { - let savedHook: ReactDevToolsHook | undefined; - - beforeEach(() => { - savedHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; - }); - - afterEach(() => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = savedHook; - }); - - it('returns false when hook is not installed', () => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = undefined; - expect(walkFiberRoots(() => true)).toBe(false); - }); - - it('returns false when renderers is empty', () => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - renderers: new Map(), - }; - expect(walkFiberRoots(() => true)).toBe(false); - }); - - it('calls visitor with root fiber and returns true on match', () => { - const rootFiber = makeFiber({ testID: 'root' }); - const fiberRoots = new Set([{ current: rootFiber }]); - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - renderers: new Map([[1, {}]]), - getFiberRoots: () => fiberRoots, - }; - const visitor = jest.fn(() => true); - expect(walkFiberRoots(visitor)).toBe(true); - expect(visitor).toHaveBeenCalledWith(rootFiber); - }); - - it('skips roots with null current', () => { - const fiberRoots = new Set([{ current: null }]); - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - renderers: new Map([[1, {}]]), - getFiberRoots: () => fiberRoots, - }; - expect(walkFiberRoots(() => true)).toBe(false); - }); - - it('returns false when no getFiberRoots', () => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - renderers: new Map([[1, {}]]), - }; - expect(walkFiberRoots(() => true)).toBe(false); - }); -}); - -describe('toAccountSummary', () => { - it('maps internal account to slim shape', () => { - expect( - toAccountSummary({ - id: 'x', - address: '0x1', - metadata: { name: 'Test' }, - }), - ).toEqual({ id: 'x', address: '0x1', name: 'Test' }); - }); -}); - -describe('getFixtureMnemonicCount', () => { - it('defaults to 1 when no count is provided', () => { - expect(getFixtureMnemonicCount(undefined)).toBe(1); - expect(getFixtureMnemonicCount({})).toBe(1); - }); - - it('prefers count, falls back to numberOfAccounts', () => { - expect(getFixtureMnemonicCount({ count: 3 })).toBe(3); - expect(getFixtureMnemonicCount({ numberOfAccounts: 2 })).toBe(2); - expect(getFixtureMnemonicCount({ count: 5, numberOfAccounts: 2 })).toBe(5); - }); - - it('throws on out-of-range or non-integer counts', () => { - expect(() => getFixtureMnemonicCount({ count: 0 })).toThrow(); - expect(() => getFixtureMnemonicCount({ count: 101 })).toThrow(); - expect(() => getFixtureMnemonicCount({ count: 1.5 })).toThrow(); - }); -}); - -describe('getFixtureAccountNames', () => { - it('uses explicit names by index when present', () => { - expect(getFixtureAccountNames({ names: ['One', 'Two'] }, 2)).toEqual([ - 'One', - 'Two', - ]); - }); - - it('uses name only for the first account', () => { - expect(getFixtureAccountNames({ name: 'Primary' }, 2)).toEqual([ - 'Primary', - 'Account 2', - ]); - }); - - it('falls back to Account N when nothing is provided', () => { - expect(getFixtureAccountNames(undefined, 3)).toEqual([ - 'Account 1', - 'Account 2', - 'Account 3', - ]); - }); -}); - -describe('tryScroll', () => { - it('returns false for null start', () => { - expect(tryScroll(null, 100, false)).toBe(false); - }); - - it('scrolls via scrollTo on stateNode', () => { - const scrollTo = jest.fn(); - const fiber = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - expect(tryScroll(fiber, 200, true)).toBe(true); - expect(scrollTo).toHaveBeenCalledWith({ y: 200, animated: true }); - }); - - it('scrolls via scrollToOffset on stateNode', () => { - const scrollToOffset = jest.fn(); - const fiber = makeFiber({ - stateNode: { scrollToOffset } as FiberNode['stateNode'], - }); - expect(tryScroll(fiber, 400, false)).toBe(true); - expect(scrollToOffset).toHaveBeenCalledWith({ - offset: 400, - animated: false, - }); - }); - - it('walks child to find scrollable', () => { - const scrollTo = jest.fn(); - const child = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - const root = makeFiber({ child }); - expect(tryScroll(root, 100, false)).toBe(true); - }); - - it('skips siblings when walkSiblings is false', () => { - const scrollTo = jest.fn(); - const sibling = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - const root = makeFiber({ sibling }); - expect(tryScroll(root, 100, false, false)).toBe(false); - expect(scrollTo).not.toHaveBeenCalled(); - }); - - it('walks siblings when walkSiblings is true', () => { - const scrollTo = jest.fn(); - const sibling = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - const root = makeFiber({ sibling }); - expect(tryScroll(root, 100, false, true)).toBe(true); - expect(scrollTo).toHaveBeenCalled(); - }); -}); - -// ─── AgenticService.install / __AGENTIC__ bridge tests ────────────────────── - -describe('AgenticService.install', () => { - let mockNavRef: NavigationContainerRef; - let mockDeferredNav: NavigationContainerRef; - let savedHook: ReactDevToolsHook | undefined; - - beforeEach(() => { - jest.clearAllMocks(); - - mockNavRef = { - navigate: jest.fn(), - reset: jest.fn(), - goBack: jest.fn(), - dispatch: jest.fn(), - getCurrentRoute: jest.fn(() => ({ name: 'Wallet', key: 'w-1' })), - getState: jest.fn(() => ({})), - canGoBack: jest.fn(() => true), - } as unknown as NavigationContainerRef; - - mockDeferredNav = { - navigate: jest.fn(), - goBack: jest.fn(), - } as unknown as NavigationContainerRef; - - savedHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; - - AgenticService.install(mockNavRef, mockDeferredNav); - }); - - afterEach(() => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = savedHook; - globalThis.__AGENTIC__ = undefined; - }); - - it('installs __AGENTIC__ on globalThis', () => { - expect(globalThis.__AGENTIC__).toBeDefined(); - expect(bridge().platform).toBeDefined(); - }); - - it('navigate delegates to deferred navigation', () => { - bridge().navigate('Settings', { key: 'val' }); - expect(mockDeferredNav.navigate).toHaveBeenCalled(); - }); - - it('getRoute returns current route', () => { - const route = bridge().getRoute(); - expect(route).toEqual({ name: 'Wallet', key: 'w-1' }); - }); - - it('getState returns navigation state', () => { - expect(bridge().getState()).toEqual({}); - }); - - it('canGoBack returns boolean', () => { - expect(bridge().canGoBack()).toBe(true); - }); - - it('goBack delegates to deferred navigation', () => { - bridge().goBack(); - expect(mockDeferredNav.goBack).toHaveBeenCalled(); - }); - - it('refreshPerpsStreams reconnects streams and reports position count', async () => { - mockEnsureConnected.mockClear(); - mockClearAllChannels.mockClear(); - ( - MockEngine.context.PerpsController.getPositions as jest.Mock - ).mockResolvedValue([{ coin: 'ETH' }, { coin: 'BTC' }]); - - await expect(bridge().refreshPerpsStreams()).resolves.toEqual({ - ok: true, - positions: 2, - }); - expect(mockEnsureConnected).toHaveBeenCalledWith({ - source: 'agentic_refresh_perps_streams', - suppressError: true, - }); - expect(mockClearAllChannels).toHaveBeenCalledTimes(1); - }); - - it('listAccounts returns mapped accounts', () => { - ( - MockEngine.context.AccountsController.listAccounts as jest.Mock - ).mockReturnValue([ - { id: '1', address: '0xabc', metadata: { name: 'Acc1' } }, - ]); - const result = bridge().listAccounts(); - expect(result).toEqual([{ id: '1', address: '0xabc', name: 'Acc1' }]); - }); - - it('getSelectedAccount returns current account', () => { - const result = bridge().getSelectedAccount(); - expect(result).toEqual({ - id: 'acc-1', - address: '0xabc', - name: 'Account 1', - }); - }); - - it('switchAccount switches to matching address', () => { - ( - MockEngine.context.AccountsController.listAccounts as jest.Mock - ).mockReturnValue([ - { id: '1', address: '0xABC', metadata: { name: 'Acc1' } }, - ]); - const result = bridge().switchAccount('0xabc'); - expect(result.switched).toBe(true); - expect(MockEngine.setSelectedAddress).toHaveBeenCalledWith('0xABC'); - }); - - it('switchAccount throws for unknown address', () => { - ( - MockEngine.context.AccountsController.listAccounts as jest.Mock - ).mockReturnValue([]); - expect(() => bridge().switchAccount('0xfff')).toThrow('No account found'); - }); - - describe('showStep / hideStep', () => { - afterEach(() => { - registerStepHudCallback(null); - }); - - it('showStep calls registered HUD callback with step data', () => { - const callback = jest.fn(); - registerStepHudCallback(callback); - - bridge().showStep({ - id: 'run 1/2', - status: 'running', - intent: 'Navigate to market', - progress: { current: 1, total: 2 }, - }); - - expect(callback).toHaveBeenCalledWith({ - id: 'run 1/2', - status: 'running', - intent: 'Navigate to market', - progress: { current: 1, total: 2 }, - }); - }); - - it('hideStep calls registered HUD callback with null', () => { - const callback = jest.fn(); - registerStepHudCallback(callback); - - bridge().hideStep(); - - expect(callback).toHaveBeenCalledWith(null); - }); - - it('showStep is a no-op when no callback is registered', () => { - registerStepHudCallback(null); - expect(() => bridge().showStep({ id: 'x', intent: 'y' })).not.toThrow(); - }); - }); - - describe('findFiberByTestId (bridge)', () => { - it('returns true when testID exists in fiber tree', () => { - const fiber = makeFiber({ - child: makeFiber({ testID: 'target-btn' }), - }); - installFiberHook(fiber); - - expect(bridge().findFiberByTestId('target-btn')).toBe(true); - }); - - it('returns false when testID does not exist', () => { - installFiberHook(makeFiber()); - - expect(bridge().findFiberByTestId('missing-id')).toBe(false); - }); - }); - - describe('pressTestId', () => { - it('presses a component found by testID', () => { - const onPress = jest.fn(); - const fiber = makeFiber({ - child: makeFiber({ testID: 'my-btn', onPress }), - }); - installFiberHook(fiber); - - const result = bridge().pressTestId('my-btn'); - - expect(result).toEqual({ ok: true, testId: 'my-btn' }); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('returns error when testID not found', () => { - installFiberHook(makeFiber()); - - const result = bridge().pressTestId('missing'); - - expect(result.ok).toBe(false); - expect(result.error).toContain('missing'); - }); - - it('returns error when hook is not installed', () => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = undefined; - - const result = bridge().pressTestId('any'); - - expect(result.ok).toBe(false); - }); - - it('returns error when component has no onPress', () => { - const fiber = makeFiber({ - child: makeFiber({ testID: 'no-press' }), - }); - installFiberHook(fiber); - - const result = bridge().pressTestId('no-press'); - - expect(result.ok).toBe(false); - expect(result.error).toContain('no-press'); - }); - - it('handles deeply nested components', () => { - const onPress = jest.fn(); - const deep = makeFiber({ testID: 'deep', onPress }); - const mid = makeFiber({ child: deep }); - const root = makeFiber({ child: mid }); - installFiberHook(root); - - const result = bridge().pressTestId('deep'); - - expect(result).toEqual({ ok: true, testId: 'deep' }); - expect(onPress).toHaveBeenCalled(); - }); - }); - - describe('scrollView', () => { - it('scrolls a ScrollView via scrollTo', () => { - const scrollTo = jest.fn(); - const fiber = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - installFiberHook(fiber); - - const result = bridge().scrollView({ offset: 200 }); - - expect(result.ok).toBe(true); - expect(result.offset).toBe(200); - expect(scrollTo).toHaveBeenCalledWith({ y: 200, animated: false }); - }); - - it('scrolls a FlatList via scrollToOffset', () => { - const scrollToOffset = jest.fn(); - const fiber = makeFiber({ - stateNode: { scrollToOffset } as FiberNode['stateNode'], - }); - installFiberHook(fiber); - - const result = bridge().scrollView({ - offset: 500, - animated: true, - }); - - expect(result.ok).toBe(true); - expect(scrollToOffset).toHaveBeenCalledWith({ - offset: 500, - animated: true, - }); - }); - - it('scrolls near a testID anchor', () => { - const scrollTo = jest.fn(); - const scrollChild = makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }); - const anchor = makeFiber({ - testID: 'my-list', - child: scrollChild, - }); - const root = makeFiber({ child: anchor }); - installFiberHook(root); - - const result = bridge().scrollView({ - testId: 'my-list', - offset: 100, - }); - - expect(result.ok).toBe(true); - expect(scrollTo).toHaveBeenCalledWith({ y: 100, animated: false }); - }); - - it('returns error when no scrollable found', () => { - installFiberHook(makeFiber()); - - const result = bridge().scrollView(); - - expect(result.ok).toBe(false); - expect(result.error).toContain('No scrollable'); - }); - - it('returns error when testID anchor not found', () => { - installFiberHook(makeFiber()); - - const result = bridge().scrollView({ - testId: 'missing', - }); - - expect(result.ok).toBe(false); - expect(result.error).toContain('missing'); - }); - - it('uses default offset of 300', () => { - const scrollTo = jest.fn(); - installFiberHook( - makeFiber({ - stateNode: { scrollTo } as FiberNode['stateNode'], - }), - ); - - bridge().scrollView(); - - expect(scrollTo).toHaveBeenCalledWith({ y: 300, animated: false }); - }); - - it('returns error when hook is not installed', () => { - globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = undefined; - - const result = bridge().scrollView(); - - expect(result.ok).toBe(false); - }); - }); - - describe('setupWallet', () => { - beforeEach(() => { - mockCreateWallet.mockClear(); - mockImportAccount.mockClear(); - mockDispatch.mockClear(); - }); - - it('dispatches all onboarding flags', async () => { - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'PASSWORD_SET' }); - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SEED_PHRASE_BACKED_UP', - }); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'LOG_IN' }); - }); - - it('returns error on failure', async () => { - mockCreateWallet.mockRejectedValueOnce(new Error('boom')); - const result = await bridge().setupWallet({ - password: 'test123', - accounts: [{ type: 'mnemonic', value: 'words' }], - }); - expect(result.ok).toBe(false); - expect(result.error).toBe('boom'); - }); - - it('opts out of metametrics when specified', async () => { - const { analytics } = jest.requireMock('../../util/analytics/analytics'); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { metametrics: false }, - }); - expect(analytics.optOut).toHaveBeenCalled(); - }); - - it('opts in to metametrics when specified', async () => { - const { analytics } = jest.requireMock('../../util/analytics/analytics'); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { metametrics: true }, - }); - expect(analytics.optIn).toHaveBeenCalled(); - }); - - it('does not suppress GTM modals when skipGtmModals is undefined', async () => { - const StorageWrapper = jest.requireMock('../../store/storage-wrapper'); - StorageWrapper.setItem.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(StorageWrapper.setItem).not.toHaveBeenCalled(); - }); - - it('suppresses GTM modals when skipGtmModals is true', async () => { - const StorageWrapper = jest.requireMock('../../store/storage-wrapper'); - StorageWrapper.setItem.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { skipGtmModals: true }, - }); - expect(StorageWrapper.setItem).toHaveBeenCalledWith('perps_gtm', 'true'); - expect(StorageWrapper.setItem).toHaveBeenCalledWith( - 'predict_gtm', - 'true', - ); - expect(StorageWrapper.setItem).toHaveBeenCalledWith( - 'rewards_gtm', - 'true', - ); - }); - - it('calls markTutorialCompleted when skipPerpsTutorial is true', async () => { - const mockMarkTutorial = MockEngine.context.PerpsController - .markTutorialCompleted as jest.Mock; - mockMarkTutorial.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { skipPerpsTutorial: true }, - }); - expect(mockMarkTutorial).toHaveBeenCalledTimes(1); - }); - - it('does not call markTutorialCompleted when skipPerpsTutorial is undefined', async () => { - const mockMarkTutorial = MockEngine.context.PerpsController - .markTutorialCompleted as jest.Mock; - mockMarkTutorial.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(mockMarkTutorial).not.toHaveBeenCalled(); - }); - - it('dispatches setLockTime(-1) when autoLockNever is true', async () => { - mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { autoLockNever: true }, - }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_LOCK_TIME', lockTime: -1 }), - ); - }); - - it('does not dispatch setLockTime when autoLockNever is not set', async () => { - mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_LOCK_TIME' }), - ); - }); - - it('dispatches setOsAuthEnabled(true) on Android when deviceAuthEnabled is true', async () => { - mockDispatch.mockClear(); - const originalOS = Platform.OS; - Platform.OS = 'android'; - try { - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { deviceAuthEnabled: true }, - }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SET_OS_AUTH_ENABLED', - enabled: true, - }), - ); - } finally { - Platform.OS = originalOS; - } - }); - - it('does not dispatch setOsAuthEnabled on iOS even when deviceAuthEnabled is true', async () => { - mockDispatch.mockClear(); - const originalOS = Platform.OS; - Platform.OS = 'ios'; - try { - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { deviceAuthEnabled: true }, - }); - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), - ); - } finally { - Platform.OS = originalOS; - } - }); - - it('does not dispatch setOsAuthEnabled when deviceAuthEnabled is not set', async () => { - mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), - ); - }); - - it('sets OPTIN_META_METRICS_UI_SEEN when metametrics is defined', async () => { - const StorageWrapper = jest.requireMock('../../store/storage-wrapper'); - StorageWrapper.setItem.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - settings: { metametrics: true }, - }); - expect(StorageWrapper.setItem).toHaveBeenCalledWith( - 'optin_meta_metrics_ui_seen', - 'true', - ); - }); - - it('does not set OPTIN_META_METRICS_UI_SEEN when metametrics is undefined', async () => { - const StorageWrapper = jest.requireMock('../../store/storage-wrapper'); - StorageWrapper.setItem.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(StorageWrapper.setItem).not.toHaveBeenCalledWith( - 'optin_meta_metrics_ui_seen', - 'true', - ); - }); - - it('always dispatches setMultichainAccountsIntroModalSeen', async () => { - mockDispatch.mockClear(); - await bridge().setupWallet({ - password: 'test123', - accounts: [], - }); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN', - payload: { seen: true }, - }), - ); - }); - }); -}); diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.ts.patch b/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.ts.patch deleted file mode 100644 index 902a9b5..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/app-overlay/app/core/AgenticService/AgenticService.ts.patch +++ /dev/null @@ -1,1506 +0,0 @@ -/** - * AgenticService — __DEV__-only bridge for Farmslot recipe runners. - * - * This file is not bundled in production builds. It exposes a stable control - * surface for deterministic local recipes while keeping user-visible proof - * flows on the real app path. - */ -import { - NavigationContainerRef, - ParamListBase, -} from '@react-navigation/native'; -import { Dimensions, Platform } from 'react-native'; -import Logger from '../../util/Logger'; -import ReduxService from '../redux'; -import { persistor } from '../../store'; -import Engine from '../Engine'; -import { Engine as EngineClass } from '../Engine/Engine'; -import { - passwordSet, - setExistingUser, - logIn, - seedphraseBackedUp, - setMultichainAccountsIntroModalSeen, -} from '../../actions/user'; -import { setCompletedOnboarding } from '../../actions/onboarding'; -import { mnemonicPhraseToBytes } from '@metamask/key-tree'; -import { AccountImportStrategy } from '@metamask/keyring-controller'; -import type { AccountGroupId } from '@metamask/account-api'; -import StorageWrapper from '../../store/storage-wrapper'; -import { - OPTIN_META_METRICS_UI_SEEN, - PERPS_GTM_MODAL_SHOWN, - PREDICT_GTM_MODAL_SHOWN, - REWARDS_GTM_MODAL_SHOWN, -} from '../../constants/storage'; -import { analytics } from '../../util/analytics/analytics'; -import { - setDataCollectionForMarketing, - setOsAuthEnabled, -} from '../../actions/security'; -import { setLockTime } from '../../actions/settings'; -import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService'; -import NavigationService from '../NavigationService'; -import Routes from '../../constants/navigation/Routes'; -import SecureKeychain from '../SecureKeychain'; -import AUTHENTICATION_TYPE from '../../constants/userProperties'; -import DevLogger from '../SDKConnect/utils/DevLogger'; -import { importNewSecretRecoveryPhrase } from '../../actions/multiSrp'; -import { bufferToHex, privateToAddress } from 'ethereumjs-util'; -import Authentication from '../Authentication'; -import { Wallet as EthersWallet } from 'ethers'; -import PerpsConnectionManager from '../../components/UI/Perps/services/PerpsConnectionManager'; -import { getStreamManagerInstance } from '../../components/UI/Perps/providers/PerpsStreamManager'; - -// ─── Fiber tree types ────────────────────────────────────────────────────── - -/** - * Minimal React fiber node shape used to walk the component tree - * via __REACT_DEVTOOLS_GLOBAL_HOOK__. - */ -interface FiberNode { - child: FiberNode | null; - sibling: FiberNode | null; - return: FiberNode | null; - memoizedProps: { - testID?: string; - onPress?: (...args: unknown[]) => unknown; - onChangeText?: (text: string) => void; - [key: string]: unknown; - } | null; - stateNode: { - scrollTo?: (opts: { y: number; animated: boolean }) => void; - scrollToOffset?: (opts: { offset: number; animated: boolean }) => void; - measure?: ( - callback: ( - x: number, - y: number, - width: number, - height: number, - pageX: number, - pageY: number, - ) => void, - ) => void; - measureInWindow?: ( - callback: (x: number, y: number, width: number, height: number) => void, - ) => void; - [key: string]: unknown; - } | null; -} - -interface FiberRoot { - current: FiberNode | null; -} - -interface ReactDevToolsHook { - renderers: Map; - getFiberRoots?: (id: number) => Set; -} - -/** Shape of the __DEV__-only agentic bridge on globalThis. */ -interface AgenticHudStep { - id: string; - status?: string; - intent: string; - progress?: { current?: number; total?: number }; - detail?: string; - error?: string; - nodeId?: string; - debug?: { nodeId?: string; proofTarget?: unknown }; -} - -interface AgenticBridge { - platform: string; - replayHarnessPatch?: string; - navigate: (name: string, params?: object) => void; - getRoute: () => unknown; - getState: () => unknown; - canGoBack: () => boolean; - goBack: () => void; - listAccounts: () => { id: string; address: string; name: string }[]; - getSelectedAccount: () => { id: string; address: string; name: string }; - pressTestId: (testId: string) => { - ok: boolean; - testId?: string; - error?: string; - }; - pressText: ( - text: string, - options?: { requiredTexts?: string[]; maxTexts?: number }, - ) => { ok: boolean; text?: string; error?: string }; - scrollView: (options?: { - testId?: string; - offset?: number; - animated?: boolean; - }) => { - ok: boolean; - error?: string; - testId?: string; - offset?: number; - animated?: boolean; - }; - setInput: ( - testId: string, - value: string, - ) => { - ok: boolean; - testId?: string; - value?: string; - error?: string; - }; - getTextByTestId: ( - testId: string, - options?: { all?: boolean }, - ) => string | string[] | null; - getAncestorTextsByTestId: ( - testId: string, - options?: { requiredLabels?: string[]; maxTexts?: number }, - ) => string[] | null; - getRowValue: ( - label: string, - pattern: string, - options?: { - anchorTestId?: string; - requiredLabels?: string[]; - maxTexts?: number; - }, - ) => string | null; - switchAccount: (address: string) => { - switched: boolean; - id: string; - address: string; - name: string; - }; - setupWallet: (fixture: { - password: string; - accounts: { - type: 'mnemonic' | 'privateKey'; - value: string; - name?: string; - count?: number; - numberOfAccounts?: number; - names?: string[]; - }[]; - settings?: { - metametrics?: boolean; - skipGtmModals?: boolean; - skipPerpsTutorial?: boolean; - autoLockNever?: boolean; - deviceAuthEnabled?: boolean; - }; - }) => Promise<{ - ok: boolean; - error?: string; - step?: string; - accounts?: { address: string; name: string }[]; - }>; - applyWalletFixture: ( - fixture: Parameters[0], - ) => Promise<{ - ok: boolean; - error?: string; - accounts?: { address: string; name: string }[]; - }>; - showStep: (step: AgenticHudStep) => void; - hideStep: () => void; - refreshPerpsStreams: () => Promise<{ ok: boolean; positions: number }>; - findFiberByTestId: (testId: string) => boolean; - queryUiTarget: (options: { - testId?: string; - textContains?: string; - visibility?: 'tree' | 'viewport'; - }) => Promise<{ - present: boolean; - visible: boolean; - visibility: 'tree' | 'viewport'; - testId?: string; - textContains?: string; - textMatched?: boolean; - rect?: { x: number; y: number; width: number; height: number }; - viewport?: { width: number; height: number }; - error?: string; - }>; -} - -type WalletFixture = Parameters[0]; - -declare global { - // eslint-disable-next-line no-var - var __AGENTIC__: AgenticBridge | undefined; - // eslint-disable-next-line no-var - var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevToolsHook | undefined; -} - -// ─── Fiber tree helpers ───────────────────────────────────────────────────── - -/** - * Walk a fiber sub-tree depth-first, calling `visitor` on each node. - * Returns true as soon as visitor returns true (short-circuit). - * Sibling traversal is iterative to avoid stack overflow on wide trees. - */ -function walkFiber( - fiber: FiberNode | null, - visitor: (f: FiberNode) => boolean, -): boolean { - let current: FiberNode | null = fiber; - while (current) { - if (visitor(current)) return true; - if (walkFiber(current.child, visitor)) return true; - current = current.sibling; - } - return false; -} - -/** - * Find the first fiber node whose `testID` prop matches. - */ -function findFiberByTestId( - fiber: FiberNode | null, - testId: string, -): FiberNode | null { - let result: FiberNode | null = null; - walkFiber(fiber, (f) => { - if (f.memoizedProps?.testID === testId) { - result = f; - return true; - } - return false; - }); - return result; -} - -/** - * Iterate all React renderer roots and call `visitor` on each root fiber. - * Returns true if any visitor call returns true. - */ -function walkFiberRoots(visitor: (rootFiber: FiberNode) => boolean): boolean { - const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; - if (!hook?.renderers) return false; - - for (const [id] of hook.renderers) { - const fiberRoots = hook.getFiberRoots?.(id); - if (!fiberRoots) continue; - let found = false; - fiberRoots.forEach((root) => { - if (!found && root.current) { - found = visitor(root.current); - } - }); - if (found) return true; - } - return false; -} - -// ─── Shared helpers ───────────────────────────────────────────────────────── - -/** Map an internal account to the slim shape exposed by the bridge. */ -function toAccountSummary(a: { - id: string; - address: string; - metadata: { name: string }; -}): { id: string; address: string; name: string } { - return { id: a.id, address: a.address, name: a.metadata.name }; -} - -export function getFixtureMnemonicCount(account?: { - count?: number; - numberOfAccounts?: number; -}): number { - const raw = account?.count ?? account?.numberOfAccounts ?? 1; - const count = Number(raw); - if (!Number.isInteger(count) || count < 1 || count > 100) { - throw new Error(`Invalid mnemonic account count: ${raw}`); - } - return count; -} - -export function getFixtureAccountNames( - account: { name?: string; names?: string[] } | undefined, - count: number, -): string[] { - return Array.from({ length: count }, (_unused, index) => { - const explicitName = account?.names?.[index]; - if (typeof explicitName === 'string' && explicitName.trim()) { - return explicitName.trim(); - } - if ( - index === 0 && - typeof account?.name === 'string' && - account.name.trim() - ) { - return account.name.trim(); - } - return `Account ${index + 1}`; - }); -} - -interface FixtureEvmAccount { - id: string; - address: string; - metadata: { name: string; keyring?: { type?: string } }; -} - -function findEvmAccounts(accounts: Record) { - return (Object.values(accounts) as FixtureEvmAccount[]).filter((account) => - account.address?.startsWith('0x'), - ); -} - -function isHdFixtureAccount(account: FixtureEvmAccount) { - return account.metadata?.keyring?.type === 'HD Key Tree'; -} - -function normalizePrivateKey(value: string) { - return value.startsWith('0x') ? value.slice(2) : value; -} - -function getPrivateKeyAddress(value: string) { - return bufferToHex( - privateToAddress(Buffer.from(normalizePrivateKey(value), 'hex')), - ).toLowerCase(); -} - -function isExpectedLegacyAccountTreeInitError(error: unknown) { - const message = String((error as Error).message || error); - return ( - message.includes('Money Keyring') || - message.includes('No keyringBuilder found') - ); -} - -async function initializeFixtureAccountTree( - options: { - allowLegacyAccountTreeInitFailure?: boolean; - } = {}, -) { - try { - await AccountTreeInitService.initializeAccountTree(); - } catch (error) { - if ( - !options.allowLegacyAccountTreeInitFailure || - !isExpectedLegacyAccountTreeInitError(error) - ) { - throw error; - } - // Historical replay vaults can lack the multichain keyring builder. In that - // mode AccountsController remains the source of truth for fixture validation - // and account labels; group renames are skipped when no account-tree group - // exists. - Logger.log( - '[AgenticService] Skipping fixture account-tree refresh for historical replay fixture setup', - ); - } -} - -function getMnemonicFirstAddress(value: string) { - return EthersWallet.fromMnemonic( - value, - "m/44'/60'/0'/0/0", - ).address.toLowerCase(); -} - -interface FixtureHdWallet { - keyringId?: string; - accounts: FixtureEvmAccount[]; -} - -function getHdFixtureWallets( - accountsController: { - state: { internalAccounts: { accounts: Record } }; - }, - accountTreeController: { - state: { accountTree?: { wallets?: Record } }; - }, -): FixtureHdWallet[] { - const evmById = new Map( - findEvmAccounts(accountsController.state.internalAccounts.accounts).map( - (account) => [account.id, account], - ), - ); - const wallets = accountTreeController.state.accountTree?.wallets ?? {}; - const result: FixtureHdWallet[] = []; - for (const wallet of Object.values(wallets) as { - metadata?: { entropy?: { id?: string } }; - groups?: Record< - string, - { accounts?: string[]; metadata?: { entropy?: { groupIndex?: number } } } - >; - }[]) { - const entropyId = wallet.metadata?.entropy?.id; - if (!entropyId) continue; - const accounts = Object.values(wallet.groups ?? {}) - .map((group) => ({ - index: group.metadata?.entropy?.groupIndex ?? Number.MAX_SAFE_INTEGER, - account: group.accounts?.[0] - ? evmById.get(group.accounts[0]) - : undefined, - })) - .filter((entry): entry is { index: number; account: FixtureEvmAccount } => - Boolean(entry.account), - ) - .sort((left, right) => left.index - right.index) - .map((entry) => entry.account); - if (accounts.length > 0) { - result.push({ keyringId: entropyId, accounts }); - } - } - if (result.length > 0) { - return result; - } - const legacyHdAccounts = findEvmAccounts( - accountsController.state.internalAccounts.accounts, - ).filter(isHdFixtureAccount); - return legacyHdAccounts.length > 0 ? [{ accounts: legacyHdAccounts }] : []; -} - -async function ensureFixtureMnemonicAccounts( - mnemonicAccount: WalletFixture['accounts'][number], - mnemonicIndex: number, - controllers: { - AccountsController: { - state: { internalAccounts: { accounts: Record } }; - }; - AccountTreeController: { - state: { accountTree?: { wallets?: Record } }; - setAccountGroupName: ( - accountGroupId: AccountGroupId, - accountGroupName: string, - ) => void; - }; - }, - options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, -) { - const { AccountsController, AccountTreeController } = controllers; - const count = getFixtureMnemonicCount(mnemonicAccount); - const names = getFixtureAccountNames(mnemonicAccount, count); - const firstAddress = getMnemonicFirstAddress(mnemonicAccount.value); - const findWallet = () => - getHdFixtureWallets(AccountsController, AccountTreeController).find( - (hdWallet) => - hdWallet.accounts[0]?.address.toLowerCase() === firstAddress, - ); - - let wallet = findWallet(); - if (!wallet) { - await importNewSecretRecoveryPhrase(mnemonicAccount.value, { - shouldSelectAccount: false, - }); - await initializeFixtureAccountTree(options); - wallet = findWallet(); - } - - if (!wallet) { - throw new Error( - `No HD wallet found for fixture mnemonic ${mnemonicIndex + 1}`, - ); - } - - if (wallet.accounts.length < count && !wallet.keyringId) { - throw new Error( - `Cannot add fixture accounts to legacy vault (no entropy source); fixture expects ${count} accounts but vault has ${wallet.accounts.length}`, - ); - } - const { MultichainAccountService } = Engine.context; - for ( - let accountIndex = wallet.accounts.length; - accountIndex < count; - accountIndex += 1 - ) { - await MultichainAccountService.createNextMultichainAccountGroup({ - entropySource: wallet.keyringId as string, - }); - wallet = findWallet(); - if (!wallet) { - throw new Error( - `No HD wallet found after adding fixture account ${accountIndex + 1}`, - ); - } - } - - wallet.accounts.slice(0, count).forEach((account, index) => { - setFixtureAccountName(AccountTreeController, account, names[index]); - }); -} - -function findAccountGroupIdByAccountId( - accountTreeController: { - state: { accountTree?: { wallets?: Record } }; - }, - accountId: string, -): string | undefined { - const wallets = accountTreeController.state.accountTree?.wallets ?? {}; - for (const wallet of Object.values(wallets) as { - groups?: Record; - }[]) { - for (const [groupId, group] of Object.entries(wallet.groups ?? {})) { - if (group.accounts?.includes(accountId)) { - return groupId; - } - } - } - return undefined; -} - -function setFixtureAccountName( - accountTreeController: { - state: { accountTree?: { wallets?: Record } }; - setAccountGroupName: ( - accountGroupId: AccountGroupId, - accountGroupName: string, - ) => void; - }, - account: { id: string; address: string }, - name: string, -) { - Engine.setAccountLabel(account.address, name); - const groupId = findAccountGroupIdByAccountId( - accountTreeController, - account.id, - ); - // Legacy vault fallback skips account-tree init, so no group exists yet. - // The account label set above is sufficient; skip the group rename instead - // of throwing and crashing fixture setup. - if (!groupId) { - DevLogger.log( - `[AgenticService] No account group for fixture account ${account.address}; skipped group rename`, - ); - return; - } - accountTreeController.setAccountGroupName(groupId as AccountGroupId, name); -} - -async function materializeFixtureAccounts( - fixture: WalletFixture, - controllers: { - KeyringController: { - importAccountWithStrategy: ( - strategy: AccountImportStrategy, - args: string[], - ) => Promise; - }; - AccountsController: { - state: { internalAccounts: { accounts: Record } }; - }; - AccountTreeController: { - state: { accountTree?: { wallets?: Record } }; - setAccountGroupName: ( - accountGroupId: AccountGroupId, - accountGroupName: string, - ) => void; - }; - }, - options: { allowLegacyAccountTreeInitFailure?: boolean } = {}, -) { - const { KeyringController, AccountsController, AccountTreeController } = - controllers; - const mnemonicAccounts = fixture.accounts.filter( - (account) => account.type === 'mnemonic', - ); - - for (const [mnemonicIndex, mnemonicAccount] of mnemonicAccounts.entries()) { - await ensureFixtureMnemonicAccounts( - mnemonicAccount, - mnemonicIndex, - { - AccountsController, - AccountTreeController, - }, - options, - ); - } - - for (const account of fixture.accounts) { - if (account.type !== 'privateKey') continue; - const address = getPrivateKeyAddress(account.value); - let imported = findEvmAccounts( - AccountsController.state.internalAccounts.accounts, - ).find((evmAccount) => evmAccount.address.toLowerCase() === address); - - if (!imported) { - await KeyringController.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [`0x${normalizePrivateKey(account.value)}`], - ); - imported = findEvmAccounts( - AccountsController.state.internalAccounts.accounts, - ).find((evmAccount) => evmAccount.address.toLowerCase() === address); - } - - if (!imported) { - throw new Error( - `Fixture private key import did not create account ${address}`, - ); - } - if (account.name) { - setFixtureAccountName(AccountTreeController, imported, account.name); - } - } -} - -/** - * Walk a fiber sub-tree looking for a scrollable stateNode (scrollTo or - * scrollToOffset). When `walkSiblings` is false only the child axis is - * traversed — useful when starting from a testId anchor whose siblings - * are unrelated components. - */ -function tryScroll( - start: FiberNode | null, - offset: number, - animated: boolean, - walkSiblings = true, -): boolean { - let current: FiberNode | null = start; - while (current) { - const sn = current.stateNode; - if (sn) { - if (typeof sn.scrollTo === 'function') { - sn.scrollTo({ y: offset, animated }); - return true; - } - if (typeof sn.scrollToOffset === 'function') { - sn.scrollToOffset({ offset, animated }); - return true; - } - } - if (tryScroll(current.child, offset, animated)) return true; - current = walkSiblings ? current.sibling : null; - } - return false; -} - -function targetMatches( - fiber: FiberNode, - options: { testId?: string; textContains?: string }, -): boolean { - if (options.testId && fiber.memoizedProps?.testID !== options.testId) { - return false; - } - if (options.textContains) { - const needle = options.textContains.toLowerCase(); - const texts = options.testId - ? collectFiberTexts(fiber) - : collectOwnFiberTexts(fiber); - return texts.some((text) => text.toLowerCase().includes(needle)); - } - return Boolean(options.testId); -} - -function findUiTargetFiber( - rootFiber: FiberNode, - options: { testId?: string; textContains?: string }, -): FiberNode | null { - let result: FiberNode | null = null; - walkFiber(rootFiber, (fiber) => { - if (targetMatches(fiber, options)) { - result = fiber; - return true; - } - return false; - }); - return result; -} - -function findMeasurableStateNode( - fiber: FiberNode | null, -): FiberNode['stateNode'] | null { - let result: FiberNode['stateNode'] | null = null; - walkFiber(fiber, (node) => { - const sn = node.stateNode; - if ( - sn && - (typeof sn.measureInWindow === 'function' || - typeof sn.measure === 'function') - ) { - result = sn; - return true; - } - return false; - }); - return result; -} - -function measureStateNode( - stateNode: FiberNode['stateNode'], -): Promise<{ x: number; y: number; width: number; height: number } | null> { - return new Promise((resolve) => { - if (!stateNode) { - resolve(null); - return; - } - let settled = false; - const settle = ( - value: { x: number; y: number; width: number; height: number } | null, - ) => { - if (settled) return; - settled = true; - resolve(value); - }; - // Native measure callbacks never fire for detached/off-screen views; - // fall back to null after a short delay so callers don't hang forever. - const timeout = setTimeout(() => settle(null), 2500); - const finish = (x: number, y: number, width: number, height: number) => { - clearTimeout(timeout); - settle({ x, y, width, height }); - }; - try { - if (typeof stateNode.measureInWindow === 'function') { - stateNode.measureInWindow(finish); - return; - } - if (typeof stateNode.measure === 'function') { - stateNode.measure((_x, _y, width, height, pageX, pageY) => - finish(pageX, pageY, width, height), - ); - return; - } - } catch (e) { - Logger.log(String(e), 'AgenticService.measureStateNode'); - } - clearTimeout(timeout); - settle(null); - }); -} - -async function queryUiTarget(options: { - testId?: string; - textContains?: string; - visibility?: 'tree' | 'viewport'; -}): Promise<{ - present: boolean; - visible: boolean; - visibility: 'tree' | 'viewport'; - testId?: string; - textContains?: string; - textMatched?: boolean; - rect?: { x: number; y: number; width: number; height: number }; - viewport?: { width: number; height: number }; - error?: string; -}> { - const visibility: 'tree' | 'viewport' = - options.visibility === 'viewport' ? 'viewport' : 'tree'; - let target: FiberNode | null = null; - - walkFiberRoots((rootFiber) => { - target = findUiTargetFiber(rootFiber, options); - return Boolean(target); - }); - - const base = { - present: Boolean(target), - visible: Boolean(target) && visibility === 'tree', - visibility, - testId: options.testId, - textContains: options.textContains, - textMatched: options.textContains - ? Boolean( - target && - collectFiberTexts(target).some((text) => - text - .toLowerCase() - .includes(String(options.textContains).toLowerCase()), - ), - ) - : undefined, - }; - - if (!target || visibility === 'tree') { - return base; - } - - const stateNode = findMeasurableStateNode(target); - if (!stateNode) { - return { - ...base, - visible: false, - error: - 'Target exists in fiber tree but no measurable native node was found', - }; - } - - const rect = await measureStateNode(stateNode); - const viewport = Dimensions.get('window'); - if (!rect) { - return { - ...base, - visible: false, - viewport: { width: viewport.width, height: viewport.height }, - error: 'Target exists in fiber tree but measurement returned no frame', - }; - } - - const visible = - rect.width > 0 && - rect.height > 0 && - rect.x < viewport.width && - rect.y < viewport.height && - rect.x + rect.width > 0 && - rect.y + rect.height > 0; - - return { - ...base, - visible, - rect, - viewport: { width: viewport.width, height: viewport.height }, - }; -} - -function appendTextContent(value: unknown, out: string[]) { - if (value === null || value === undefined) { - return; - } - if (typeof value === 'string' || typeof value === 'number') { - const text = String(value).trim(); - if (text.length > 0) { - out.push(text); - } - return; - } - if (Array.isArray(value)) { - value.forEach((entry) => appendTextContent(entry, out)); - return; - } - if (typeof value === 'object' && value && 'props' in value) { - const maybeProps = (value as { props?: { children?: unknown } }).props; - if (maybeProps?.children !== undefined) { - appendTextContent(maybeProps.children, out); - } - } -} - -function dedupeTexts(texts: string[]): string[] { - return texts.filter((text, index) => texts.indexOf(text) === index); -} - -function collectFiberTexts(fiber: FiberNode | null): string[] { - const texts: string[] = []; - walkFiber(fiber, (node) => { - if (node.memoizedProps?.children !== undefined) { - appendTextContent(node.memoizedProps.children, texts); - } - return false; - }); - return dedupeTexts(texts); -} - -function collectOwnFiberTexts(fiber: FiberNode | null): string[] { - const texts: string[] = []; - if (fiber?.memoizedProps?.children !== undefined) { - appendTextContent(fiber.memoizedProps.children, texts); - } - return dedupeTexts(texts); -} - -function findAncestorTexts( - fiber: FiberNode | null, - predicate: (texts: string[]) => boolean, - maxTexts = 14, -): string[] | null { - let current = fiber; - while (current) { - const texts = collectFiberTexts(current); - if (texts.length > 0 && texts.length <= maxTexts && predicate(texts)) { - return texts; - } - current = current.return; - } - return null; -} - -function findRowTexts( - label: string, - options: { - anchorTestId?: string; - requiredLabels?: string[]; - maxTexts?: number; - } = {}, -): string[] | null { - const { anchorTestId, requiredLabels = [], maxTexts = 14 } = options; - const matchesRow = (texts: string[]) => - texts.includes(label) && - requiredLabels.every((requiredLabel) => texts.includes(requiredLabel)); - - if (anchorTestId) { - let anchoredMatch: string[] | null = null; - walkFiberRoots((rootFiber) => { - const anchor = findFiberByTestId(rootFiber, anchorTestId); - if (!anchor) { - return false; - } - anchoredMatch = findAncestorTexts(anchor, matchesRow, maxTexts); - return Boolean(anchoredMatch); - }); - if (anchoredMatch) { - return anchoredMatch; - } - } - - let fallbackMatch: string[] | null = null; - walkFiberRoots((rootFiber) => - walkFiber(rootFiber, (fiber) => { - const texts = collectFiberTexts(fiber); - if (texts.length === 0 || texts.length > maxTexts) { - return false; - } - if (!matchesRow(texts)) { - return false; - } - fallbackMatch = texts; - return true; - }), - ); - - return fallbackMatch; -} - -function getRowValue( - label: string, - pattern: string, - options: { - anchorTestId?: string; - requiredLabels?: string[]; - maxTexts?: number; - } = {}, -): string | null { - const rowTexts = findRowTexts(label, options); - if (!rowTexts) { - return null; - } - const matcher = new RegExp(pattern); - return rowTexts.find((text) => text !== label && matcher.test(text)) ?? null; -} - -// ─── Step HUD callback registry ───────────────────────────────────────────── - -type StepHudCallback = ((step: AgenticHudStep | null) => void) | null; -let _stepHudCallback: StepHudCallback = null; - -export function registerStepHudCallback(fn: StepHudCallback) { - _stepHudCallback = fn; -} - -// ─── AgenticService ───────────────────────────────────────────────────────── - -/** - * __DEV__-only service that installs the `globalThis.__AGENTIC__` bridge. - * - * The bridge exposes navigation primitives, account helpers, and UI - * interaction methods (pressTestId, scrollView) so that AI coding agents - * can inspect and drive the app remotely via Metro's Hermes CDP WebSocket. - * - * Consumed by the scripts in `scripts/perps/agentic/`. - * See docs/perps/perps-agentic-feedback-loop.md for the full workflow. - */ -const AgenticService = { - /** - * Install the agentic bridge on globalThis. - * - * @param navRef - Raw (unwrapped) navigation container ref - * @param deferredNav - The requestAnimationFrame-deferred proxy - */ - install( - navRef: NavigationContainerRef, - deferredNav: NavigationContainerRef, - ) { - Logger.log('[AgenticService] __AGENTIC__ bridge installed'); - - globalThis.__AGENTIC__ = { - platform: Platform.OS, - replayHarnessPatch: 'legacy-wallet-fixture-r2', - navigate: (name: string, params?: object) => - ( - deferredNav as unknown as { - navigate: (name: string, params?: object) => void; - } - ).navigate(name, params), - getRoute: () => navRef.getCurrentRoute(), - getState: () => navRef.getState(), - canGoBack: () => navRef.canGoBack(), - goBack: () => deferredNav.goBack(), - listAccounts: () => - Engine.context.AccountsController.listAccounts().map(toAccountSummary), - getSelectedAccount: () => - toAccountSummary( - Engine.context.AccountsController.getSelectedAccount(), - ), - pressTestId: (testId: string) => { - try { - const found = walkFiberRoots((rootFiber) => - walkFiber(rootFiber, (f) => { - if ( - f.memoizedProps?.testID === testId && - typeof f.memoizedProps?.onPress === 'function' - ) { - f.memoizedProps.onPress(); - return true; - } - return false; - }), - ); - if (found) return { ok: true, testId }; - return { - ok: false, - error: `No component with testID="${testId}" found or no onPress prop`, - }; - } catch (e) { - return { ok: false, error: String(e) }; - } - }, - pressText: ( - text: string, - options: { requiredTexts?: string[]; maxTexts?: number } = {}, - ) => { - const { requiredTexts = [], maxTexts = 14 } = options; - try { - let bestMatch: FiberNode | null = null; - let bestTextCount = Number.POSITIVE_INFINITY; - walkFiberRoots((rootFiber) => - walkFiber(rootFiber, (fiber) => { - if (typeof fiber.memoizedProps?.onPress !== 'function') { - return false; - } - const texts = collectFiberTexts(fiber); - if (texts.length === 0 || texts.length > maxTexts) { - return false; - } - if (!texts.includes(text)) { - return false; - } - if (requiredTexts.some((required) => !texts.includes(required))) { - return false; - } - if (texts.length < bestTextCount) { - bestMatch = fiber; - bestTextCount = texts.length; - } - return false; - }), - ); - const matched = bestMatch as FiberNode | null; - if (matched && typeof matched.memoizedProps?.onPress === 'function') { - matched.memoizedProps.onPress(); - return { ok: true, text }; - } - return { - ok: false, - error: `No pressable found for text="${text}"`, - }; - } catch (e) { - return { ok: false, error: String(e) }; - } - }, - scrollView: ( - options: { - testId?: string; - offset?: number; - animated?: boolean; - } = {}, - ) => { - const { - testId: scrollTestId, - offset = 300, - animated = false, - } = options; - try { - const found = walkFiberRoots((rootFiber) => { - if (scrollTestId) { - const anchor = findFiberByTestId(rootFiber, scrollTestId); - if (!anchor) return false; - return tryScroll(anchor, offset, animated, false); - } - return tryScroll(rootFiber, offset, animated); - }); - if (found) - return { ok: true, testId: scrollTestId, offset, animated }; - return { - ok: false, - error: scrollTestId - ? `No scrollable found near testID="${scrollTestId}"` - : 'No scrollable found in fiber tree', - }; - } catch (e) { - return { ok: false, error: String(e) }; - } - }, - setInput: (testId: string, value: string) => { - try { - const result: { - ok: boolean; - testId?: string; - value?: string; - error?: string; - } = { - ok: false, - error: `No component with testID="${testId}" found`, - }; - walkFiberRoots((rootFiber) => { - const target = findFiberByTestId(rootFiber, testId); - if (!target) return false; - // Walk the found fiber and its parents looking for onChangeText - let current: FiberNode | null = target; - while (current) { - if (typeof current.memoizedProps?.onChangeText === 'function') { - current.memoizedProps.onChangeText(value); - result.ok = true; - result.testId = testId; - result.value = value; - result.error = undefined; - return true; - } - current = current.return; - } - result.error = `Component with testID="${testId}" has no onChangeText prop`; - return true; - }); - return result; - } catch (e) { - return { ok: false, error: String(e) }; - } - }, - getTextByTestId: (testId: string, options: { all?: boolean } = {}) => { - let texts: string[] | null = null; - walkFiberRoots((rootFiber) => { - const target = findFiberByTestId(rootFiber, testId); - if (!target) { - return false; - } - texts = collectFiberTexts(target); - return true; - }); - const collected = texts as string[] | null; - if (!collected || collected.length === 0) { - return null; - } - return options.all ? collected : collected[0]; - }, - getAncestorTextsByTestId: ( - testId: string, - options: { requiredLabels?: string[]; maxTexts?: number } = {}, - ) => { - const { requiredLabels = [], maxTexts = 14 } = options; - let texts: string[] | null = null; - walkFiberRoots((rootFiber) => { - const target = findFiberByTestId(rootFiber, testId); - if (!target) { - return false; - } - texts = findAncestorTexts( - target, - (candidateTexts) => - requiredLabels.every((label) => candidateTexts.includes(label)), - maxTexts, - ); - return Boolean(texts); - }); - return texts; - }, - getRowValue: ( - label: string, - pattern: string, - options: { - anchorTestId?: string; - requiredLabels?: string[]; - maxTexts?: number; - } = {}, - ) => getRowValue(label, pattern, options), - switchAccount: (address: string) => { - const accounts = Engine.context.AccountsController.listAccounts(); - const target = accounts.find( - (a: { address: string }) => - a.address.toLowerCase() === address.toLowerCase(), - ); - if (!target) { - throw new Error(`No account found for address ${address}`); - } - Engine.setSelectedAddress(target.address); - return { switched: true, ...toAccountSummary(target) }; - }, - showStep: (step: AgenticHudStep) => { - _stepHudCallback?.(step); - }, - hideStep: () => { - _stepHudCallback?.(null); - }, - refreshPerpsStreams: async () => { - await PerpsConnectionManager.ensureConnected({ - source: 'agentic_refresh_perps_streams', - suppressError: true, - }); - const streamManager = getStreamManagerInstance(); - streamManager.clearAllChannels(); - const positions = await Engine.context.PerpsController.getPositions(); - return { - ok: true, - positions: Array.isArray(positions) ? positions.length : 0, - }; - }, - findFiberByTestId: (testId: string): boolean => { - let found = false; - walkFiberRoots((rootFiber) => { - if (findFiberByTestId(rootFiber, testId)) { - found = true; - return true; - } - return false; - }); - return found; - }, - queryUiTarget, - applyWalletFixture: async (fixture) => { - try { - const { - KeyringController, - AccountsController, - AccountTreeController, - } = Engine.context; - // The backup subscriber reads the Engine class static, so set it on - // the class (not the facade) before importing/renaming accounts to - // avoid racing native keychain export during fixture apply. - EngineClass.disableAutomaticVaultBackup = true; - // Unlock via the real auth flow (loginVaultCreation + dispatchLogin + - // post-login) rather than a bare KeyringController.submitPassword, so - // multichain services and Redux/auth state are consistent before we - // mutate accounts. - if (!KeyringController.isUnlocked()) { - await Authentication.unlockWallet({ password: fixture.password }); - } - // Existing replay vaults can have the same historical multichain - // account-tree init gap as fresh legacy setup. Only the known legacy - // init errors are tolerated by this option; unexpected errors still - // throw from initializeFixtureAccountTree(). - const legacyAccountTreeInitOptions = { - allowLegacyAccountTreeInitFailure: true, - }; - await initializeFixtureAccountTree(legacyAccountTreeInitOptions); - await materializeFixtureAccounts( - fixture, - { - KeyringController, - AccountsController, - AccountTreeController, - }, - legacyAccountTreeInitOptions, - ); - const ethAccs = findEvmAccounts( - AccountsController.state.internalAccounts.accounts, - ).map(toAccountSummary); - return { ok: true, accounts: ethAccs }; - } catch (e) { - return { ok: false, error: String((e as Error).message || e) }; - } - }, - setupWallet: async (fixture) => { - let setupStep = 'start'; - try { - setupStep = 'read-engine'; - const { - MultichainAccountService, - KeyringController, - AccountsController, - AccountTreeController, - } = Engine.context; - const store = ReduxService.store; - const settings = fixture.settings ?? {}; - // Deliberately one-way for the dev harness process: fixture setup - // rewrites vault/account state, so automatic backup must stay disabled - // for the rest of this simulator session to avoid native keychain - // export paths racing the synthetic setup flow. - EngineClass.disableAutomaticVaultBackup = true; - - // 1. Create wallet from the first mnemonic (same path as onboarding UI) - setupStep = 'create-wallet'; - const mnemonicAccount = fixture.accounts.find( - (a) => a.type === 'mnemonic', - ); - let usedLegacyVaultSetup = false; - if (mnemonicAccount) { - const mnemonic = mnemonicPhraseToBytes(mnemonicAccount.value); - try { - await MultichainAccountService.createMultichainAccountWallet({ - type: 'restore', - password: fixture.password, - mnemonic, - }); - } catch (error) { - if (!isExpectedLegacyAccountTreeInitError(error)) { - throw error; - } - Logger.log( - '[AgenticService] Falling back to legacy vault restore for historical replay fixture setup', - ); - await KeyringController.createNewVaultAndRestore( - fixture.password, - mnemonic, - ); - usedLegacyVaultSetup = true; - } - } else { - try { - await MultichainAccountService.createMultichainAccountWallet({ - type: 'create', - password: fixture.password, - }); - } catch (error) { - if (!isExpectedLegacyAccountTreeInitError(error)) { - throw error; - } - Logger.log( - '[AgenticService] Falling back to legacy vault creation for historical replay fixture setup', - ); - await KeyringController.createNewVaultAndKeychain( - fixture.password, - ); - usedLegacyVaultSetup = true; - } - } - - // 2. Initialize services (same as Authentication.dispatchLogin) - setupStep = 'initialize-services'; - if (!usedLegacyVaultSetup) { - try { - await AccountTreeInitService.initializeAccountTree(); - await MultichainAccountService.init(); - } catch (error) { - if (!isExpectedLegacyAccountTreeInitError(error)) { - throw error; - } - Logger.log( - '[AgenticService] Skipping multichain account-tree initialization for historical replay fixture setup', - ); - } - } - - // 3. Materialize requested fixture accounts. A mnemonic account can - // declare count/numberOfAccounts plus names[]; this is generic - // fixture semantics, not a dev-account special case. - setupStep = 'materialize-srp-accounts'; - await materializeFixtureAccounts( - fixture, - { - KeyringController, - AccountsController, - AccountTreeController, - }, - { allowLegacyAccountTreeInitFailure: usedLegacyVaultSetup }, - ); - - // 4. Dispatch all onboarding/auth flags - setupStep = 'dispatch-onboarding-flags'; - store.dispatch(passwordSet()); - store.dispatch(seedphraseBackedUp()); - store.dispatch(setCompletedOnboarding(true)); - store.dispatch(setExistingUser(true)); - store.dispatch(logIn()); - - // 5. Suppress post-onboarding modals if explicitly requested - setupStep = 'persist-modal-settings'; - if (settings.skipGtmModals === true) { - await Promise.all([ - StorageWrapper.setItem(PERPS_GTM_MODAL_SHOWN, 'true'), - StorageWrapper.setItem(PREDICT_GTM_MODAL_SHOWN, 'true'), - StorageWrapper.setItem(REWARDS_GTM_MODAL_SHOWN, 'true'), - ]); - // Suppress ExperienceEnhancer (marketing consent) modal - store.dispatch(setDataCollectionForMarketing(false)); - } - - // 5b. Set metrics UI as seen (prevents Authentication.unlockWallet - // from navigating to OptinMetrics after setupWallet resets to Wallet) - setupStep = 'persist-metrics-setting'; - if (settings.metametrics !== undefined) { - await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, 'true'); - } - - // 5c. Mark multichain accounts intro modal as seen - setupStep = 'dispatch-multichain-intro-seen'; - store.dispatch(setMultichainAccountsIntroModalSeen(true)); - - // 6. Skip perps tutorial onboarding if requested - setupStep = 'persist-perps-tutorial-setting'; - if (settings.skipPerpsTutorial === true) { - Engine.context.PerpsController?.markTutorialCompleted(); - } - - // 7. Set auto-lock to "Never" (-1) for agentic workflows - setupStep = 'dispatch-auto-lock'; - if (settings.autoLockNever === true) { - ReduxService.store.dispatch(setLockTime(-1)); - } - - // 8. Enable device authentication only on Android fixture runs. - // On iOS simulator, toggling OS auth during synthetic setup can drive - // react-native-keychain/quick-crypto secret export on the JS runtime - // and crash the app after setupWallet returns. Android is the only - // harness path that stores the fixture password for auto-unlock here. - setupStep = 'dispatch-device-auth'; - if ( - settings.deviceAuthEnabled === true && - Platform.OS === 'android' - ) { - ReduxService.store.dispatch(setOsAuthEnabled(true)); - } - - // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only) - if ( - settings.deviceAuthEnabled === true && - Platform.OS === 'android' - ) { - DevLogger.log('[AUTO-UNLOCK] Storing password in SecureKeychain', { - authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - }); - await SecureKeychain.setGenericPassword( - fixture.password, - AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - ); - DevLogger.log('[AUTO-UNLOCK] SecureKeychain password stored'); - } - - // 9. Configure MetaMetrics if specified - setupStep = 'configure-metametrics'; - if (settings.metametrics === false) { - await analytics.optOut(); - } else if (settings.metametrics === true) { - await analytics.optIn(); - } - - // 10. Navigate to wallet (same as Authentication.unlockWallet) - setupStep = 'navigate-wallet'; - NavigationService.navigation?.reset({ - routes: [{ name: Routes.ONBOARDING.HOME_NAV }], - }); - - // 11. Collect all ETH accounts for the summary - setupStep = 'collect-accounts'; - const ethAccs = findEvmAccounts( - AccountsController.state.internalAccounts.accounts, - ).map(toAccountSummary); - - return { ok: true, accounts: ethAccs }; - } catch (e) { - return { - ok: false, - step: setupStep, - error: String((e as Error).message || e), - }; - } - }, - }; - - try { - (globalThis as { store?: unknown }).store = ReduxService.store; - (globalThis as { persistor?: unknown }).persistor = persistor; - } catch { - // ReduxService.store may not be initialized yet; skip. - } - (globalThis as { Engine?: unknown }).Engine = Engine; - }, -}; - -export default AgenticService; -export { - walkFiber, - findFiberByTestId, - walkFiberRoots, - tryScroll, - toAccountSummary, -}; -export type { FiberNode, FiberRoot, ReactDevToolsHook, AgenticBridge }; diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/manifest.json b/domains/agentic/skills/recipe-harness/adapters/mobile/manifest.json deleted file mode 100644 index 23e06a7..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "adapter": "mobile", - "runtime": "MetaMask Mobile recipe runtime", - "version": 1, - "installs": [ - "scripts/perps/agentic/**", - "app/core/AgenticService/**" - ], - "patches": [ - "package.json", - "app/core/NavigationService/NavigationService.ts", - "app/components/Nav/App/App.tsx" - ] -} diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/cleanup.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/cleanup.sh deleted file mode 100755 index 2735332..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/cleanup.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -ALLOW_MANAGED_CHANGES=false -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --allow-managed-changes) ALLOW_MANAGED_CHANGES=true; shift ;; - -h|--help) echo "Usage: cleanup.sh [--target ] [--allow-managed-changes]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TARGET="$(cd "$TARGET" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/lib/hash-helpers.sh" -# shellcheck disable=SC1091 -for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi -HARNESS_DIR="$(harness_dir "$TARGET" mobile)" -if GIT_BACKUP_PATH="$(git -C "$TARGET" rev-parse --git-path recipe-harness/mobile/backup 2>/dev/null)"; then - case "$GIT_BACKUP_PATH" in - /*) BACKUP_DIR="$GIT_BACKUP_PATH" ;; - *) BACKUP_DIR="$TARGET/$GIT_BACKUP_PATH" ;; - esac -else - BACKUP_DIR="$HARNESS_DIR/backup-store" -fi -if [ ! -f "$BACKUP_DIR/state.env" ] && [ -f "$HARNESS_DIR/backup/state.env" ]; then - BACKUP_DIR="$HARNESS_DIR/backup" -fi -STATE_FILE="$BACKUP_DIR/state.env" - -remove_recorded_git_exclude_entries() { - local tracking_file="$1" - [ -s "$tracking_file" ] || return 0 - local git_dir exclude_file tmp_file - if ! git_dir="$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null)"; then - return 0 - fi - case "$git_dir" in - /*) ;; - *) git_dir="$TARGET/$git_dir" ;; - esac - exclude_file="$git_dir/info/exclude" - [ -f "$exclude_file" ] || return 0 - # Remove only the lines THIS install recorded, one occurrence per distinct - # ledger entry (the appended copy is the last match), so a pre-existing - # duplicate copy or a stale/duplicate ledger entry never drops a line we did - # not add this run. - tmp_file="$(mktemp)" - awk ' - NR==FNR { if (length($0)) want[$0]=1; next } - { lines[++n]=$0; if ($0 in want) last[$0]=n } - END { for (k in last) drop[last[k]]=1; for (i=1;i<=n;i++) if (!(i in drop)) print lines[i] } - ' "$tracking_file" "$exclude_file" > "$tmp_file" - mv "$tmp_file" "$exclude_file" -} - -if [ -f "$HARNESS_DIR/manifest.json" ] && node -e ' - const fs = require("fs"); - const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - process.exit(manifest.installMode === "product-owned" ? 0 : 1); - ' "$HARNESS_DIR/manifest.json" 2>/dev/null; then - remove_recorded_git_exclude_entries "$HARNESS_DIR/added-git-exclude" - rm -rf "$HARNESS_DIR" - echo "Cleaned mobile recipe harness metadata from $TARGET (product-owned harness files left untouched)" - exit 0 -fi - -if [ ! -f "$STATE_FILE" ]; then - if [ -f "$HARNESS_DIR/manifest.json" ] && node -e ' - const fs = require("fs"); - const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - process.exit(manifest.installMode === "product-owned" ? 0 : 1); - ' "$HARNESS_DIR/manifest.json" 2>/dev/null; then - remove_recorded_git_exclude_entries "$HARNESS_DIR/added-git-exclude" - rm -rf "$HARNESS_DIR" - echo "Cleaned mobile recipe harness metadata from $TARGET (product-owned harness files left untouched)" - exit 0 - fi - echo "No mobile harness backup found at $STATE_FILE" >&2 - exit 1 -fi - -while IFS= read -r _line || [ -n "$_line" ]; do - [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue - _key="${_line%%=*}" - _val="${_line#*=}" - case "$_key" in - SCRIPTS_EXISTED|AGENTIC_SERVICE_EXISTED|PACKAGE_JSON_EXISTED|NAVIGATION_SERVICE_EXISTED|APP_TSX_EXISTED) ;; - *) continue ;; - esac - export "$_key=$_val" -done < "$STATE_FILE" -unset _line _key _val - -HASH_FILE="$BACKUP_DIR/managed-hashes.tsv" - -verify_managed_paths_unchanged() { - if [ ! -f "$HASH_FILE" ]; then - cat >&2 <&2 - echo " expected: $expected" >&2 - echo " actual: $actual" >&2 - conflicts=1 - fi - done < "$HASH_FILE" - if [ "$conflicts" != "0" ]; then - cat >&2 <&2 - exit 1 - fi - rm -rf "$target_path" - mkdir -p "$(dirname "$target_path")" - cp -a "$backup_path" "$target_path" - else - rm -rf "$target_path" - fi -} - -restore_path "scripts/perps/agentic" "${SCRIPTS_EXISTED:-0}" -restore_path "app/core/AgenticService" "${AGENTIC_SERVICE_EXISTED:-0}" -if [ "${PACKAGE_JSON_EXISTED+x}" = "x" ]; then - restore_path "package.json" "$PACKAGE_JSON_EXISTED" -fi -restore_path "app/core/NavigationService/NavigationService.ts" "${NAVIGATION_SERVICE_EXISTED:-0}" -restore_path "app/components/Nav/App/App.tsx" "${APP_TSX_EXISTED:-0}" - -remove_recorded_git_exclude_entries "$BACKUP_DIR/added-git-exclude" - -# Leave the consumer's .skills-cache/ alone: it is gitignored and owned by the -# product checkout, not the harness. -rm -rf "$HARNESS_DIR" -rm -rf "$BACKUP_DIR" -echo "Cleaned mobile recipe harness from $TARGET" diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/install.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/install.sh deleted file mode 100755 index 68604c3..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/install.sh +++ /dev/null @@ -1,656 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -ALLOW_DIRTY=false -FORCE_OVERLAY=false -GIT_EXCLUDE=true -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --allow-dirty-harness-paths) ALLOW_DIRTY=true; shift ;; - --force-overlay) FORCE_OVERLAY=true; shift ;; - --no-git-exclude) GIT_EXCLUDE=false; shift ;; - -h|--help) echo "Usage: install.sh [--target ] [--allow-dirty-harness-paths] [--force-overlay] [--no-git-exclude]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ADAPTER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -SKILL_DIR="$(cd "${ADAPTER_DIR}/../.." && pwd)" -AGENTIC_DIR="$(cd "$SKILL_DIR/../.." && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/lib/hash-helpers.sh" -# shellcheck disable=SC1091 -for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi -# shellcheck disable=SC1091 -. "$SKILL_DIR/scripts/resolve-runner-source.sh" -TARGET="$(cd "$TARGET" && pwd)" -resolve_metamask_recipe_runner_source "$SKILL_DIR" "$AGENTIC_DIR" "$TARGET" -HARNESS_ROOT="$(harness_root)" -HARNESS_REL="$HARNESS_ROOT/mobile" - -refuse_symlink_destination() { - local rel="$1" - local path_so_far="$TARGET" - IFS='/' read -r -a parts <<< "$rel" - for part in "${parts[@]}"; do - [ -n "$part" ] || continue - path_so_far="$path_so_far/$part" - if [ -L "$path_so_far" ]; then - echo "Refusing mobile recipe harness install: $rel contains symlink component $path_so_far." >&2 - return 1 - fi - done -} - -HARNESS_DIR="$(harness_dir "$TARGET" mobile)" -# refuse_symlink_destination walks every path component, so the deepest path also -# guards its parents ($HARNESS_REL covers the root segments). -refuse_symlink_destination "$HARNESS_REL" -refuse_symlink_destination "scripts" -refuse_symlink_destination "scripts/perps" -refuse_symlink_destination "scripts/perps/agentic" -refuse_symlink_destination "app" -refuse_symlink_destination "app/core" -refuse_symlink_destination "app/core/AgenticService" -refuse_symlink_destination "app/core/NavigationService" -refuse_symlink_destination "app/components" -refuse_symlink_destination "app/components/Nav" -refuse_symlink_destination "app/components/Nav/App" -if GIT_BACKUP_PATH="$(git -C "$TARGET" rev-parse --git-path recipe-harness/mobile/backup 2>/dev/null)"; then - case "$GIT_BACKUP_PATH" in - /*) BACKUP_DIR="$GIT_BACKUP_PATH" ;; - *) BACKUP_DIR="$TARGET/$GIT_BACKUP_PATH" ;; - esac -else - BACKUP_DIR="$HARNESS_DIR/backup-store" -fi -OLD_BACKUP_DIR="$HARNESS_DIR/backup" -if [ "$BACKUP_DIR" != "$OLD_BACKUP_DIR" ] && [ -f "$OLD_BACKUP_DIR/state.env" ] && [ ! -e "$BACKUP_DIR" ]; then - mkdir -p "$(dirname "$BACKUP_DIR")" - mv "$OLD_BACKUP_DIR" "$BACKUP_DIR" -fi -STATE_FILE="$BACKUP_DIR/state.env" - -install_v1_runner_assets() { - # refuse_symlink_destination walks every path component, so the deepest paths - # also guard their parents ($HARNESS_REL covers the root segments). - refuse_symlink_destination "$HARNESS_REL/runner" - refuse_symlink_destination "$HARNESS_REL/action-manifest.json" - mkdir -p "$HARNESS_DIR" - rm -rf "$HARNESS_DIR/runner" - mkdir -p "$HARNESS_DIR/runner/bin" "$HARNESS_DIR/runner/manifests" "$HARNESS_DIR/runner/recipes" - - # Mobile Metro/Watchman observes the product checkout. Copying the full - # TypeScript runner tree into .agent while Metro is running can trigger large - # file-map recrawls and make the React Native debug bridge unresponsive. - # Keep the target overlay lightweight: install a harness-owned delegate plus - # manifest/recipe snapshots, and execute the reviewed external runner source - # recorded in manifest.json. This preserves the injection contract without - # making the skills repo or the watched product checkout own the runtime. - # Emit shell-safe lines: %q-quote the interpolated paths (like CLEANUP_COMMAND - # below) so a FARMSLOT_ROOT/runner path containing a space — or $()/backtick/ - # quote — cannot break the generated wrapper or inject at runtime. - local runner_farmslot_root_q runner_exec_q - runner_farmslot_root_q="$(printf '%q' "$METAMASK_RUNNER_FARMSLOT_ROOT")" - runner_exec_q="$(printf '%q' "$METAMASK_RUNNER_DIR/bin/metamask-recipe")" - { - printf '%s\n' '#!/usr/bin/env bash' - printf '%s\n' 'set -euo pipefail' - if [ -n "$METAMASK_RUNNER_FARMSLOT_ROOT" ]; then - printf 'export FARMSLOT_ROOT=${FARMSLOT_ROOT:-%s}\n' "$runner_farmslot_root_q" - fi - printf 'exec %s "$@"\n' "$runner_exec_q" - } > "$HARNESS_DIR/runner/bin/metamask-recipe" - chmod +x "$HARNESS_DIR/runner/bin/metamask-recipe" - if [ -n "$METAMASK_RUNNER_FARMSLOT_ROOT" ]; then - printf '%s\n' "$METAMASK_RUNNER_FARMSLOT_ROOT" > "$HARNESS_DIR/runner/.farmslot-root" - fi - printf '%s\n' "$METAMASK_RUNNER_DIR" > "$HARNESS_DIR/runner/.runner-source" - cp "$METAMASK_RUNNER_DIR/manifests/mobile.action-manifest.json" "$HARNESS_DIR/action-manifest.json" - cp "$METAMASK_RUNNER_DIR/manifests/mobile.action-manifest.json" "$HARNESS_DIR/runner/manifests/mobile.action-manifest.json" - cp "$METAMASK_RUNNER_DIR/manifests/extension.action-manifest.json" "$HARNESS_DIR/runner/manifests/extension.action-manifest.json" - if [ -d "$METAMASK_RUNNER_DIR/recipes" ]; then - rsync -a --delete "$METAMASK_RUNNER_DIR/recipes/" "$HARNESS_DIR/runner/recipes/" - fi - if [ ! -x "$HARNESS_DIR/runner/bin/metamask-recipe" ]; then - echo "Refusing mobile recipe harness install: failed to make runner executable." >&2 - return 1 - fi -} - -git_tracks_any_under() { - git -C "$TARGET" ls-files -- "$1" 2>/dev/null | grep -q . -} - -has_product_owned_mobile_harness() { - # If the product repo tracks any first-party harness subtree, installing the - # skill overlay must be metadata-only by default. Requiring every marker to be - # present is unsafe for older/partial Mobile commits: falling through would - # rsync --delete tracked product-owned files without --force-overlay. - git_tracks_any_under "scripts/perps/agentic" && return 0 - git_tracks_any_under "app/core/AgenticService" && return 0 - - # Marker fallback for checkouts where the harness was patched but not tracked. - grep -q "AgenticService.install" "$TARGET/app/core/NavigationService/NavigationService.ts" 2>/dev/null \ - && grep -q "AgentStepHud" "$TARGET/app/components/Nav/App/App.tsx" 2>/dev/null -} - -validate_mobile_agentic_source() { - local source="$1" - local rel - for rel in \ - "cdp-bridge.js" \ - "preflight.sh" \ - "start-metro.sh" \ - "interactive-start.sh" \ - "stop-metro.sh" \ - "reload-metro.sh" \ - "app-state.sh" \ - "app-navigate.sh" \ - "screenshot.sh" \ - "setup-wallet.sh" \ - "lib/safe-env-parser.sh"; do - if [ ! -f "$source/$rel" ]; then - echo "METAMASK_MOBILE_AGENTIC_SOURCE is missing required Mobile bridge entrypoint: $source/$rel" >&2 - return 1 - fi - done -} - -resolve_mobile_agentic_source() { - local raw="${METAMASK_MOBILE_AGENTIC_SOURCE:-${METAMASK_RECIPE_MOBILE_BRIDGE_SOURCE:-}}" - local candidate="" - if [ -n "$raw" ]; then - if [ -d "$raw/scripts/perps/agentic" ]; then - candidate="$raw/scripts/perps/agentic" - elif [ -d "$raw" ] && [ -f "$raw/cdp-bridge.js" ]; then - candidate="$raw" - else - echo "METAMASK_MOBILE_AGENTIC_SOURCE must point at scripts/perps/agentic or a repo containing scripts/perps/agentic: $raw" >&2 - return 1 - fi - validate_mobile_agentic_source "$candidate" - MOBILE_AGENTIC_SOURCE="$(cd "$candidate" && pwd)" - return 0 - fi - - cat >&2 </dev/null)"; then - return 0 - fi - # Skip if the path is already gitignored (e.g. a temp/-rooted harness under an - # existing temp/ rule) — no redundant info/exclude entry needed. - if git -C "$TARGET" check-ignore -q "${entry%/}" 2>/dev/null; then - return 0 - fi - case "$git_dir" in - /*) ;; - *) git_dir="$TARGET/$git_dir" ;; - esac - exclude_file="$git_dir/info/exclude" - mkdir -p "$(dirname "$exclude_file")" - touch "$exclude_file" - if ! grep -qxF "$entry" "$exclude_file"; then - echo "$entry" >> "$exclude_file" - if [ -n "$tracking_file" ]; then - echo "$entry" >> "$tracking_file" - fi - fi -} - -if [ "$FORCE_OVERLAY" = false ] && has_product_owned_mobile_harness; then - install_v1_runner_assets - SOURCE_REV="$(git -C "$SKILL_DIR" rev-parse HEAD 2>/dev/null || echo unknown)" - CLEANUP_COMMAND="RECIPE_HARNESS_ROOT=$HARNESS_ROOT $(printf '%q' "$SCRIPT_DIR/cleanup.sh") --target $(printf '%q' "$TARGET")" - if [ "$GIT_EXCLUDE" = true ]; then - echo "[recipe-harness] Adding local .git/info/exclude entries (removed on cleanup): $HARNESS_ROOT/, .skills-cache/" - add_git_exclude_entry "$HARNESS_ROOT/" "$HARNESS_DIR/added-git-exclude" - add_git_exclude_entry ".skills-cache/" "$HARNESS_DIR/added-git-exclude" - else - echo "[recipe-harness] --no-git-exclude: skipping .git/info/exclude updates; harness overlay paths may show as untracked in git status." - fi - node -e ' - const fs = require("fs"); - const m = { - adapter: "mobile", - installMode: "product-owned", - installedAt: new Date().toISOString(), - source: { - skillDir: process.argv[1], - skillRevision: process.argv[2], - runnerDir: process.argv[3], - runnerRevision: process.argv[4], - runnerSourceKind: process.argv[5], - adapterRuntime: process.argv[6], - mobileAgenticSource: null - }, - target: process.argv[7], - protocolVersion: "v1", - actionManifestPath: process.argv[11] + "/action-manifest.json", - runnerEntrypoint: process.argv[11] + "/runner/bin/metamask-recipe", - installedPaths: [], - harnessInstalledPaths: [process.argv[11] + "/runner", process.argv[11] + "/action-manifest.json"], - patchedFiles: [], - productOwnedPaths: [ - "scripts/perps/agentic", - "app/core/AgenticService", - "package.json", - "app/core/NavigationService/NavigationService.ts", - "app/components/Nav/App/App.tsx" - ], - backupDir: null, - cleanupCommand: process.argv[12], - productDiffExcludes: [ - ":(exclude)" + process.argv[10], - ":(exclude).skills-cache" - ], - note: "This checkout already contains the first-party Mobile agentic harness. Skill install only writes recipe-harness metadata and must not overwrite tracked product harness files." - }; - fs.writeFileSync(process.argv[9], JSON.stringify(m, null, 2) + "\n"); - ' "$SKILL_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND" - echo "Installed mobile recipe harness metadata only (product-owned harness detected): $HARNESS_DIR/manifest.json" - exit 0 -fi - -# Full-install path: reached when the checkout lacks the bridge, or when -# --force-overlay was passed to deliberately overwrite a product-owned bridge. -if [ "$FORCE_OVERLAY" = true ] && has_product_owned_mobile_harness; then - echo "[recipe-harness] --force-overlay: OVERWRITING tracked product harness files with the skills overlay:" - echo " scripts/perps/agentic, app/core/AgenticService, package.json, app/core/NavigationService/NavigationService.ts, app/components/Nav/App/App.tsx" - echo "[recipe-harness] Files are backed up and restorable via cleanup. This replaces the checkout's in-repo agentic bridge/HUD (intended for stale or older-commit checkouts)." -fi -if [ "$GIT_EXCLUDE" = false ]; then - echo "[recipe-harness] --no-git-exclude: skipping .git/info/exclude updates; harness overlay paths may show as untracked in git status." -fi - -INSTALLED=false -INSTALL_MUTATING=false -ROLLBACK_BACKUP_DIR="$BACKUP_DIR" -ROLLBACK_STATE_FILE="$STATE_FILE" -REFRESH_BACKUP_DIR="" -REBASE_BACKUP=false - -verify_installed_paths_unchanged() { - local hash_file="$BACKUP_DIR/managed-hashes.tsv" - if [ ! -f "$hash_file" ]; then - return 0 - fi - local rel expected actual conflicts=0 stale_clean=0 - while IFS=$'\t' read -r rel expected; do - [ -n "$rel" ] || continue - actual="$(hash_path "$rel")" - if [ "$actual" != "$expected" ]; then - if git -C "$TARGET" rev-parse --git-dir >/dev/null 2>&1 \ - && [ -z "$(git -C "$TARGET" status --porcelain -- "$rel")" ]; then - echo "Mobile recipe harness managed path changed after install but is clean in git: $rel" >&2 - echo " expected: $expected" >&2 - echo " actual: $actual" >&2 - stale_clean=1 - else - echo "Refusing to refresh mobile recipe harness: managed path changed after install: $rel" >&2 - echo " expected: $expected" >&2 - echo " actual: $actual" >&2 - conflicts=1 - fi - fi - done < "$hash_file" - if [ "$conflicts" != "0" ]; then - cat >&2 <&2 <&2 -fi - -if [ "$ALLOW_DIRTY" = false ] && [ "$INSTALLED" = true ]; then - verify_installed_paths_unchanged -fi - -if [ "$ALLOW_DIRTY" = false ] && [ "$INSTALLED" = false ] && git -C "$TARGET" rev-parse --git-dir >/dev/null 2>&1; then - DIRTY_PATHS="$(git -C "$TARGET" status --porcelain -- package.json scripts/perps/agentic app/core/AgenticService app/core/NavigationService/NavigationService.ts app/components/Nav/App/App.tsx)" - if [ -n "$DIRTY_PATHS" ]; then - cat >&2 <> "$ACTIVE_STATE_FILE" - else - printf '%s=0\n' "$var" >> "$ACTIVE_STATE_FILE" - fi -} - -rollback_path() { - local rel="$1" - local existed="$2" - local target_path="$TARGET/$rel" - local backup_path="$ROLLBACK_BACKUP_DIR/$rel" - if [ "$existed" = "1" ]; then - if [ -e "$backup_path" ]; then - rm -rf "$target_path" - mkdir -p "$(dirname "$target_path")" - cp -a "$backup_path" "$target_path" - else - echo "Rollback warning: missing backup for $rel at $backup_path" >&2 - fi - else - rm -rf "$target_path" - fi -} - -rollback_git_exclude() { - [ -f "$ROLLBACK_BACKUP_DIR/added-git-exclude" ] || return 0 - local git_dir exclude_file tmp_file entry - git_dir="$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null || true)" - [ -n "$git_dir" ] || return 0 - case "$git_dir" in - /*) ;; - *) git_dir="$TARGET/$git_dir" ;; - esac - exclude_file="$git_dir/info/exclude" - [ -f "$exclude_file" ] || return 0 - tmp_file="$(mktemp)" - cp "$exclude_file" "$tmp_file" - while IFS= read -r entry; do - [ -n "$entry" ] || continue - grep -vxF "$entry" "$tmp_file" > "$tmp_file.next" || true - mv "$tmp_file.next" "$tmp_file" - done < "$ROLLBACK_BACKUP_DIR/added-git-exclude" - mv "$tmp_file" "$exclude_file" -} - -rollback_failed_install() { - local code="${1:-1}" - if [ "$INSTALL_MUTATING" != true ]; then - exit "$code" - fi - - set +e - echo "Mobile recipe harness install failed; restoring backed-up product files." >&2 - if [ -f "$ROLLBACK_STATE_FILE" ]; then - while IFS= read -r _line || [ -n "$_line" ]; do - [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue - _key="${_line%%=*}"; _val="${_line#*=}" - case "$_key" in - SCRIPTS_EXISTED|AGENTIC_SERVICE_EXISTED|PACKAGE_JSON_EXISTED|NAVIGATION_SERVICE_EXISTED|APP_TSX_EXISTED) ;; - *) continue ;; - esac - export "$_key=$_val" - done < "$ROLLBACK_STATE_FILE" - unset _line _key _val - rollback_path "scripts/perps/agentic" "${SCRIPTS_EXISTED:-0}" - rollback_path "app/core/AgenticService" "${AGENTIC_SERVICE_EXISTED:-0}" - rollback_path "package.json" "${PACKAGE_JSON_EXISTED:-0}" - rollback_path "app/core/NavigationService/NavigationService.ts" "${NAVIGATION_SERVICE_EXISTED:-0}" - rollback_path "app/components/Nav/App/App.tsx" "${APP_TSX_EXISTED:-0}" - rollback_git_exclude - else - echo "Rollback warning: no backup state found at $ROLLBACK_STATE_FILE" >&2 - fi - if [ "$INSTALLED" = true ]; then - [ -n "$REFRESH_BACKUP_DIR" ] && rm -rf "$REFRESH_BACKUP_DIR" - else - rm -rf "$HARNESS_DIR" - rm -rf "$BACKUP_DIR" - fi - exit "$code" -} - -trap 'rollback_failed_install $?' ERR - -if [ ! -f "$STATE_FILE" ] || [ "$REBASE_BACKUP" = true ]; then - TMP_BACKUP_DIR="$(mktemp -d "$HARNESS_DIR/backup.tmp.XXXXXX")" - ACTIVE_BACKUP_DIR="$TMP_BACKUP_DIR" - ACTIVE_STATE_FILE="$TMP_BACKUP_DIR/state.env" - : > "$ACTIVE_STATE_FILE" - backup_path "scripts/perps/agentic" "SCRIPTS_EXISTED" - backup_path "app/core/AgenticService" "AGENTIC_SERVICE_EXISTED" - backup_path "package.json" "PACKAGE_JSON_EXISTED" - backup_path "app/core/NavigationService/NavigationService.ts" "NAVIGATION_SERVICE_EXISTED" - backup_path "app/components/Nav/App/App.tsx" "APP_TSX_EXISTED" - if [ "$REBASE_BACKUP" = true ] && [ -e "$BACKUP_DIR" ]; then - ARCHIVE_DIR="$HARNESS_DIR/backup-stale.$(date -u +%Y%m%dT%H%M%SZ)" - mkdir -p "$(dirname "$ARCHIVE_DIR")" - mv "$BACKUP_DIR" "$ARCHIVE_DIR" - echo "Archived stale mobile harness backup: $ARCHIVE_DIR" >&2 - else - rm -rf "$BACKUP_DIR" - fi - mkdir -p "$(dirname "$BACKUP_DIR")" - mv "$TMP_BACKUP_DIR" "$BACKUP_DIR" - STATE_FILE="$BACKUP_DIR/state.env" - ROLLBACK_BACKUP_DIR="$BACKUP_DIR" - ROLLBACK_STATE_FILE="$STATE_FILE" -else - REFRESH_BACKUP_DIR="$(mktemp -d "$HARNESS_DIR/refresh-backup.tmp.XXXXXX")" - ACTIVE_BACKUP_DIR="$REFRESH_BACKUP_DIR" - ACTIVE_STATE_FILE="$REFRESH_BACKUP_DIR/state.env" - : > "$ACTIVE_STATE_FILE" - backup_path "scripts/perps/agentic" "SCRIPTS_EXISTED" - backup_path "app/core/AgenticService" "AGENTIC_SERVICE_EXISTED" - backup_path "package.json" "PACKAGE_JSON_EXISTED" - backup_path "app/core/NavigationService/NavigationService.ts" "NAVIGATION_SERVICE_EXISTED" - backup_path "app/components/Nav/App/App.tsx" "APP_TSX_EXISTED" - ROLLBACK_BACKUP_DIR="$REFRESH_BACKUP_DIR" - ROLLBACK_STATE_FILE="$REFRESH_BACKUP_DIR/state.env" -fi - -INSTALL_MUTATING=true - -resolve_mobile_agentic_source -install_v1_runner_assets - -mkdir -p "$TARGET/scripts/perps" "$TARGET/app/core" -rsync -a --delete "$MOBILE_AGENTIC_SOURCE/" "$TARGET/scripts/perps/agentic/" -rm -rf "$TARGET/app/core/AgenticService" -mkdir -p "$TARGET/app/core/AgenticService" -while IFS= read -r -d '' overlay_file; do - rel="${overlay_file#$ADAPTER_DIR/app-overlay/app/core/AgenticService/}" - dest_rel="${rel%.patch}" - mkdir -p "$TARGET/app/core/AgenticService/$(dirname "$dest_rel")" - cp "$overlay_file" "$TARGET/app/core/AgenticService/$dest_rel" - # Exclude overlay *.test.* files: they are skills-repo tests for the overlay - # templates and must not ship into the product checkout, where the product's - # jest could pick them up on local runs. -done < <(find "$ADAPTER_DIR/app-overlay/app/core/AgenticService" -type f ! -name '*.test.*' -print0) - -node - "$TARGET" <<'NODE' -const fs = require('fs'); -const path = require('path'); - -const target = process.argv[2]; - -function patchPackageJson() { - const file = path.join(target, 'package.json'); - if (!fs.existsSync(file)) throw new Error(`missing ${file}`); - const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); - pkg.scripts = pkg.scripts || {}; - const desired = { - 'a:start': 'scripts/perps/agentic/start-metro.sh', - 'a:watch': 'scripts/perps/agentic/interactive-start.sh', - 'a:stop': 'scripts/perps/agentic/stop-metro.sh', - 'a:status': 'scripts/perps/agentic/app-state.sh status', - 'a:reload': 'scripts/perps/agentic/reload-metro.sh', - 'a:navigate': 'scripts/perps/agentic/app-navigate.sh', - 'a:ios': 'scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup', - 'a:android': 'scripts/perps/agentic/preflight.sh --platform android --mode fast --wallet-setup', - 'a:setup:ios': 'scripts/perps/agentic/preflight.sh --platform ios --mode clean --wallet-setup', - 'a:setup:android': 'scripts/perps/agentic/preflight.sh --platform android --mode clean --wallet-setup', - }; - let changed = false; - for (const [key, value] of Object.entries(desired)) { - if (pkg.scripts[key] !== value) { - pkg.scripts[key] = value; - changed = true; - } - } - if (changed) { - fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`); - } - return changed ? 'patched' : 'already-present'; -} - -function patchNavigation() { - const file = path.join(target, 'app/core/NavigationService/NavigationService.ts'); - if (!fs.existsSync(file)) throw new Error(`missing ${file}`); - let src = fs.readFileSync(file, 'utf8'); - if (src.includes('AgenticService.install')) return 'already-present'; - const marker = ' this.#navigation = this.#createReactAwareNavigation(navRef);\n'; - const insert = `${marker}\n if (__DEV__) {\n import('../AgenticService/AgenticService').then(\n ({ default: AgenticService }) => {\n AgenticService.install(navRef, this.#navigation);\n },\n );\n }\n`; - if (!src.includes(marker)) { - throw new Error(`cannot patch NavigationService.ts: marker not found`); - } - src = src.replace(marker, insert); - fs.writeFileSync(file, src); - return 'patched'; -} - -function patchApp() { - const file = path.join(target, 'app/components/Nav/App/App.tsx'); - if (!fs.existsSync(file)) throw new Error(`missing ${file}`); - let src = fs.readFileSync(file, 'utf8'); - let importStatus = 'already-present'; - if (!src.includes("core/AgenticService/AgentStepHud")) { - const marker = "import PerpsWebSocketHealthToast"; - const line = "import AgentStepHud from '../../../core/AgenticService/AgentStepHud';\n"; - if (src.includes(marker)) { - src = src.replace(marker, `${line}${marker}`); - } else { - throw new Error(`cannot patch App.tsx: import marker not found`); - } - importStatus = 'patched'; - } - let renderStatus = 'already-present'; - if (!src.includes('/m; - const match = src.match(marker); - if (!match) { - throw new Error(`cannot patch App.tsx: render marker not found`); - } - src = src.replace(marker, `${match[1]}{__DEV__ && }\n${match[0]}`); - renderStatus = 'patched'; - } - fs.writeFileSync(file, src); - return `${importStatus},${renderStatus}`; -} - -console.log(JSON.stringify({ - packageJson: patchPackageJson(), - navigation: patchNavigation(), - app: patchApp(), -})); -NODE - -if [ "$GIT_EXCLUDE" = true ]; then - echo "[recipe-harness] Adding local .git/info/exclude entries (removed on cleanup): $HARNESS_ROOT/, .skills-cache/, scripts/perps/agentic/, app/core/AgenticService/" - add_git_exclude_entry "$HARNESS_ROOT/" "$BACKUP_DIR/added-git-exclude" - add_git_exclude_entry ".skills-cache/" "$BACKUP_DIR/added-git-exclude" - add_git_exclude_entry "scripts/perps/agentic/" "$BACKUP_DIR/added-git-exclude" - add_git_exclude_entry "app/core/AgenticService/" "$BACKUP_DIR/added-git-exclude" -fi - -write_managed_hashes() { - local hash_file="$BACKUP_DIR/managed-hashes.tsv" - local rel - : > "$hash_file" - for rel in \ - "scripts/perps/agentic" \ - "app/core/AgenticService" \ - "package.json" \ - "app/core/NavigationService/NavigationService.ts" \ - "app/components/Nav/App/App.tsx"; do - printf '%s\t%s\n' "$rel" "$(hash_path "$rel")" >> "$hash_file" - done -} - -write_managed_hashes -INSTALL_MUTATING=false -trap - ERR -[ -n "$REFRESH_BACKUP_DIR" ] && rm -rf "$REFRESH_BACKUP_DIR" - -SOURCE_REV="$(git -C "$SKILL_DIR" rev-parse HEAD 2>/dev/null || echo unknown)" -CLEANUP_COMMAND="RECIPE_HARNESS_ROOT=$HARNESS_ROOT $(printf '%q' "$SCRIPT_DIR/cleanup.sh") --target $(printf '%q' "$TARGET")" -node -e ' - const fs = require("fs"); - const m = { - adapter: "mobile", - installedAt: new Date().toISOString(), - source: { - skillDir: process.argv[1], - skillRevision: process.argv[2], - runnerDir: process.argv[3], - runnerRevision: process.argv[4], - runnerSourceKind: process.argv[5], - adapterRuntime: process.argv[6], - mobileAgenticSource: process.argv[11] - }, - target: process.argv[7], - protocolVersion: "v1", - actionManifestPath: process.argv[13] + "/action-manifest.json", - runnerEntrypoint: process.argv[13] + "/runner/bin/metamask-recipe", - installedPaths: [process.argv[13] + "/runner", process.argv[13] + "/action-manifest.json", "scripts/perps/agentic", "app/core/AgenticService"], - patchedFiles: ["package.json", "app/core/NavigationService/NavigationService.ts", "app/components/Nav/App/App.tsx"], - backupDir: process.argv[8], - managedHashes: process.argv[8] + "/managed-hashes.tsv", - cleanupCommand: process.argv[14], - productDiffExcludes: [ - ":(exclude)" + process.argv[12], ":(exclude).skills-cache", - ":(exclude)scripts/perps/agentic", ":(exclude)app/core/AgenticService" - ] - }; - fs.writeFileSync(process.argv[10], JSON.stringify(m, null, 2) + "\n"); -' "$SKILL_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$BACKUP_DIR" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "${MOBILE_AGENTIC_SOURCE:-}" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND" - -echo "Installed mobile recipe harness: $HARNESS_DIR/manifest.json" diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/launch.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/launch.sh deleted file mode 100755 index 36edecc..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/launch.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -PLATFORM="ios" -PREFLIGHT_MODE="${RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE:-fast}" -ARTIFACTS="" -WALLET_SETUP="auto" -WALLET_FIXTURE=".agent/wallet-fixture.json" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --platform) PLATFORM="$2"; shift 2 ;; - --preflight-mode|--mode) PREFLIGHT_MODE="$2"; shift 2 ;; - --artifacts-dir) ARTIFACTS="$2"; shift 2 ;; - --wallet-setup) WALLET_SETUP="true"; shift ;; - --no-wallet-setup) WALLET_SETUP="false"; shift ;; - --wallet-fixture) WALLET_FIXTURE="$2"; shift 2 ;; - -h|--help) echo "Usage: launch.sh [--target ] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--artifacts-dir ] [--wallet-setup|--no-wallet-setup]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -case "$PLATFORM" in ios|android) ;; *) echo "Unknown --platform: $PLATFORM" >&2; exit 2 ;; esac -case "$PREFLIGHT_MODE" in fast|auto|default|rebuild-native|clean) ;; *) echo "Unknown --preflight-mode: $PREFLIGHT_MODE" >&2; exit 2 ;; esac - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi -TARGET="$(cd "$TARGET" && pwd)" -HARNESS_DIR="$(harness_dir "$TARGET" mobile)" -ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/launch/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS/logs" - -if [ ! -x "$TARGET/scripts/perps/agentic/preflight.sh" ]; then - echo "Mobile recipe harness is not installed in $TARGET. Run recipe-harness mobile install --target $TARGET first." >&2 - exit 1 -fi - -preflight_args=(scripts/perps/agentic/preflight.sh --platform "$PLATFORM" --mode "$PREFLIGHT_MODE") -fixture_status="MISSING_FIXTURES" -if [ -f "$TARGET/$WALLET_FIXTURE" ]; then - fixture_status="READY" - if [ "$WALLET_SETUP" != "false" ]; then - preflight_args+=(--wallet-setup --wallet-fixture "$WALLET_FIXTURE") - fi -elif [ "$WALLET_SETUP" = "true" ]; then - echo "Requested --wallet-setup but fixture is missing: $TARGET/$WALLET_FIXTURE" >&2 - exit 1 -fi - -echo "Launching Mobile harness runtime: platform=$PLATFORM mode=$PREFLIGHT_MODE target=$TARGET" | tee "$ARTIFACTS/logs/launch.log" -echo "Fixture status: $fixture_status ($WALLET_FIXTURE)" | tee -a "$ARTIFACTS/logs/launch.log" -set +e -( - cd "$TARGET" - bash "${preflight_args[@]}" -) 2>&1 | tee -a "$ARTIFACTS/logs/launch.log" -preflight_status=${PIPESTATUS[0]} -set -e - -status="pass" -if [ "$preflight_status" -ne 0 ]; then - status="fail" - app_state_status="skipped" -elif ( - cd "$TARGET" - bash scripts/perps/agentic/app-state.sh status -) > "$ARTIFACTS/logs/app-state.json" 2> "$ARTIFACTS/logs/app-state.err"; then - app_state_status="pass" -else - app_state_status="fail" - status="fail" -fi - -TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" STATUS_FOR_SUMMARY="$status" PREFLIGHT_STATUS="$preflight_status" APP_STATE_STATUS="$app_state_status" PLATFORM_FOR_SUMMARY="$PLATFORM" MODE_FOR_SUMMARY="$PREFLIGHT_MODE" FIXTURE_STATUS="$fixture_status" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const cp = require('child_process'); -const target = process.env.TARGET_FOR_SUMMARY; -const artifacts = process.env.ARTIFACTS_FOR_SUMMARY; -function run(cmd) { - try { return cp.execSync(cmd, { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } - catch { return ''; } -} -let appState = null; -try { appState = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/app-state.json'), 'utf8')); } catch {} -const watcherPort = run("bash -lc '. scripts/perps/agentic/lib/safe-env-parser.sh 2>/dev/null; load_js_env 2>/dev/null; printf \"%s\" \"${WATCHER_PORT:-8081}\"'") || '8081'; -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'mobile', - action: 'launch', - status: process.env.STATUS_FOR_SUMMARY, - platform: process.env.PLATFORM_FOR_SUMMARY, - preflightMode: process.env.MODE_FOR_SUMMARY, - preflight: { - status: Number(process.env.PREFLIGHT_STATUS) === 0 ? 'pass' : 'fail', - exitCode: Number(process.env.PREFLIGHT_STATUS), - logPath: path.join(artifacts, 'logs/launch.log'), - }, - runtimePolicy: { - nativeBuildPolicy: process.env.MODE_FOR_SUMMARY === 'fast' - ? 'fast mode reuses an installed matching app or shared cache and fails before native rebuild; use --preflight-mode auto/default only after explicit approval' - : 'this mode may run native build/setup work; caller must have recorded explicit approval before using it', - }, - target, - watcherPort, - fixtureStatus: process.env.FIXTURE_STATUS, - appControl: { - status: process.env.APP_STATE_STATUS, - route: appState?.route || null, - }, - cleanupCommand: `recipe-harness mobile cleanup --target ${target}`, - note: 'Launch starts/reuses the harness runtime only; it does not run a recipe or claim evidence validation.', - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -NODE - -echo "Mobile harness launch $status: $ARTIFACTS/summary.json" -[ "$status" = "pass" ] diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/lib/hash-helpers.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/lib/hash-helpers.sh deleted file mode 100644 index 7a84be9..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/lib/hash-helpers.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash - -digest_file() { - local file="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" | awk '{print $1}' - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then - openssl dgst -sha256 "$file" | awk '{print $NF}' - else - echo "Error: need shasum, sha256sum, or openssl for mobile harness hashing." >&2 - exit 1 - fi -} - -digest_stdin() { - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 | awk '{print $1}' - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then - openssl dgst -sha256 | awk '{print $NF}' - else - echo "Error: need shasum, sha256sum, or openssl for mobile harness hashing." >&2 - exit 1 - fi -} - -hash_path() { - local rel="$1" - if [ ! -e "$TARGET/$rel" ]; then - printf 'MISSING' - elif [ -d "$TARGET/$rel" ]; then - ( - cd "$TARGET" - find "$rel" -type f | LC_ALL=C sort | while IFS= read -r file; do - printf '%s %s\n' "$(digest_file "$file")" "$file" - done | digest_stdin - ) - else - (cd "$TARGET" && digest_file "$rel") - fi -} diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/live.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/live.sh deleted file mode 100755 index a90cb91..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/live.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -PLATFORM="ios" -PREFLIGHT_MODE="${RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE:-fast}" -ARTIFACTS="" -WALLET_SETUP="auto" -WALLET_FIXTURE=".agent/wallet-fixture.json" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --platform) PLATFORM="$2"; shift 2 ;; - --preflight-mode|--mode) PREFLIGHT_MODE="$2"; shift 2 ;; - --artifacts-dir) ARTIFACTS="$2"; shift 2 ;; - --wallet-setup) WALLET_SETUP="true"; shift ;; - --no-wallet-setup) WALLET_SETUP="false"; shift ;; - --wallet-fixture) WALLET_FIXTURE="$2"; shift 2 ;; - -h|--help) echo "Usage: live.sh [--target ] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--artifacts-dir ] [--wallet-setup|--no-wallet-setup]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -case "$PLATFORM" in ios|android) ;; *) echo "Unknown --platform: $PLATFORM" >&2; exit 2 ;; esac -case "$PREFLIGHT_MODE" in fast|auto|default|rebuild-native|clean) ;; *) echo "Unknown --preflight-mode: $PREFLIGHT_MODE" >&2; exit 2 ;; esac - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi -TARGET="$(cd "$TARGET" && pwd)" -ARTIFACTS="${ARTIFACTS:-$(harness_dir "$TARGET" mobile)/live/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS" - -launch_args=(--target "$TARGET" --platform "$PLATFORM" --preflight-mode "$PREFLIGHT_MODE" --artifacts-dir "$ARTIFACTS/launch") -case "$WALLET_SETUP" in - true) launch_args+=(--wallet-setup) ;; - false) launch_args+=(--no-wallet-setup) ;; -esac -launch_args+=(--wallet-fixture "$WALLET_FIXTURE") - -echo "Mobile live validation command:" -echo " recipe-harness live --platform $PLATFORM --preflight-mode $PREFLIGHT_MODE" -echo "Launch artifacts: $ARTIFACTS/launch" -echo "Verify artifacts: $ARTIFACTS/verify" - -set +e -"$SCRIPT_DIR/launch.sh" "${launch_args[@]}" -launch_status=$? -set -e - -verify_status=1 -if [ "$launch_status" -eq 0 ]; then - set +e - "$SCRIPT_DIR/verify.sh" --target "$TARGET" --platform "$PLATFORM" --preflight-mode "$PREFLIGHT_MODE" --no-auto-start --artifacts-dir "$ARTIFACTS/verify" - verify_status=$? - set -e -else - echo "Skipping Mobile live verify because launch failed; see $ARTIFACTS/launch/summary.json" >&2 -fi - -TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" PLATFORM_FOR_SUMMARY="$PLATFORM" MODE_FOR_SUMMARY="$PREFLIGHT_MODE" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const artifacts = process.env.ARTIFACTS_FOR_SUMMARY; -const launchSummary = path.join(artifacts, 'launch', 'summary.json'); -const verifySummary = path.join(artifacts, 'verify', 'summary.json'); -const launchStatus = Number(process.env.LAUNCH_STATUS); -const verifyStatus = Number(process.env.VERIFY_STATUS); -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'mobile', - action: 'live', - status: launchStatus === 0 && verifyStatus === 0 ? 'pass' : 'fail', - target: process.env.TARGET_FOR_SUMMARY, - platform: process.env.PLATFORM_FOR_SUMMARY, - preflightMode: process.env.MODE_FOR_SUMMARY, - launch: { exitCode: launchStatus, summaryPath: fs.existsSync(launchSummary) ? launchSummary : null }, - verify: { exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null }, - easyCommand: `/scripts/recipe-harness live --platform ${process.env.PLATFORM_FOR_SUMMARY} --preflight-mode ${process.env.MODE_FOR_SUMMARY}`, - note: 'Runs launch then live verify so a developer can validate app startup, CDP/app-state bridge, screenshot capture, and tiny recipe control from one skill-owned command.', - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -NODE - -echo "Mobile live validation summary: $ARTIFACTS/summary.json" -[ "$launch_status" -eq 0 ] && [ "$verify_status" -eq 0 ] diff --git a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/verify.sh b/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/verify.sh deleted file mode 100755 index bf1b06d..0000000 --- a/domains/agentic/skills/recipe-harness/adapters/mobile/scripts/verify.sh +++ /dev/null @@ -1,529 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET="$PWD" -ARTIFACTS="" -STATIC_ONLY=false -AUTO_START="${RECIPE_HARNESS_MOBILE_AUTO_START:-false}" -PLATFORM="${RECIPE_HARNESS_PLATFORM:-${PLATFORM:-ios}}" -PREFLIGHT_MODE="${RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE:-fast}" -while [ "$#" -gt 0 ]; do - case "$1" in - --target) TARGET="$2"; shift 2 ;; - --artifacts-dir) ARTIFACTS="$2"; shift 2 ;; - --static-only) STATIC_ONLY=true; shift ;; - --platform) PLATFORM="$2"; shift 2 ;; - --preflight-mode) PREFLIGHT_MODE="$2"; shift 2 ;; - --auto-start) AUTO_START=true; shift ;; - --no-auto-start) AUTO_START=false; shift ;; - -h|--help) echo "Usage: verify.sh [--target ] [--artifacts-dir ] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--auto-start|--no-auto-start]"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done -case "$PREFLIGHT_MODE" in - fast|auto|default|rebuild-native|clean) ;; - *) echo "Unknown --preflight-mode: $PREFLIGHT_MODE" >&2; exit 2 ;; -esac -case "$PLATFORM" in - ios|android) ;; - *) echo "Unknown --platform: $PLATFORM (expected ios or android)" >&2; exit 2 ;; -esac - -case "$AUTO_START" in - 1|true|TRUE|True|yes|YES|Yes|on|ON|On) AUTO_START=true ;; - 0|false|FALSE|False|no|NO|No|off|OFF|Off|"") AUTO_START=false ;; - *) echo "Unknown RECIPE_HARNESS_MOBILE_AUTO_START value: $AUTO_START" >&2; exit 2 ;; -esac - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../../scripts/lib/harness-path.sh"; do - [ -f "$_hp" ] && { . "$_hp"; break; } -done -unset _hp -if ! command -v harness_root >/dev/null 2>&1; then - echo "recipe-harness: shared lib scripts/lib/harness-path.sh not found; reinstall the harness." >&2 - exit 1 -fi -TARGET="$(cd "$TARGET" && pwd)" -HARNESS_ROOT="$(harness_root)" -HARNESS_REL="$HARNESS_ROOT/mobile" -HARNESS_DIR="$(harness_dir "$TARGET" mobile)" -RUNNER_BIN="$HARNESS_DIR/runner/bin/metamask-recipe" -ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/verify/$(date -u +%Y%m%dT%H%M%SZ)}" -mkdir -p "$ARTIFACTS/logs" - -status="pass" -checks=() - -add_note() { - printf '%s\n' "$1" >> "$ARTIFACTS/logs/runtime-notes.txt" -} - -fixture_status_json() { - TARGET_FOR_FIXTURE="$TARGET" node <<'NODE' -const fs = require('fs'); -const crypto = require('crypto'); -const path = require('path'); -const target = process.env.TARGET_FOR_FIXTURE; -const candidates = [ - '.agent/wallet-fixture.json', - 'scripts/perps/agentic/wallet-fixture.json', -].map((rel) => path.join(target, rel)); -const fixture = candidates.find((file) => fs.existsSync(file)); -if (!fixture) { - const example = path.join(target, 'scripts/perps/agentic/wallet-fixture.example.json'); - console.log(JSON.stringify({ - status: 'MISSING_FIXTURES', - message: 'No wallet fixture found. This run may spend time repairing wallet/perps state manually. For a clean isolated sandbox, create .agent/wallet-fixture.json from scripts/perps/agentic/wallet-fixture.example.json.', - setupCommand: fs.existsSync(example) - ? 'cp scripts/perps/agentic/wallet-fixture.example.json .agent/wallet-fixture.json' - : null, - })); - process.exit(0); -} -const fixtureRaw = fs.readFileSync(fixture); -let parsed = null; -let valid = false; -let accountCount = null; -let hasPassword = false; -try { - parsed = JSON.parse(fixtureRaw.toString('utf8')); - valid = true; - accountCount = Array.isArray(parsed.accounts) ? parsed.accounts.length : 0; - hasPassword = typeof parsed.password === 'string' && parsed.password.length > 0; -} catch { - valid = false; -} -const stat = fs.statSync(fixture); -console.log(JSON.stringify({ - status: valid && hasPassword && accountCount > 0 ? 'READY' : 'STALE_OR_INVALID', - path: path.relative(target, fixture), - sha256: crypto.createHash('sha256').update(fixtureRaw).digest('hex'), - modifiedAt: stat.mtime.toISOString(), - accountCount, - hasPassword, - message: valid && hasPassword && accountCount > 0 - ? `Fixture status: READY (${path.relative(target, fixture)}, accounts=${accountCount}).` - : `Fixture status: STALE_OR_INVALID (${path.relative(target, fixture)}). Validate password/accounts before relying on a clean sandbox.`, -})); -NODE -} - -port_holder_json() { - local port="$1" - PORT_FOR_STATUS="$port" node <<'NODE' -const cp = require('child_process'); -const port = process.env.PORT_FOR_STATUS; -function run(cmd) { - try { return cp.execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } - catch { return ''; } -} -const pid = run(`lsof -iTCP:${port} -sTCP:LISTEN -t | head -1`); -let command = ''; -// Validate pid is numeric before interpolating it into the `ps -p` shell string. -if (/^[0-9]+$/.test(pid)) command = run(`ps -p ${pid} -o command=`); -console.log(JSON.stringify({ - port, - listening: Boolean(pid), - pid: pid || null, - command: command || null, - metroStatusReachable: null, - metroHttpProbeSkipped: true, - note: 'HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile.', -})); -NODE -} - -fixture_check_json() { - local fixture_status_path="$1" - node - "$fixture_status_path" <<'NODE' -const fs = require('fs'); -const v = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -console.log(JSON.stringify({ - name: 'fixture status', - status: v.status === 'READY' ? 'pass' : 'warn', - detail: v.path || v.status || '', - message: v.message || v.status, -})); -NODE -} - -watcher_port() { - TARGET_FOR_WATCHER_PORT="$TARGET" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const target = process.env.TARGET_FOR_WATCHER_PORT; -let port = process.env.WATCHER_PORT || '8081'; -for (const file of ['.js.env', '.env', '.env.local']) { - const full = path.join(target, file); - if (!fs.existsSync(full)) continue; - const text = fs.readFileSync(full, 'utf8'); - const match = text.match(/^\s*(?:export\s+)?WATCHER_PORT=(["']?)([0-9]+)\1/m); - if (match) { port = match[2]; break; } -} -console.log(port); -NODE -} - -# Resolve a runtime env var (e.g. IOS_SIMULATOR, ADB_SERIAL) from the process -# env or the target repo's .js.env so the runner can bind device-scoped proof -# (screenshots) to the same device as the bridge commands. -jsenv_value() { - TARGET_FOR_JSENV="$TARGET" JSENV_NAME="$1" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const target = process.env.TARGET_FOR_JSENV; -const name = process.env.JSENV_NAME; -let value = process.env[name] || ''; -if (!value) { - const re = new RegExp("^\\s*(?:export\\s+)?" + name + "=([\"']?)([^\"'\\n]+)\\1", "m"); - for (const file of ['.js.env', '.env', '.env.local']) { - const full = path.join(target, file); - if (!fs.existsSync(full)) continue; - const match = fs.readFileSync(full, 'utf8').match(re); - if (match) { value = match[2]; break; } - } -} -process.stdout.write(value); -NODE -} - -check_file() { - local rel="$1" - if [ -e "$TARGET/$rel" ]; then - checks+=("{\"name\":\"$rel\",\"status\":\"pass\"}") - else - checks+=("{\"name\":\"$rel\",\"status\":\"fail\"}") - status="fail" - fi -} - -check_file "$HARNESS_REL/manifest.json" -check_file "$HARNESS_REL/action-manifest.json" -check_file "$HARNESS_REL/runner/bin/metamask-recipe" -check_file "package.json" -check_file "scripts/perps/agentic/cdp-bridge.js" -check_file "app/core/AgenticService/AgenticService.ts" - -if ! grep -q "AgenticService.install" "$TARGET/app/core/NavigationService/NavigationService.ts" 2>/dev/null; then - checks+=("{\"name\":\"NavigationService patch\",\"status\":\"fail\"}") - status="fail" -else - checks+=("{\"name\":\"NavigationService patch\",\"status\":\"pass\"}") -fi - -if ! grep -q "AgentStepHud" "$TARGET/app/components/Nav/App/App.tsx" 2>/dev/null; then - checks+=("{\"name\":\"App AgentStepHud patch\",\"status\":\"fail\"}") - status="fail" -else - checks+=("{\"name\":\"App AgentStepHud patch\",\"status\":\"pass\"}") -fi - -# Drift detection (read-only): a product-owned checkout keeps its own -# app/core/AgenticService source; install never overwrites it. Compare the -# in-repo AgenticService/HUD against the bundled overlay and WARN when the -# branch is behind the skills version, so a stale HUD is visible at verify -# time. Never fails the run and never mutates product source. -overlay_drift_check="$( - SCRIPT_DIR_FOR_DRIFT="$SCRIPT_DIR" TARGET_FOR_DRIFT="$TARGET" node <<'NODE' -const fs = require('fs'); -const path = require('path'); -const name = 'agentic overlay matches skills (HUD freshness)'; -const overlayDir = path.join(process.env.SCRIPT_DIR_FOR_DRIFT, '..', 'app-overlay', 'app', 'core', 'AgenticService'); -const targetDir = path.join(process.env.TARGET_FOR_DRIFT, 'app', 'core', 'AgenticService'); -let checked = 0; -const drifted = []; -const missing = []; -try { - for (const entry of fs.readdirSync(overlayDir)) { - if (!entry.endsWith('.patch')) continue; - // install.sh excludes *.test.* from the product copy, so these overlay test - // files are intentionally absent in the target — skip them or they WARN as - // spurious "absent in repo" drift on every verify. - if (/\.test\./u.test(entry)) continue; - const base = entry.slice(0, -'.patch'.length); - const targetFile = path.join(targetDir, base); - if (!fs.existsSync(targetFile)) { missing.push(base); continue; } - checked += 1; - if (fs.readFileSync(path.join(overlayDir, entry), 'utf8') !== fs.readFileSync(targetFile, 'utf8')) { - drifted.push(base); - } - } -} catch (error) { - process.stdout.write(JSON.stringify({ name, status: 'warn', detail: 'overlay compare skipped: ' + String((error && error.message) || error) })); - process.exit(0); -} -// checked === 0 means the in-repo harness is absent; install/presence checks own that case. -if (checked === 0) { - process.stdout.write(JSON.stringify({ name, status: 'pass' })); -} else if (drifted.length || missing.length) { - const parts = []; - if (drifted.length) parts.push('behind skills: ' + drifted.join(', ')); - if (missing.length) parts.push('absent in repo: ' + missing.join(', ')); - process.stdout.write(JSON.stringify({ name, status: 'warn', detail: parts.join('; ') + ' — merge latest or run recipe-harness install --force-overlay to refresh the in-repo HUD/AgenticService' })); -} else { - process.stdout.write(JSON.stringify({ name, status: 'pass' })); -} -NODE -)" -checks+=("$overlay_drift_check") - -if node - "$TARGET/package.json" <<'NODE' -const fs = require('fs'); -const file = process.argv[2]; -const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); -const scripts = pkg.scripts || {}; -const required = ['a:start', 'a:status', 'a:ios', 'a:android']; -const hasRequired = required.every((name) => scripts[name]); -const safeLaunch = String(scripts['a:ios'] || '').includes('--mode fast') - && String(scripts['a:android'] || '').includes('--mode fast'); -process.exit(hasRequired && safeLaunch ? 0 : 1); -NODE -then - checks+=("{\"name\":\"package a:* ergonomic aliases use fast mode\",\"status\":\"pass\"}") -else - checks+=("{\"name\":\"package a:* ergonomic aliases use fast mode\",\"status\":\"warn\"}") -fi - -run_with_timeout() { - local log_path="$1" - local timeout_s="$2" - shift 2 - [[ "$timeout_s" =~ ^[0-9]+$ ]] || { - echo "Invalid timeout seconds: $timeout_s" >&2 - return 2 - } - "$@" > "$log_path" 2>&1 & - local pid=$! - local waited=0 - while kill -0 "$pid" 2>/dev/null; do - if [ "$waited" -ge "$timeout_s" ]; then - echo "Timed out after ${timeout_s}s: $*" >> "$log_path" - kill "$pid" 2>/dev/null || true - sleep 1 - kill -9 "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - return 124 - fi - sleep 1 - waited=$((waited + 1)) - done - wait "$pid" -} - -live_status_ok() { - local log_path="$1" - run_with_timeout "$log_path" 20 bash -lc 'cd "$1" && bash scripts/perps/agentic/app-state.sh status' bash "$TARGET" && node - "$log_path" <<'NODE' -const fs = require('fs'); -const raw = fs.readFileSync(process.argv[2], 'utf8').trim(); -const start = raw.search(/[\[{]/); -if (start < 0) process.exit(1); -const value = JSON.parse(raw.slice(start)); -if (!value || Array.isArray(value) || !value.route || !value.route.name) process.exit(1); -NODE -} - -preflight_supports_mode_flag() { - grep -q -- '--mode)' "$TARGET/scripts/perps/agentic/preflight.sh" 2>/dev/null -} - -managed_native_config_hash() { - ( - cd "$TARGET" - for rel in package.json tsconfig.json ios/Podfile.lock; do - if [ -e "$rel" ]; then - shasum -a 256 "$rel" - else - printf 'missing %s\n' "$rel" - fi - done - ) -} - -ensure_live_runtime() { - live_status_ok "$ARTIFACTS/logs/app-state-precheck.log" && return 0 - if [ "$AUTO_START" != true ]; then - echo "Mobile runtime is not recipe-controllable and auto-start is disabled; use recipe-harness live/launch, pass --auto-start, or set RECIPE_HARNESS_MOBILE_AUTO_START=1 after explicit runtime-start approval." >&2 - return 1 - fi - - echo "Mobile runtime is not recipe-controllable; starting ${PLATFORM} app via harness preflight (--mode ${PREFLIGHT_MODE})..." >&2 - if [ "$PREFLIGHT_MODE" = "fast" ]; then - if ! preflight_supports_mode_flag; then - cat >&2 <<'EOF' -Refusing mobile auto-start in fast mode: target scripts/perps/agentic/preflight.sh does not support --mode. -Running it would silently fall back to legacy/default behavior and may mutate package.json, -tsconfig.json, ios/Podfile.lock, Pods, or native build state. Start the app manually or install -a product-owned harness preflight that supports --mode fast. -EOF - return 1 - fi - echo " Build policy: fast reuses an installed matching app or shared cache and fails before a native rebuild." >&2 - echo " To permit a rebuild, rerun with --preflight-mode auto or RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE=auto after explicit caller/human approval." >&2 - else - echo " Build policy: ${PREFLIGHT_MODE} may run native build/setup work; use only after explicit caller/human approval." >&2 - fi - managed_native_config_hash > "$ARTIFACTS/logs/native-config-before-autostart.sha256" - preflight_args=(scripts/perps/agentic/preflight.sh --platform "$PLATFORM" --mode "$PREFLIGHT_MODE") - if [ -f "$TARGET/.agent/wallet-fixture.json" ]; then - preflight_args+=(--wallet-setup --wallet-fixture .agent/wallet-fixture.json) - else - echo " Fixture status: MISSING_FIXTURES. Starting without wallet setup; state repair may be slower/flakier." >&2 - fi - if ( - cd "$TARGET" - EXPO_NO_TYPESCRIPT_SETUP=1 bash "${preflight_args[@]}" - ) 2>&1 | tee "$ARTIFACTS/logs/auto-start.log"; then - managed_native_config_hash > "$ARTIFACTS/logs/native-config-after-autostart.sha256" - if ! cmp -s "$ARTIFACTS/logs/native-config-before-autostart.sha256" "$ARTIFACTS/logs/native-config-after-autostart.sha256"; then - echo "Mobile auto-start mutated package/native config; refusing to treat runtime as verified. See logs/native-config-*-autostart.sha256." >&2 - return 1 - fi - : > "$ARTIFACTS/logs/harness-started-runtime" - else - managed_native_config_hash > "$ARTIFACTS/logs/native-config-after-autostart.sha256" - return 1 - fi -} - -if [ "$STATIC_ONLY" = false ]; then - if ensure_live_runtime; then - checks+=('{"name":"mobile runtime controllable precheck","status":"pass"}') - else - checks+=('{"name":"mobile runtime controllable precheck","status":"fail","detail":"see logs/app-state-precheck.log or logs/auto-start.log"}') - status="fail" - fi - - fixture_json="$(fixture_status_json)" - printf '%s\n' "$fixture_json" > "$ARTIFACTS/logs/fixture-status.json" - fixture_check_json="$(fixture_check_json "$ARTIFACTS/logs/fixture-status.json")" - fixture_message="$(node -e 'const v=JSON.parse(process.argv[1]); console.log(v.message || v.detail);' "$fixture_check_json")" - echo "$fixture_message" >&2 - add_note "$fixture_message" - checks+=("$fixture_check_json") - - port="$(watcher_port)" - # Reject a non-numeric port before it is interpolated into a shell command - # (port_holder_json runs `lsof -iTCP:${port}`). The .env path is regex-guarded, - # but the WATCHER_PORT env path is not, so validate here. - case "$port" in - ""|*[!0-9]*) echo "Refusing mobile verify: resolved watcher port is not numeric: '$port' (check WATCHER_PORT)" >&2; exit 2 ;; - esac - port_holder_json "$port" > "$ARTIFACTS/logs/port-holder.json" - - cat > "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" <<'JSON' -{ - "schema_version": 1, - "title": "Mobile v1 runner live bridge smoke", - "description": "Verifies the installed MetaMask runner can drive the React Native debug bridge without using the legacy recipe graph.", - "validate": { - "workflow": { - "entry": "status", - "nodes": { - "status": { "action": "app.status", "intent": "Read Mobile app status through the v1 runner", "next": "cdp-probe" }, - "cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "required": true, "timeout_ms": 15000, "next": "wallet-setup" }, - "wallet-setup": { "action": "metamask.wallet.setup", "intent": "Prepare the wallet fixture for the live bridge smoke", "timeout_ms": 45000, "next": "wallet-unlock" }, - "wallet-unlock": { "action": "metamask.wallet.ensure_unlocked", "intent": "Unlock the wallet through the manifest-declared action", "timeout_ms": 45000, "next": "wallet-read" }, - "wallet-read": { "action": "metamask.wallet.read_state", "intent": "Read wallet state before navigating to the wallet view", "timeout_ms": 45000, "next": "navigate-wallet" }, - "navigate-wallet": { "action": "ui.navigate", "intent": "Open the wallet view through the UI navigation action", "route": "WalletView", "timeout_ms": 45000, "next": "wait-wallet" }, - "wait-wallet": { "action": "ui.wait_for", "intent": "Wait until the wallet screen is present", "test_id": "wallet-screen", "expected": "present", "timeout_ms": 45000, "next": "hud-smoke" }, - "hud-smoke": { "action": "app.hud", "status": "running", "intent": "Show the Mobile live bridge smoke HUD", "progress": { "current": 1, "total": 1 }, "timeout_ms": 45000, "next": "screenshot" }, - "screenshot": { "action": "ui.screenshot", "intent": "Capture proof that the wallet screen is visible", "path": "screenshots/mobile-v1-live-smoke.png", "timeout_ms": 45000, "next": "done" }, - "done": { "action": "end", "status": "pass" } - } - } - } -} -JSON - - ios_simulator_resolved="$(jsenv_value IOS_SIMULATOR)" - adb_serial_resolved="$(jsenv_value ADB_SERIAL)" - if ( - cd "$TARGET" - METAMASK_RECIPE_AUTO_HUD=0 \ - IOS_SIMULATOR="${IOS_SIMULATOR:-$ios_simulator_resolved}" \ - ADB_SERIAL="${ADB_SERIAL:-$adb_serial_resolved}" \ - ANDROID_SERIAL="${ANDROID_SERIAL:-$adb_serial_resolved}" \ - "$RUNNER_BIN" run "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" --adapter mobile --project-root "$TARGET" --artifacts-dir "$ARTIFACTS/runner-live-smoke" --json - ) > "$ARTIFACTS/logs/runner-live-smoke.log" 2>&1; then - checks+=("{\"name\":\"runner v1 live bridge smoke\",\"status\":\"pass\"}") - else - checks+=("{\"name\":\"runner v1 live bridge smoke\",\"status\":\"fail\",\"detail\":\"see logs/runner-live-smoke.log\"}") - add_note "Runner v1 live bridge smoke failed; inspect logs/runner-live-smoke.log and runner-live-smoke/trace.json." - status="fail" - fi -fi - -RECIPE_HARNESS_PREFLIGHT_MODE="$PREFLIGHT_MODE" RECIPE_HARNESS_ROOT_EXCLUDE="$HARNESS_ROOT" node - "$ARTIFACTS" "$TARGET" "$status" "${checks[@]}" <<'NODE' -const fs = require('fs'); -const path = require('path'); -const cp = require('child_process'); -const [artifacts, target, status, ...checks] = process.argv.slice(2); -const parsedChecks = checks.map((entry) => JSON.parse(entry)); -let fixtureStatus = null; -let portHolder = null; -let runtimeNotes = []; -const startedRuntime = fs.existsSync(path.join(artifacts, 'logs/harness-started-runtime')); -try { fixtureStatus = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/fixture-status.json'), 'utf8')); } catch {} -try { portHolder = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/port-holder.json'), 'utf8')); } catch {} -try { runtimeNotes = fs.readFileSync(path.join(artifacts, 'logs/runtime-notes.txt'), 'utf8').trim().split('\n').filter(Boolean); } catch {} -function runGit(args) { - try { - return cp.execFileSync('git', ['-C', target, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); - } catch (error) { - // Git metadata is diagnostic-only; non-git targets still produce a usable verify summary. - return null; - } -} -const harnessRootExclude = process.env.RECIPE_HARNESS_ROOT_EXCLUDE || 'temp/agentic/recipe-harness'; -const statusShort = runGit(['status', '--short', '--', '.', `:(exclude)${harnessRootExclude}`, ':(exclude).skills-cache']); -const gitStatus = { - branch: runGit(['branch', '--show-current']), - head: runGit(['rev-parse', '--short', 'HEAD']), - dirtyCount: statusShort ? statusShort.split('\n').filter(Boolean).length : 0, - dirtyPreview: statusShort ? statusShort.split('\n').filter(Boolean).slice(0, 25) : [], -}; -const liveRuntimeCheck = parsedChecks.find((check) => check.name === 'runner v1 live bridge smoke'); -const runtimeOwner = !portHolder - ? 'static-only' - : startedRuntime - ? 'harness-owned' - : portHolder.listening - ? (liveRuntimeCheck?.status === 'pass' ? 'compatible-external-or-harness' : 'incompatible-external-or-stale') - : 'none'; -const recipeControllable = liveRuntimeCheck?.status === 'pass'; -fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({ - adapter: 'mobile', - status, - runtimeClassification: { - runtimeOwner, - recipeControllable, - startedByVerify: startedRuntime, - }, - cleanupOwnership: { - mayStop: startedRuntime, - reason: startedRuntime - ? 'verify launched the runtime through harness preflight' - : 'verify did not launch this runtime; do not stop human-owned or pre-existing processes automatically', - }, - gitStatus, - runtimePolicy: { - preflightMode: process.env.RECIPE_HARNESS_PREFLIGHT_MODE || 'fast', - nativeBuildPolicy: (process.env.RECIPE_HARNESS_PREFLIGHT_MODE || 'fast') === 'fast' - ? 'fast mode reuses an installed matching app or shared cache and fails before native rebuild; use --preflight-mode auto/default only after explicit approval' - : 'this mode may run native build/setup work; caller must have recorded explicit approval before using it', - }, - fixtureStatus, - portHolder, - runtimeNotes, - checks: parsedChecks, - generatedAt: new Date().toISOString(), -}, null, 2)}\n`); -fs.writeFileSync(path.join(artifacts, 'artifact-manifest.json'), `${JSON.stringify({ - artifacts: fs.readdirSync(artifacts).map((name) => ({ path: name })), -}, null, 2)}\n`); -NODE - -echo "Mobile harness verify $status: $ARTIFACTS/summary.json" -[ "$status" = "pass" ] diff --git a/domains/agentic/skills/recipe-harness/references/contract.md b/domains/agentic/skills/recipe-harness/references/contract.md deleted file mode 100644 index 3315faa..0000000 --- a/domains/agentic/skills/recipe-harness/references/contract.md +++ /dev/null @@ -1,89 +0,0 @@ -# Recipe Harness Contract - -## Manifest - -Each install writes `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//manifest.json` in the target checkout. - -Required fields: - -- `adapter`: `mobile` or `extension` -- `installedAt` -- `source`: skill UX wrapper path/revision plus resolved runner source path/revision/kind when available. Prefer `METAMASK_RECIPE_RUNNER_SOURCE` for local development; otherwise the skill may use the cached public `deeeed/metamask-recipe-runner` fallback. That personal-account fallback is temporary for ADR-58 proof-of-concept testing until the runner is fully validated and migrated to the MetaMask organization. The skills repo does not own the runner runtime. -- `target` -- `installedPaths` -- `patchedFiles` (may be an empty array for adapters that only copy ignored runtime files) -- `backupDir` -- `cleanupCommand` -- `productDiffExcludes` - -## Overlay Source Files - -Mobile app overlay templates under `adapters/mobile/app-overlay/` use `.patch` suffixed filenames such as `AgenticService.ts.patch`. They are full overlay templates, not TypeScript files meant to compile inside the skills repo. The installer strips the `.patch` suffix when copying them into the target checkout. This keeps editors and reviewers from treating target-specific imports as broken skills-repo source. - -## Verification - -Verification writes artifacts under `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//verify/`. - -Every live verification must classify the runtime before trusting evidence: - -- `runtimeOwner`: harness-owned, compatible external, incompatible external, or unknown when derivable from ports/processes/manifest/profile; -- CDP reachability and selected target metadata; -- recipe bridge reachability; -- native screenshot capability or the reason fallback evidence would be used; -- Metro/webpack log locations; -- fixture/profile status (`READY`, `MISSING_FIXTURES`, or `STALE_OR_INVALID`); -- cleanup ownership, so agents know what they can safely stop at the end. - -Build/reuse rule: verification must not silently kick off an expensive native/full build when a runtime is missing or incompatible. Prefer reuse, installed-app fingerprint checks, shared build-cache artifacts, Expo/native prebuild artifacts, and Extension watch output. Mobile live verify defaults to `fast` preflight mode, which fails before native rebuild; modes that can rebuild (`auto`, `rebuild-native`, `clean`) require an explicit caller/human decision and should be recorded in the evidence. - -Mobile verification should prove, when a live app is available: - -- `scripts/perps/agentic/**` backing scripts are present from the product checkout or an explicit external Mobile bridge source; they are not bundled in the skills repo. -- direct script entrypoints work; harness automation must not depend on `yarn a:*`. -- `package.json` exposes optional `a:*` aliases that point at the product/external backing scripts when overlay install is used. -- CDP connects. -- `globalThis.__AGENTIC__` exists. -- route read works. -- `scripts/perps/agentic/app-state.sh status` works. -- wallet fixture setup/unlock works when fixture data exists. -- screenshot capture works. -- a tiny recipe can emit summary, trace, and artifact manifest. -- externally-started Metro/app states are detected as compatible only if the recipe bridge and screenshot capture work; otherwise verification must relaunch/reconnect through the harness path or fail with actionable diagnostics. - -Extension verification should prove: - -- runner files are installed. -- CDP/browser connection works when a browser is available. -- extension build readiness is derived from `dist/chrome/manifest.json` so - historical MV2/MV3 commits are handled without hardcoding current output - filenames. -- one non-UI sample recipe runs. -- one UI/browser sample recipe runs when feasible. -- product diff excludes harness files. -- externally-started webpack/Chrome/CDP states are detected as compatible only if the loaded extension target is recipe-controllable and screenshot/evidence capture works; otherwise verification must relaunch/reconnect through the harness path or fail with actionable diagnostics. - -## Recipe authoring boundary - -The skills repo is only the installer/invoker. Recipe semantics come from -Farmslot Recipe Protocol v1 and the resolved MetaMask recipe runner manifest. - -- Read the runner action manifest before writing a recipe; only manifest-listed - actions are callable. -- Use `metamask.*` actions for reproducible setup/teardown, direct supported - product/controller operations, and read/assert checks. -- Use official `ui.*` actions for any human-visible proof path: pressing a - button, entering an input/keypad value, scrolling an element into view, and - capturing screenshots. -- Do not use direct controller/CDP calls to replace the UI path for a visual - acceptance criterion. Controller/API calls are acceptable for setup when the - recipe then proves the resulting state. -- Drag/swipe proof is not available until the runner manifest advertises - `ui.gesture` and live action-validation proves it on Mobile and Extension. - -## Source Revision Caveat - -When install runs from a copied installed skill directory or unpacked runner package rather than a git checkout, `source.skillRevision` or `source.runnerRevision` may be `unknown`. Treat `source.skillDir`, `source.runnerDir`, `source.runnerSourceKind`, adapter name, manifest timestamp, and the PR/branch that installed the skill as the audit trail in that case. - -## Static vs Live Verification - -Static verification can prove install shape and idempotency only. Live Extension verification requires `--cdp-port`; if it is omitted outside `--static-only`, verification must fail or report `liveMode: missing-cdp` so agents cannot claim runtime proof from static checks. diff --git a/domains/agentic/skills/recipe-harness/repos/metamask-extension.md b/domains/agentic/skills/recipe-harness/repos/metamask-extension.md deleted file mode 100644 index 430c38e..0000000 --- a/domains/agentic/skills/recipe-harness/repos/metamask-extension.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-harness ---- - -# MetaMask Extension - -Use the Extension adapter for `metamask-extension` checkouts, especially historical commits or slots where the recipe runner is absent. - -## Commands - -```bash -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension install --target . -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension launch --target . --cdp-port -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension live --target . --cdp-port --launch-existing-dist -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension verify --target . --cdp-port -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension verify --target . --static-only -.agents/skills/mms-recipe-harness/scripts/recipe-harness.sh extension cleanup --target . -``` - -The same `scripts/recipe-harness.sh` path is also mirrored under `.claude/skills/mms-recipe-harness/` and `.cursor/rules/mms-recipe-harness/` for Claude/Cursor operators; examples use `.agents/skills` because Codex reads that tree. - -If running from the source skills checkout instead, use: - -```bash -domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh extension install --target /path/to/metamask-extension -domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh extension launch --target /path/to/metamask-extension --cdp-port 9222 -domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh extension live --target /path/to/metamask-extension --cdp-port 9222 --launch-existing-dist -domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh extension verify --target /path/to/metamask-extension --cdp-port 9222 -``` - -Use `mme-4` for Extension validation when available. - -## Runtime readiness (deterministic) - -Slots run `watch=off` (frozen, no watcher). Don't hand-debug — the runner decides. -Full reference: `/extension/runner/docs/extension-runtime-commands.md`. - -- need? → `runtime-decision … --cdp-port --json`; branch `.decision` (`install`|`build`|`relaunch`|`ready`). Don't re-parse webpack logs. -- id? → `resolve-extension …` (deterministic from dist `key`; never `serviceWorkers()[0]`). -- one healthy tab → `ensure-ready … --cdp-port ` after launch/reopen. - -After editing source: `runtime-decision → build? → refresh-build.sh` (one-shot, no watcher) `→ ensure-ready`. Human hot-reload instead: relaunch `--watch on`. - -## Runtime Dependencies - -The copied Extension runner expects the target checkout to provide its normal Node dependency set, including `@playwright/test`, `@metamask/client-mcp-core`, and `ws`. If verify fails with module-resolution errors, run the repo's package install/bootstrap first; do not treat that as product behavior failure. - -## Adapter Behavior - -Install copies the current Extension recipe runtime under the ignored `temp/agentic/**` harness path and writes `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/extension/manifest.json`. - -## Validation - -See references/contract.md for the full verification checklist. Extension-specific: use `dist/chrome/manifest.json` as the build contract (not hardcoded filenames like `scripts/app-init.js` or `service-worker.js`) so historical MV2/MV3 commits are handled correctly. - -## Prepare Compatibility Notes - -When an orchestrator prepares an Extension checkout before running this harness: - -- Strip editor-only `BUNDLED_DEBUGPY_PATH` through the orchestrator/project - environment layer, not with product code changes. The Extension webpack CLI - reads `BUNDLE_*` environment variables and treats that Cursor/VS Code variable - as an unknown build option. -- Treat CDP as ready only after a `chrome-extension:///...` target exists - for the intended extension; a listening CDP port alone is not sufficient. -- Use `adapters/extension/scripts/extension-readiness.js --target ---cdp-port ` as the source-of-truth readiness probe when wiring - caller-owned runners. -- If a prepare command would trigger a full rebuild, say so before starting and ask the human to approve. diff --git a/domains/agentic/skills/recipe-harness/repos/metamask-mobile.md b/domains/agentic/skills/recipe-harness/repos/metamask-mobile.md deleted file mode 100644 index b55da7d..0000000 --- a/domains/agentic/skills/recipe-harness/repos/metamask-mobile.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-harness ---- - -# MetaMask Mobile - -Use the Mobile adapter for `metamask-mobile` checkouts, especially historical commits where the checked-out runner may be stale or absent. - -## Commands - -```bash -.agents/skills/mms-recipe-harness/scripts/recipe-harness install -.agents/skills/mms-recipe-harness/scripts/recipe-harness verify -.agents/skills/mms-recipe-harness/scripts/recipe-harness launch --platform ios --preflight-mode fast -.agents/skills/mms-recipe-harness/scripts/recipe-harness live --platform ios --preflight-mode fast -.agents/skills/mms-recipe-harness/scripts/recipe-harness verify --static-only -.agents/skills/mms-recipe-harness/scripts/recipe-harness cleanup -``` - -The same `scripts/recipe-harness.sh` path is also mirrored under `.claude/skills/mms-recipe-harness/` and `.cursor/rules/mms-recipe-harness/` for Claude/Cursor operators; examples use `.agents/skills` because Codex reads that tree. - -If running from the source skills checkout instead, use: - -```bash -domains/agentic/skills/recipe-harness/scripts/recipe-harness mobile install --target /path/to/metamask-mobile -domains/agentic/skills/recipe-harness/scripts/recipe-harness mobile launch --target /path/to/metamask-mobile --platform ios --preflight-mode fast -domains/agentic/skills/recipe-harness/scripts/recipe-harness mobile live --target /path/to/metamask-mobile --platform ios --preflight-mode fast -``` - -## Adapter Behavior - -Install is conservative by default. On Mobile commits that already track the -first-party agentic harness, it writes metadata only and does not overwrite -tracked product files unless `--force-overlay` is explicit. On older commits -without a product-owned harness, install overlays the recipe runtime only when -`METAMASK_MOBILE_AGENTIC_SOURCE` (or `METAMASK_RECIPE_MOBILE_BRIDGE_SOURCE`) points -to a reviewed product/farm checkout or directly to its `scripts/perps/agentic` -directory. The skills repo does not bundle that product harness. Overlay install -then idempotently patches: - -- `scripts/perps/agentic/**`, copied from the external Mobile bridge source. The CDP bridge should support structured `show-step-json` HUD payloads and may keep legacy `show-step` for older runners. -- `package.json` with optional `a:*` aliases pointing at injected scripts. -- `app/core/NavigationService/NavigationService.ts` to install `AgenticService`. -- `app/components/Nav/App/App.tsx` to render `AgentStepHud`. - -## Validation - -See references/contract.md for the full verification checklist. Mobile-specific: `scripts/perps/agentic/**` backing scripts must be present from the product checkout or an explicit external Mobile bridge source (not bundled in the skills repo); direct script entrypoints must work independently of `yarn a:*`. - -Use `--static-only` only for install/idempotency checks when the simulator, Metro, or CDP is unavailable. - -```bash -bash scripts/perps/agentic/preflight.sh --platform ios --mode fast -bash scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup --wallet-fixture .agent/wallet-fixture.json -bash scripts/perps/agentic/app-state.sh status -/bin/metamask-recipe run --target -``` diff --git a/domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js b/domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js deleted file mode 100755 index 1b335a3..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// gen-action-vocab.js — regenerate recipe-action-vocab.fixture.json from the -// MetaMask v1 runner manifests (+ the runner's shipped recipes/*.json). -// -// The fixture is the offline source of truth for validate-recipe-docs.js. Action -// NAMES and field SHAPES come from the runner; per-action field sets are the union -// of action_metadata.schema.properties, action_metadata.examples node keys, and the -// fields actually used in the runner's shipped recipes (the manifest examples alone -// are minimal). Two things are CURATED (not derived) and kept in this generator: -// 1) app.hud excludes `text` and authored sub-intent fields — the manifest -// action_metadata declares intent/status/progress/display. -// 2) prose.forbiddenFieldPatterns — removed/never-valid field tokens that must -// never appear in recipe docs (prose isn't fully field-validated; see the -// validator header). Add new removed-field tokens here when actions change. -// -// Usage: -// node domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js \ -// --runner /path/to/metamask-recipe-runner [--out ] -// (RUNNER may also be given via METAMASK_RECIPE_RUNNER_DIR.) - -const fs = require('node:fs'); -const path = require('node:path'); - -const UNIVERSAL = ['action', 'next', 'cases', 'default', 'description', 'id', 'phase', 'proofTarget', 'record', 'timeout_ms', 'intent', 'detail', 'flow']; - -// Curated denylist of stale/removed field tokens (regex source strings) that must -// not appear in recipe docs. Kept here so regeneration preserves it. -const FORBIDDEN_FIELD_PATTERNS = [ - 'text_contains', - 'must_show', - 'must_not_show', - 'poll_ms', - 'eval_sync', - 'claims\\.must', - '`claims`', - '`visibility`', - 'visibility"?\\s*:\\s*"?viewport', - '"strategy"\\s*:\\s*"into_view"', - 'sub_intent', - 'subIntent', - 'showSubflow', -]; - -function parseArgs(argv) { - const a = { runner: process.env.METAMASK_RECIPE_RUNNER_DIR || '', out: '' }; - for (let i = 0; i < argv.length; i += 1) { - if (argv[i] === '--runner') a.runner = argv[++i] || ''; - else if (argv[i] === '--out') a.out = argv[++i] || ''; - } - if (!a.runner) { - // default to a sibling checkout next to this repo - const guess = path.resolve(__dirname, '../../../../../../metamask-recipe-runner'); - if (fs.existsSync(guess)) a.runner = guess; - } - if (!a.runner || !fs.existsSync(a.runner)) { - throw new Error('gen-action-vocab: pass --runner (or set METAMASK_RECIPE_RUNNER_DIR).'); - } - a.out = a.out || path.join(__dirname, 'recipe-action-vocab.fixture.json'); - return a; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - const manifestPath = (adapter) => path.join(args.runner, 'manifests', `${adapter}.action-manifest.json`); - const m = JSON.parse(fs.readFileSync(manifestPath('mobile'), 'utf8')); - const me = JSON.parse(fs.readFileSync(manifestPath('extension'), 'utf8')); - - const official = m.supported_official_actions || []; - const officialE = me.supported_official_actions || []; - if (JSON.stringify(official) !== JSON.stringify(officialE)) { - throw new Error('gen-action-vocab: mobile/extension official action lists differ — review before regenerating.'); - } - const custom = (m.custom_actions || []).map((c) => c.name); - - const md = { ...(m.action_metadata || {}) }; - for (const c of (m.custom_actions || [])) md[c.name] = c; - - // field usage from the runner's shipped recipes - const shipped = {}; - const recipesDir = path.join(args.runner, 'recipes'); - for (const f of fs.readdirSync(recipesDir).filter((x) => x.endsWith('.json'))) { - const r = JSON.parse(fs.readFileSync(path.join(recipesDir, f), 'utf8')); - const nodes = (r.validate && r.validate.workflow && r.validate.workflow.nodes) || {}; - for (const k of Object.keys(nodes)) { - const n = nodes[k]; - shipped[n.action] = shipped[n.action] || new Set(); - Object.keys(n).forEach((x) => shipped[n.action].add(x)); - } - } - - const U = new Set(UNIVERSAL); - const nameOnly = []; - const actionFields = {}; - for (const a of [...official, ...custom]) { - const d = md[a]; - const hasMeta = d && (d.schema || (Array.isArray(d.examples) && d.examples.length)); - if (!hasMeta) nameOnly.push(a); // no action_metadata — fields known only from shipped recipes - const set = new Set(); - if (d && d.schema && d.schema.properties) Object.keys(d.schema.properties).forEach((x) => set.add(x)); - if (d && Array.isArray(d.examples)) for (const e of d.examples) if (e.node) Object.keys(e.node).forEach((x) => set.add(x)); - if (shipped[a]) shipped[a].forEach((x) => set.add(x)); - actionFields[a] = [...set].filter((x) => !U.has(x)).sort(); - } - // CURATION: app.hud canonical fields are intent/status/progress/display. - actionFields['app.hud'] = (actionFields['app.hud'] || []).filter( - (x) => !['text', 'sub_intent', 'subIntent', 'showSubflow'].includes(x), - ); - - const fixture = { - _provenance: `Generated by gen-action-vocab.js from metamask-recipe-runner manifests (mobile+extension, identical official action sets) and the runner's shipped recipes/*.json. runner_protocol_version=${m.runner_protocol_version}, action_registry_version=${m.action_registry_version}. Per-action fields = union(action_metadata.schema.properties, action_metadata.examples node keys, shipped-recipe node keys) minus universalFields. CURATION: app.hud excludes 'text' and authored sub-intent fields. nameOnlyActions have no action_metadata, so their field sets come only from shipped-recipe usage (a new-but-valid field not yet used in a shipped recipe could be flagged — regenerate if so).`, - _regenerate: 'node domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js --runner /path/to/metamask-recipe-runner', - protocolVersion: m.runner_protocol_version, - registryVersion: m.action_registry_version, - officialActions: official, - customActions: custom, - nameOnlyActions: nameOnly.slice().sort(), - universalFields: UNIVERSAL.slice().sort(), - actionFields, - prose: { - _note: 'Prose is not fully field-validated; only fenced ```json recipe blocks and embedded recipes are schema-checked. This denylist (regex sources) catches specific stale/removed field tokens that must never appear in recipe docs.', - forbiddenFieldPatterns: FORBIDDEN_FIELD_PATTERNS, - }, - }; - fs.writeFileSync(args.out, `${JSON.stringify(fixture, null, 2)}\n`); - console.error(`gen-action-vocab: wrote ${args.out} (official=${official.length}, custom=${custom.length}, nameOnly=${nameOnly.length}).`); -} - -main(); diff --git a/domains/agentic/skills/recipe-harness/scripts/lib/harness-path.sh b/domains/agentic/skills/recipe-harness/scripts/lib/harness-path.sh deleted file mode 100644 index 9e9c899..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/lib/harness-path.sh +++ /dev/null @@ -1,30 +0,0 @@ -# harness-path.sh — shared, configurable recipe-harness injection root. -# Sourced (not executed) by both adapters and the wrapper so skill + farmslot use -# one definition. Override RECIPE_HARNESS_ROOT (relative to the target repo); -# defaults to temp/agentic/recipe-harness (under the gitignored temp/, so installs -# need no extra git-exclude). -# -# An empty/unset value falls back to the default; a set value is validated -# (relative, safe charset, no '.'/'..' components) so a hostile/typo'd value -# can't make install/cleanup write or rm -rf outside the target, and is safe to -# embed in shell/JSON without quoting surprises. -# Returns non-zero on an invalid value; callers run under `set -e`. -harness_root() { - local root="${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}" - case "$root" in - ""|/*) echo "RECIPE_HARNESS_ROOT must be a non-empty relative path: '$root'" >&2; return 1 ;; - *[!A-Za-z0-9._/-]*) echo "RECIPE_HARNESS_ROOT may only contain A-Za-z0-9 and . _ / - : '$root'" >&2; return 1 ;; - esac - local IFS=/ part - for part in $root; do - case "$part" in - .|..) echo "RECIPE_HARNESS_ROOT must not contain '.' or '..' path components: '$root'" >&2; return 1 ;; - esac - done - printf '%s' "$root" -} - -# harness_dir [adapter] -> absolute install dir for the adapter. -harness_dir() { - printf '%s/%s/%s' "$1" "$(harness_root)" "${2:-extension}" -} diff --git a/domains/agentic/skills/recipe-harness/scripts/lib/json-field.sh b/domains/agentic/skills/recipe-harness/scripts/lib/json-field.sh deleted file mode 100644 index 152037e..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/lib/json-field.sh +++ /dev/null @@ -1,23 +0,0 @@ -# json-field.sh — shared JSON-field reader for recipe-harness scripts. -# Sourced (not executed). read_runtime_context_field -# prints the value (empty string if absent); returns 1 if the file is missing. -read_runtime_context_field() { - local context_path="$1" - local field="$2" - [ -f "$context_path" ] || return 1 - node -e ' -const fs = require("node:fs"); -const [path, field] = process.argv.slice(1); -try { - const data = JSON.parse(fs.readFileSync(path, "utf8")); - const value = field.split(".").reduce((node, key) => { - if (node === undefined || node === null) return undefined; - return node[key]; - }, data); - if (value !== undefined && value !== null && value !== "") process.stdout.write(String(value)); -} catch (error) { - process.stderr.write(String(error && error.message ? error.message : error) + "\n"); - process.exitCode = 1; -} -' "$context_path" "$field" -} diff --git a/domains/agentic/skills/recipe-harness/scripts/recipe-action-vocab.fixture.json b/domains/agentic/skills/recipe-harness/scripts/recipe-action-vocab.fixture.json deleted file mode 100644 index b964204..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/recipe-action-vocab.fixture.json +++ /dev/null @@ -1,291 +0,0 @@ -{ - "_provenance": "Generated by gen-action-vocab.js from metamask-recipe-runner manifests (mobile+extension, identical official action sets) and the runner's shipped recipes/*.json. runner_protocol_version=1, action_registry_version=1. Per-action fields = union(action_metadata.schema.properties, action_metadata.examples node keys, shipped-recipe node keys) minus universalFields. CURATION: app.hud excludes 'text' and authored sub-intent fields. nameOnlyActions have no action_metadata, so their field sets come only from shipped-recipe usage (a new-but-valid field not yet used in a shipped recipe could be flagged — regenerate if so).", - "_regenerate": "node domains/agentic/skills/recipe-harness/scripts/gen-action-vocab.js --runner /path/to/metamask-recipe-runner", - "protocolVersion": 1, - "registryVersion": 1, - "officialActions": [ - "command", - "wait", - "assert_file", - "assert_json", - "assert_exit_code", - "assert_output", - "watch_logs", - "index_artifacts", - "end", - "ui.navigate", - "ui.press", - "ui.set_input", - "ui.scroll", - "ui.wait_for", - "ui.screenshot", - "app.status", - "app.hud", - "cdp.target" - ], - "customActions": [ - "metamask.wallet.fixture_status", - "metamask.wallet.setup", - "metamask.wallet.ensure_unlocked", - "metamask.wallet.select_account", - "metamask.wallet.read_state", - "metamask.perps.read_positions", - "metamask.perps.read_orders", - "metamask.perps.close_positions", - "metamask.perps.close_orders", - "metamask.perps.place_order", - "metamask.perps.assert_positions", - "metamask.perps.assert_orders", - "metamask.perps.ensure_positions", - "metamask.perps.ensure_orders", - "metamask.perps.start_state", - "metamask.perps.teardown_state" - ], - "nameOnlyActions": [ - "app.status", - "cdp.target", - "ui.navigate" - ], - "universalFields": [ - "action", - "cases", - "default", - "description", - "detail", - "flow", - "id", - "intent", - "next", - "phase", - "proofTarget", - "record", - "timeout_ms" - ], - "actionFields": { - "command": [ - "cmd" - ], - "wait": [ - "duration_ms" - ], - "assert_file": [ - "contains", - "path" - ], - "assert_json": [ - "assert", - "path" - ], - "assert_exit_code": [ - "expected", - "source" - ], - "assert_output": [ - "contains", - "source", - "stream" - ], - "watch_logs": [ - "contains", - "path" - ], - "index_artifacts": [ - "artifacts" - ], - "end": [ - "status" - ], - "ui.navigate": [ - "hash", - "params", - "route" - ], - "ui.press": [ - "selector", - "target", - "test_id" - ], - "ui.set_input": [ - "selector", - "testID", - "test_id", - "text", - "value" - ], - "ui.scroll": [ - "delta_y", - "offset", - "scroll_into_view", - "selector", - "test_id" - ], - "ui.wait_for": [ - "expected", - "selector", - "test_id", - "text", - "visible" - ], - "ui.screenshot": [ - "path" - ], - "app.status": [], - "app.hud": [ - "clear", - "display", - "error", - "progress", - "status" - ], - "cdp.target": [ - "required" - ], - "metamask.wallet.fixture_status": [], - "metamask.wallet.setup": [], - "metamask.wallet.ensure_unlocked": [], - "metamask.wallet.select_account": [ - "address", - "name" - ], - "metamask.wallet.read_state": [], - "metamask.perps.read_positions": [ - "market", - "markets", - "mode", - "selector", - "side", - "symbol", - "symbols" - ], - "metamask.perps.read_orders": [ - "market", - "markets", - "mode", - "selector", - "side", - "symbol", - "symbols" - ], - "metamask.perps.close_positions": [ - "market", - "markets", - "mode", - "selector", - "side", - "symbol", - "symbols" - ], - "metamask.perps.close_orders": [ - "market", - "markets", - "mode", - "selector", - "side", - "symbol", - "symbols" - ], - "metamask.perps.place_order": [ - "amount", - "leverage", - "market", - "markets", - "mode", - "notional", - "selector", - "side", - "size", - "symbol", - "symbols" - ], - "metamask.perps.assert_positions": [ - "market", - "markets", - "mode", - "selector", - "side", - "state", - "symbol", - "symbols" - ], - "metamask.perps.assert_orders": [ - "market", - "markets", - "mode", - "selector", - "side", - "state", - "symbol", - "symbols" - ], - "metamask.perps.ensure_positions": [ - "market", - "markets", - "mode", - "selector", - "side", - "state", - "symbol", - "symbols" - ], - "metamask.perps.ensure_orders": [ - "market", - "markets", - "mode", - "selector", - "side", - "state", - "symbol", - "symbols" - ], - "metamask.perps.start_state": [ - "account", - "hud", - "market", - "markets", - "mode", - "network", - "orders", - "page", - "positions", - "profile", - "provider", - "side", - "symbol", - "symbols", - "tutorial" - ], - "metamask.perps.teardown_state": [ - "account", - "hud", - "market", - "markets", - "mode", - "network", - "orders", - "page", - "positions", - "profile", - "provider", - "symbol", - "symbols" - ] - }, - "prose": { - "_note": "Prose is not fully field-validated; only fenced ```json recipe blocks and embedded recipes are schema-checked. This denylist (regex sources) catches specific stale/removed field tokens that must never appear in recipe docs.", - "forbiddenFieldPatterns": [ - "text_contains", - "must_show", - "must_not_show", - "poll_ms", - "eval_sync", - "claims\\.must", - "`claims`", - "`visibility`", - "visibility\"?\\s*:\\s*\"?viewport", - "\"strategy\"\\s*:\\s*\"into_view\"", - "sub_intent", - "subIntent", - "showSubflow" - ] - } -} diff --git a/domains/agentic/skills/recipe-harness/scripts/recipe-harness b/domains/agentic/skills/recipe-harness/scripts/recipe-harness deleted file mode 100755 index fe403e1..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/recipe-harness +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_NAME="recipe-harness" - -usage() { - cat <<'USAGE' -Usage: - recipe-harness # auto-detect repo, install into current dir - recipe-harness install # auto-detect repo, install into current dir - recipe-harness live [args] # launch then verify live CDP/app control - recipe-harness verify [args] # auto-detect repo, verify current dir - recipe-harness cleanup [args] # auto-detect repo, cleanup current dir - recipe-harness --target install - recipe-harness --adapter --cdp-port - -Explicit low-level form is also supported: - recipe-harness [args] - -Common examples: - recipe-harness - recipe-harness launch --platform ios --preflight-mode fast - recipe-harness live --platform ios --preflight-mode fast - recipe-harness launch --platform android --preflight-mode fast - recipe-harness live --cdp-port 6665 --launch-existing-dist - recipe-harness live --cdp-port 6665 --start-test-watch - recipe-harness verify --static-only - recipe-harness verify --no-auto-start - recipe-harness verify --auto-start --preflight-mode fast - recipe-harness verify --cdp-port 6662 - recipe-harness --target verify --cdp-port 6662 - -Mobile runtime configuration: - RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE=fast # default; reuse installed/cache, fail before native rebuild - RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE=auto # allow fingerprint-gated native build on cache miss - RECIPE_HARNESS_MOBILE_AUTO_START=0 # default; verify does not start Metro/app unless --auto-start is passed - -Extension runtime configuration: - RECIPE_HARNESS_EXTENSION_AUTO_START=0 # never launch a missing browser - RECIPE_HARNESS_EXTENSION_LAUNCH_CMD='...' # caller-owned startup command -USAGE -} - -log() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*" >&2; } -fail() { printf '[%s] ERROR: %s\n' "$SCRIPT_NAME" "$*" >&2; exit 1; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RECIPE_HARNESS="$SCRIPT_DIR/recipe-harness.sh" -[ -x "$RECIPE_HARNESS" ] || fail "missing recipe-harness.sh next to $0" - -if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then - usage - exit 0 -fi - -case "${1:-}" in - mobile|extension) - # Backwards-compatible explicit form with nicer progress output. - ADAPTER="$1" - ACTION="${2:-}" - [ -n "$ACTION" ] || { usage >&2; exit 2; } - shift 2 - ;; - *) - ADAPTER="" - ACTION="install" - TARGET="$PWD" - PASSTHRU=() - while [ "$#" -gt 0 ]; do - case "$1" in - install|launch|live|verify|cleanup) - ACTION="$1" - shift - ;; - --adapter) - ADAPTER="$2" - shift 2 - ;; - --target|-C) - TARGET="$2" - shift 2 - ;; - --) - shift - PASSTHRU+=("$@") - break - ;; - *) - PASSTHRU+=("$1") - shift - ;; - esac - done - TARGET="$(cd "$TARGET" && pwd)" - - detect_adapter() { - local target="$1" - local remote="" - remote="$(git -C "$target" config --get remote.origin.url 2>/dev/null || true)" - case "$remote" in - *metamask-extension*) printf 'extension\n'; return 0 ;; - *metamask-mobile*) printf 'mobile\n'; return 0 ;; - esac - - # Repo-shape fallback for worktrees/clones with unusual remote names. - if [ -f "$target/development/skills-sync.ts" ] || { [ -d "$target/ui" ] && [ -d "$target/app/scripts" ]; }; then - printf 'extension\n' - return 0 - fi - if [ -f "$target/scripts/skills-sync.mts" ] || { [ -d "$target/ios" ] && [ -d "$target/android" ] && [ -d "$target/app/core" ]; }; then - printf 'mobile\n' - return 0 - fi - return 1 - } - - if [ -z "$ADAPTER" ]; then - ADAPTER="$(detect_adapter "$TARGET")" || fail "could not detect MetaMask repo type for $TARGET; pass --adapter mobile or --adapter extension" - fi - - if [ "${#PASSTHRU[@]}" -gt 0 ]; then - set -- "${PASSTHRU[@]}" - else - set -- - fi - ;; -esac - -case "$ADAPTER:$ACTION" in - mobile:install|mobile:launch|mobile:live|mobile:verify|mobile:cleanup|extension:install|extension:launch|extension:live|extension:verify|extension:cleanup) ;; - *) usage >&2; fail "unsupported adapter/action: ${ADAPTER:-?}/${ACTION:-?}" ;; -esac - -# In explicit form, TARGET may not be set. Try to discover it from args for display only. -if [ -z "${TARGET:-}" ]; then - TARGET="$PWD" - args=("$@") - for ((i=0; i<${#args[@]}; i++)); do - if [ "${args[$i]}" = "--target" ] && [ $((i + 1)) -lt ${#args[@]} ]; then - TARGET="${args[$((i + 1))]}" - break - fi - done - TARGET="$(cd "$TARGET" 2>/dev/null && pwd || printf '%s' "$TARGET")" -else - # Smart form owns --target injection so humans can omit it. - set -- --target "$TARGET" "$@" -fi - -case "$ACTION" in - install) VERB="Installing" ;; - launch) VERB="Launching" ;; - live) VERB="Running live validation for" ;; - verify) VERB="Verifying" ;; - cleanup) VERB="Cleaning" ;; -esac - -log "$VERB $ADAPTER recipe harness" -log "Target: $TARGET" -has_arg() { - local needle="$1" - shift - for arg in "$@"; do - [ "$arg" = "$needle" ] && return 0 - case "$arg" in "$needle="*) return 0 ;; esac - done - return 1 -} - -cdp_reachable() { - local port="$1" - command -v curl >/dev/null 2>&1 || return 1 - curl -fsS --max-time 1 "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1 -} - -is_false() { - case "${1:-}" in - 0|false|FALSE|False|no|NO|No|off|OFF|Off) return 0 ;; - *) return 1 ;; - esac -} - -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/lib/json-field.sh" - -resolve_extension_context_cdp_port() { - local target="$1" - local context_path="${RECIPE_RUNTIME_CONTEXT:-$target/temp/runtime/agentic-runtime.json}" - [ -f "$context_path" ] || return 1 - local value - value="$(read_runtime_context_field "$context_path" cdpPort)" - [ -n "$value" ] || return 1 - export RECIPE_RUNTIME_CONTEXT="$context_path" - export RECIPE_CDP_PORT="$value" - export CDP_PORT="$value" - printf '%s\n' "$value" -} - -resolve_extension_cdp_port() { - local target="$1" - if [ -n "${CDP_PORT:-}" ]; then - printf '%s\n' "$CDP_PORT" - return 0 - fi - - # Local env files are useful outside a managed launcher. Keep this generic: accept an - # explicit CDP_PORT assignment only, not repo/slot-specific variable names. - local env_file line value - for env_file in "$target/.js.env" "$target/.env" "$target/.env.local"; do - [ -f "$env_file" ] || continue - line=$(grep -E '^[[:space:]]*(export[[:space:]]+)?CDP_PORT=' "$env_file" | tail -1 || true) - [ -n "$line" ] || continue - value="${line#*=}" - value="${value%%#*}" - value="$(printf '%s' "$value" | tr -d "\"'[:space:]")" - if [ -n "$value" ]; then - printf '%s\n' "$value" - return 0 - fi - done - - return 1 -} - -run_extension_prepare() { - local target="$1" - local cdp_port="$2" - local custom_cmd="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}" - if [ -n "$custom_cmd" ]; then - log "CDP port $cdp_port is offline; running RECIPE_HARNESS_EXTENSION_LAUNCH_CMD" - log "Runtime startup is caller-owned; use a cached/watch-only command unless the human approved heavier work." - ( - cd "$target" - TARGET="$target" CDP_PORT="$cdp_port" bash -lc "$custom_cmd" - ) - return - fi - - log "CDP port $cdp_port is offline; start the extension with a caller-owned command, set RECIPE_HARNESS_EXTENSION_LAUNCH_CMD, or pass --static-only" -} - -if [ "$ADAPTER" = "extension" ] && { [ "$ACTION" = "verify" ] || [ "$ACTION" = "launch" ] || [ "$ACTION" = "live" ]; }; then - no_auto_start=false - if [ "$ACTION" = "verify" ] && has_arg --no-auto-start "$@"; then - no_auto_start=true - stripped_args=() - for arg in "$@"; do - [ "$arg" = "--no-auto-start" ] && continue - stripped_args+=("$arg") - done - # Preserve remaining args after removing the smart-wrapper-only flag. - set -- "${stripped_args[@]}" - fi - if ! has_arg --cdp-port "$@" && ! has_arg --static-only "$@"; then - if resolved_cdp_port="$(resolve_extension_context_cdp_port "$TARGET")"; then - log "Using runtime-context CDP port $resolved_cdp_port for extension $ACTION" - set -- --cdp-port "$resolved_cdp_port" "$@" - elif resolved_cdp_port="$(resolve_extension_cdp_port "$TARGET")"; then - log "Using CDP port $resolved_cdp_port for extension $ACTION" - set -- --cdp-port "$resolved_cdp_port" "$@" - elif [ "$ACTION" = "verify" ]; then - log "Tip: extension live verify needs --cdp-port ; use --static-only for static verification." - fi - fi - if [ "$ACTION" = "verify" ] && ! has_arg --static-only "$@" && ! $no_auto_start && ! is_false "${RECIPE_HARNESS_EXTENSION_AUTO_START:-0}"; then - args=("$@") - cdp_arg="" - for ((i=0; i<${#args[@]}; i++)); do - case "${args[$i]}" in - --cdp-port) cdp_arg="${args[$((i + 1))]:-}" ;; - --cdp-port=*) cdp_arg="${args[$i]#--cdp-port=}" ;; - esac - done - if [ -n "$cdp_arg" ] && ! cdp_reachable "$cdp_arg"; then - run_extension_prepare "$TARGET" "$cdp_arg" - fi - elif [ "$ACTION" = "verify" ] && ! has_arg --static-only "$@"; then - log "Extension auto-start disabled; verify will use the existing CDP runtime only" - fi -fi - -start=$SECONDS -if "$RECIPE_HARNESS" "$ADAPTER" "$ACTION" "$@"; then - elapsed=$((SECONDS - start)) - log "Done: $ADAPTER $ACTION passed in ${elapsed}s" -else - status=$? - elapsed=$((SECONDS - start)) - log "Failed: $ADAPTER $ACTION exited $status after ${elapsed}s" - exit "$status" -fi diff --git a/domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh b/domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh deleted file mode 100755 index 952bae9..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/recipe-harness.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: recipe-harness.sh [args] - -Examples: - recipe-harness.sh mobile install --target /path/to/metamask-mobile - recipe-harness.sh mobile launch --target /path/to/metamask-mobile --platform ios --preflight-mode fast - recipe-harness.sh mobile live --target /path/to/metamask-mobile --platform ios --preflight-mode fast - recipe-harness.sh mobile verify --target /path/to/metamask-mobile --no-auto-start - recipe-harness.sh extension install --target /path/to/metamask-extension - recipe-harness.sh extension launch --target /path/to/metamask-extension --cdp-port - recipe-harness.sh extension live --target /path/to/metamask-extension --cdp-port --launch-existing-dist - recipe-harness.sh extension verify --target /path/to/metamask-extension --cdp-port - recipe-harness.sh extension verify --target /path/to/metamask-extension --static-only -EOF -} - -has_arg() { - local needle="$1" - shift - local arg - for arg in "$@"; do - [ "$arg" = "$needle" ] && return 0 - done - return 1 -} - -arg_value() { - local needle="$1" - shift - while [ "$#" -gt 0 ]; do - if [ "$1" = "$needle" ]; then - [ "$#" -ge 2 ] && printf '%s\n' "$2" - return 0 - fi - shift - done - return 1 -} - -if [ "$#" -lt 2 ]; then - usage >&2 - exit 2 -fi - -ADAPTER="$1" -ACTION="$2" -shift 2 - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -ADAPTER_SCRIPT="${SKILL_DIR}/adapters/${ADAPTER}/scripts/${ACTION}.sh" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/lib/json-field.sh" - -case "${ADAPTER}:${ACTION}" in - mobile:install|mobile:launch|mobile:live|mobile:verify|mobile:cleanup|extension:install|extension:launch|extension:live|extension:verify|extension:cleanup) ;; - *) - usage >&2 - exit 2 - ;; -esac - -if [ ! -x "$ADAPTER_SCRIPT" ]; then - echo "Missing adapter script: $ADAPTER_SCRIPT" >&2 - exit 1 -fi - -if [ "$ADAPTER" = "extension" ] && [ "$ACTION" != "install" ] && [ "$ACTION" != "cleanup" ]; then - TARGET="$(arg_value --target "$@" || true)" - TARGET="${TARGET:-$(pwd)}" - if [ -d "$TARGET" ]; then - TARGET="$(cd "$TARGET" && pwd -P)" - fi - - CONTEXT_PATH="${RECIPE_RUNTIME_CONTEXT:-$TARGET/temp/runtime/agentic-runtime.json}" - if [ -f "$CONTEXT_PATH" ]; then - export RECIPE_RUNTIME_CONTEXT="$CONTEXT_PATH" - if [ -z "${RECIPE_SLOT_ID:-}" ]; then - RECIPE_SLOT_ID="$(read_runtime_context_field "$CONTEXT_PATH" slotId || true)" - [ -n "$RECIPE_SLOT_ID" ] && export RECIPE_SLOT_ID - fi - if [ -z "${RECIPE_RUNTIME_STRICT:-}" ]; then - _strict="$(read_runtime_context_field "$CONTEXT_PATH" strict || true)" - case "$_strict" in - true|True|1) export RECIPE_RUNTIME_STRICT=1 ;; - false|False|0) export RECIPE_RUNTIME_STRICT=0 ;; - esac - unset _strict - fi - if [ -z "${RECIPE_HARNESS_EXTENSION_ID:-}" ]; then - _extension_id="$(read_runtime_context_field "$CONTEXT_PATH" extensionId || true)" - [ -n "$_extension_id" ] && export RECIPE_HARNESS_EXTENSION_ID="$_extension_id" - unset _extension_id - fi - if [ -z "${RECIPE_RUNTIME_START_APPROVED:-}" ]; then - _runtime_start_approved="$(read_runtime_context_field "$CONTEXT_PATH" runtimeStart.approved || true)" - case "$_runtime_start_approved" in - true|True|1) export RECIPE_RUNTIME_START_APPROVED=1 ;; - false|False|0) export RECIPE_RUNTIME_START_APPROVED=0 ;; - esac - unset _runtime_start_approved - fi - if [ -z "${RECIPE_RUNTIME_START_CMD:-}" ]; then - _runtime_start_cmd="$(read_runtime_context_field "$CONTEXT_PATH" runtimeStart.command || true)" - [ -n "$_runtime_start_cmd" ] && export RECIPE_RUNTIME_START_CMD="$_runtime_start_cmd" - unset _runtime_start_cmd - fi - if [ -z "${RECIPE_RUNTIME_READY_URL:-}" ]; then - _runtime_ready_url="$(read_runtime_context_field "$CONTEXT_PATH" runtimeStart.readyUrl || true)" - [ -n "$_runtime_ready_url" ] && export RECIPE_RUNTIME_READY_URL="$_runtime_ready_url" - unset _runtime_ready_url - fi - fi - - if ! has_arg --cdp-port "$@"; then - CONTEXT_CDP_PORT="" - if [ -f "$CONTEXT_PATH" ]; then - CONTEXT_CDP_PORT="$(read_runtime_context_field "$CONTEXT_PATH" cdpPort || true)" - fi - CONTEXT_CDP_PORT="${CONTEXT_CDP_PORT:-${RECIPE_CDP_PORT:-${CDP_PORT:-}}}" - if [ -n "$CONTEXT_CDP_PORT" ]; then - export RECIPE_CDP_PORT="$CONTEXT_CDP_PORT" - export CDP_PORT="$CONTEXT_CDP_PORT" - set -- "$@" --cdp-port "$CONTEXT_CDP_PORT" - fi - fi - - if { [ "$ACTION" = "launch" ] || [ "$ACTION" = "live" ]; } && ! has_arg --prepare-cmd "$@"; then - if [ "${RECIPE_RUNTIME_START_APPROVED:-0}" = "1" ] && [ -n "${RECIPE_RUNTIME_START_CMD:-}" ]; then - set -- "$@" --prepare-cmd "$RECIPE_RUNTIME_START_CMD" - fi - fi -fi - -exec "$ADAPTER_SCRIPT" "$@" diff --git a/domains/agentic/skills/recipe-harness/scripts/resolve-runner-source.sh b/domains/agentic/skills/recipe-harness/scripts/resolve-runner-source.sh deleted file mode 100644 index 8632be6..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/resolve-runner-source.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash - -DEFAULT_METAMASK_RECIPE_RUNNER_GIT_URL="https://github.com/deeeed/metamask-recipe-runner.git" -DEFAULT_METAMASK_RECIPE_RUNNER_GIT_REF="main" - -resolve_metamask_recipe_runner_source() { - local skill_dir="$1" - local agentic_dir="$2" - local target_dir="${3:-}" - local candidate - local skill_repo_root="" - local sibling_runner="" - - if skill_repo_root="$(git -C "$skill_dir" rev-parse --show-toplevel 2>/dev/null)"; then - sibling_runner="$(dirname "$skill_repo_root")/metamask-recipe-runner" - fi - - METAMASK_RUNNER_SOURCE_KIND="" - METAMASK_RUNNER_DIR="" - METAMASK_RUNNER_FARMSLOT_ROOT="" - - for explicit_var in METAMASK_RECIPE_RUNNER_SOURCE RECIPE_RUNNER_SOURCE METAMASK_RECIPE_RUNNER_PACKAGE_DIR; do - candidate="${!explicit_var:-}" - [ -n "$candidate" ] || continue - if [ ! -d "$candidate" ]; then - echo "$explicit_var points to a missing MetaMask recipe runner source: $candidate" >&2 - return 1 - fi - METAMASK_RUNNER_DIR="$(cd "$candidate" && pwd -P)" - METAMASK_RUNNER_SOURCE_KIND="env:$explicit_var" - break - done - - if [ -z "$METAMASK_RUNNER_DIR" ] && ! is_truthy "${METAMASK_RECIPE_RUNNER_SIBLING_DISABLED:-}" && [ -n "$sibling_runner" ] && [ -d "$sibling_runner" ]; then - METAMASK_RUNNER_DIR="$(cd "$sibling_runner" && pwd -P)" - METAMASK_RUNNER_SOURCE_KIND="sibling-checkout" - fi - - if [ -z "$METAMASK_RUNNER_DIR" ]; then - resolve_cached_metamask_recipe_runner_source - fi - - for required in \ - "$METAMASK_RUNNER_DIR/package.json" \ - "$METAMASK_RUNNER_DIR/bin/metamask-recipe" \ - "$METAMASK_RUNNER_DIR/manifests/mobile.action-manifest.json" \ - "$METAMASK_RUNNER_DIR/manifests/extension.action-manifest.json" - do - if [ ! -e "$required" ]; then - echo "Invalid MetaMask recipe runner source: missing $required" >&2 - return 1 - fi - done - - if METAMASK_RUNNER_REVISION="$(git -C "$METAMASK_RUNNER_DIR" rev-parse HEAD 2>/dev/null)"; then - : - else - METAMASK_RUNNER_REVISION="unknown" - fi - METAMASK_RUNNER_SKILL_DIR="$(cd "$skill_dir" && pwd -P)" - METAMASK_RUNNER_FARMSLOT_ROOT="$(resolve_metamask_runner_farmslot_root "$target_dir" "$skill_dir" "$METAMASK_RUNNER_DIR" "$PWD" 2>/dev/null || true)" - export METAMASK_RUNNER_DIR METAMASK_RUNNER_SOURCE_KIND METAMASK_RUNNER_REVISION METAMASK_RUNNER_SKILL_DIR METAMASK_RUNNER_FARMSLOT_ROOT -} - -resolve_cached_metamask_recipe_runner_source() { - if is_truthy "${METAMASK_RECIPE_RUNNER_GIT_DISABLED:-}"; then - cat >&2 </dev/null 2>&1 || { echo "Missing git; cannot fetch MetaMask recipe runner fallback." >&2; return 1; } - - local git_url="${METAMASK_RECIPE_RUNNER_GIT_URL:-$DEFAULT_METAMASK_RECIPE_RUNNER_GIT_URL}" - local git_ref="${METAMASK_RECIPE_RUNNER_GIT_REF:-$DEFAULT_METAMASK_RECIPE_RUNNER_GIT_REF}" - local cache_root="${METAMASK_RECIPE_RUNNER_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/metamask-skills/recipe-runner}" - local cache_key repo_dir - cache_key="$(printf '%s' "$git_url" | git hash-object --stdin)" - repo_dir="$cache_root/$cache_key" - - mkdir -p "$cache_root" - if [ -d "$repo_dir/.git" ]; then - git -C "$repo_dir" fetch --tags --prune origin >/dev/null - else - rm -rf "$repo_dir" - git clone --filter=blob:none "$git_url" "$repo_dir" >/dev/null - fi - if git -C "$repo_dir" show-ref --verify --quiet "refs/remotes/origin/$git_ref"; then - git -C "$repo_dir" checkout --detach -q "origin/$git_ref" - else - git -C "$repo_dir" checkout --detach -q "$git_ref" - fi - ensure_metamask_recipe_runner_dependencies "$repo_dir" - - METAMASK_RUNNER_DIR="$(cd "$repo_dir" && pwd -P)" - METAMASK_RUNNER_SOURCE_KIND="git:$git_url#$git_ref" -} - -ensure_metamask_recipe_runner_dependencies() { - local runner_dir="$1" - [ -f "$runner_dir/package.json" ] || return 0 - if [ -f "$runner_dir/dist/cli.js" ]; then - return 0 - fi - if [ -x "$runner_dir/node_modules/.bin/tsx" ] && [ -d "$runner_dir/node_modules/@farmslot/recipe-harness" ]; then - return 0 - fi - command -v npm >/dev/null 2>&1 || { echo "Missing npm; cannot install MetaMask recipe runner dependencies in $runner_dir." >&2; return 1; } - (cd "$runner_dir" && npm install --no-package-lock >/dev/null) -} - -is_truthy() { - case "${1:-}" in - 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; - *) return 1 ;; - esac -} - -resolve_metamask_runner_farmslot_root() { - local candidate - for candidate in "${FARMSLOT_ROOT:-}" "$@"; do - [ -n "$candidate" ] || continue - if candidate="$(find_metamask_runner_farmslot_root "$candidate")"; then - printf '%s\n' "$candidate" - return 0 - fi - done - cat >&2 < "$FARMSLOT_FIXTURE/packages/recipe-harness/package.json" -printf '{"name":"@farmslot/protocol"}\n' > "$FARMSLOT_FIXTURE/packages/protocol/package.json" -printf '{"name":"@metamask/recipe-runner-fixture"}\n' > "$RUNNER_FIXTURE/package.json" -printf '#!/usr/bin/env bash\nprintf "fixture runner\\n"\n' > "$RUNNER_FIXTURE/bin/metamask-recipe" -chmod +x "$RUNNER_FIXTURE/bin/metamask-recipe" -printf '{"runner_protocol_version":1,"action_registry_version":1,"supported_official_actions":[],"action_metadata":{}}\n' \ - > "$RUNNER_FIXTURE/manifests/mobile.action-manifest.json" -printf '{"runner_protocol_version":1,"action_registry_version":1,"supported_official_actions":[],"action_metadata":{}}\n' \ - > "$RUNNER_FIXTURE/manifests/extension.action-manifest.json" - -run_mobile_install() { - FARMSLOT_ROOT="$FARMSLOT_FIXTURE" \ - METAMASK_RECIPE_RUNNER_SOURCE="$RUNNER_FIXTURE" \ - "$SKILL_DIR/adapters/mobile/scripts/install.sh" "$@" -} - -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -assert_extension_verify_does_not_autostart_by_default() { - local target="$tmpdir/fake-extension" - local sentinel="$tmpdir/extension-autostart-ran" - mkdir -p "$target" - - set +e - RECIPE_HARNESS_EXTENSION_LAUNCH_CMD="touch '$sentinel'" \ - "$SKILL_DIR/scripts/recipe-harness" \ - --adapter extension \ - --target "$target" \ - verify \ - --cdp-port 9 \ - >/tmp/recipe-harness-extension-no-autostart.log 2>&1 - local rc=$? - set -e - - [ "$rc" -ne 0 ] || fail "fake extension verify unexpectedly passed" - [ ! -e "$sentinel" ] || fail "extension verify auto-started despite built-in no-start policy" -} - -assert_no_mobile_harness_bundled_in_skill() { - [ ! -e "$SKILL_DIR/adapters/mobile/runner/scripts/perps/agentic" ] \ - || fail "mobile product harness must not be bundled under recipe-harness skill" -} - -assert_force_overlay_requires_external_mobile_source() { - local target="$tmpdir/mobile-force-overlay-no-source" - mkdir -p "$target/app/core/NavigationService" "$target/app/components/Nav/App" - ( - cd "$target" - git init -q - printf '{"scripts":{}}\n' > package.json - printf ' this.#navigation = this.#createReactAwareNavigation(navRef);\n' > app/core/NavigationService/NavigationService.ts - printf "import PerpsWebSocketHealthToast from './toast';\n \n" > app/components/Nav/App/App.tsx - ) - - set +e - run_mobile_install --target "$target" --force-overlay --allow-dirty-harness-paths \ - >/tmp/recipe-harness-mobile-force-overlay-no-source.log 2>&1 - local rc=$? - set -e - - [ "$rc" -ne 0 ] || fail "force overlay install passed without external mobile bridge source" - grep -q 'Mobile bridge overlay source is not bundled in metamask-skills' \ - /tmp/recipe-harness-mobile-force-overlay-no-source.log \ - || fail "force overlay failure did not explain required external mobile bridge source" -} - -assert_verify_marks_harness_owned_only_after_preflight_success() { - local verify="$SKILL_DIR/adapters/mobile/scripts/verify.sh" - node - "$verify" <<'NODE' -const fs = require('fs'); -const src = fs.readFileSync(process.argv[2], 'utf8'); -const preflightCall = src.indexOf('bash "${preflight_args[@]}"'); -const marker = src.indexOf(': > "$ARTIFACTS/logs/harness-started-runtime"'); -const failureAfterMarker = src.indexOf('return 1', marker); -if (preflightCall === -1 || marker === -1 || marker < preflightCall) { - throw new Error('verify must write harness-started-runtime only after preflight succeeds'); -} -if (failureAfterMarker === -1) { - throw new Error('verify must return failure when preflight does not start a runtime'); -} -if (!src.includes('EXPO_NO_TYPESCRIPT_SETUP=1 bash "${preflight_args[@]}"')) { - throw new Error('verify auto-start must disable Expo TypeScript setup to avoid tsconfig drift'); -} -if (!src.includes('native-config-before-autostart.sha256') || !src.includes('native-config-after-autostart.sha256')) { - throw new Error('verify auto-start must snapshot native/package config around preflight'); -} -NODE -} - -assert_partial_product_harness_install_is_metadata_only() { - local target="$tmpdir/partial-product-mobile" - mkdir -p "$target/scripts/perps/agentic" - ( - cd "$target" - git init -q - printf 'product-owned\n' > scripts/perps/agentic/preflight.sh - git add scripts/perps/agentic/preflight.sh - ) - - printf '.agent/recipe-harness/\n' > "$target/.git/info/exclude" - - run_mobile_install --target "$target" >/tmp/recipe-harness-mobile-partial-product.log 2>&1 - - grep -qxF 'product-owned' "$target/scripts/perps/agentic/preflight.sh" \ - || fail "partial tracked product harness was overwritten without --force-overlay" - grep -qxF '.agent/recipe-harness/' "$target/.git/info/exclude" \ - || fail "metadata-only install did not add expected exclude entry" - grep -qxF '.skills-cache/' "$target/.agent/recipe-harness/mobile/added-git-exclude" \ - || fail "metadata-only install did not record newly added exclude entries" - ! grep -qxF '.agent/recipe-harness/' "$target/.agent/recipe-harness/mobile/added-git-exclude" \ - || fail "metadata-only install recorded a pre-existing exclude entry" - node - "$target/.agent/recipe-harness/mobile/manifest.json" <<'NODE' -const fs = require('fs'); -const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (manifest.installMode !== 'product-owned') { - throw new Error(`expected product-owned installMode, got ${manifest.installMode || ''}`); -} -if ((manifest.installedPaths || []).length !== 0 || (manifest.patchedFiles || []).length !== 0) { - throw new Error('metadata-only install must not report installed or patched product files'); -} -NODE - - "$SKILL_DIR/adapters/mobile/scripts/cleanup.sh" --target "$target" >/tmp/recipe-harness-mobile-partial-product-cleanup.log 2>&1 - [ ! -e "$target/.agent/recipe-harness/mobile" ] \ - || fail "metadata-only cleanup did not remove harness metadata" - grep -qxF '.agent/recipe-harness/' "$target/.git/info/exclude" \ - || fail "metadata-only cleanup removed a pre-existing exclude entry" - ! grep -qxF '.skills-cache/' "$target/.git/info/exclude" \ - || fail "metadata-only cleanup left an install-added exclude entry behind" -} - -assert_install_cleanup_restores_exclude_baseline_byte_identical() { - # An install->cleanup cycle must leave .git/info/exclude byte-for-byte at its - # pre-install baseline, and must never delete the consumer-owned .skills-cache/ - # directory (it is gitignored and product-owned). - local target="$tmpdir/mobile-exclude-byte-identical" - mkdir -p "$target/scripts/perps/agentic" - ( - cd "$target" - git init -q - printf 'product-owned\n' > scripts/perps/agentic/preflight.sh - git add scripts/perps/agentic/preflight.sh - ) - - # Pre-install baseline: developer-owned exclude lines (incl. their own - # .skills-cache/) that the harness must leave untouched. - printf 'node_modules/\n.skills-cache/\n' > "$target/.git/info/exclude" - local baseline - baseline="$(cat "$target/.git/info/exclude")" - - # A consumer-owned skills cache that the harness must never delete. - mkdir -p "$target/.skills-cache" - printf 'cache\n' > "$target/.skills-cache/marker" - - run_mobile_install --target "$target" >/tmp/recipe-harness-mobile-exclude-byte.log 2>&1 - # .skills-cache/ is already excluded, so install must NOT re-record it. - ! grep -qxF '.skills-cache/' "$target/.agent/recipe-harness/mobile/added-git-exclude" \ - || fail "install recorded a developer-owned pre-existing exclude entry (.skills-cache/)" - - "$SKILL_DIR/adapters/mobile/scripts/cleanup.sh" --target "$target" \ - >/tmp/recipe-harness-mobile-exclude-byte-cleanup.log 2>&1 - - [ -f "$target/.skills-cache/marker" ] \ - || fail "cleanup deleted the consumer-owned .skills-cache directory" - [ "$(cat "$target/.git/info/exclude")" = "$baseline" ] \ - || fail "install->cleanup did not restore .git/info/exclude byte-for-byte (pre-existing exclude lines lost)" -} - -assert_cleanup_removes_only_one_copy_per_recorded_exclude_entry() { - # Regression: an unconditional `grep -vxF` removal dropped EVERY copy of a - # recorded exclude line. cleanup must remove only the single occurrence this - # install added, leaving a developer's own duplicate copy intact. - local target="$tmpdir/mobile-exclude-one-occurrence" - mkdir -p "$target/scripts/perps/agentic" - ( - cd "$target" - git init -q - printf 'product-owned\n' > scripts/perps/agentic/preflight.sh - git add scripts/perps/agentic/preflight.sh - ) - - # No tracked .gitignore: install records `.skills-cache/` as harness-added. - : > "$target/.git/info/exclude" - run_mobile_install --target "$target" >/tmp/recipe-harness-mobile-exclude-one.log 2>&1 - grep -qxF '.skills-cache/' "$target/.agent/recipe-harness/mobile/added-git-exclude" \ - || fail "fixture precondition: install did not record .skills-cache/ in the ledger" - - # A developer independently keeps their OWN identical exclude line plus an - # unrelated one. cleanup must not nuke the developer's duplicate. - printf '.skills-cache/\nnode_modules/\n' >> "$target/.git/info/exclude" - - "$SKILL_DIR/adapters/mobile/scripts/cleanup.sh" --target "$target" \ - >/tmp/recipe-harness-mobile-exclude-one-cleanup.log 2>&1 - - local remaining - remaining="$(grep -cxF '.skills-cache/' "$target/.git/info/exclude" 2>/dev/null || true)" - [ "${remaining:-0}" = "1" ] \ - || fail "cleanup must leave exactly one .skills-cache/ copy (developer's), got ${remaining:-0}" - grep -qxF 'node_modules/' "$target/.git/info/exclude" \ - || fail "cleanup dropped an unrelated developer exclude line (node_modules/)" -} - -assert_installer_refuses_symlinked_runner_destinations() { - local mobile_target="$tmpdir/mobile-symlink-runner" - local extension_target="$tmpdir/extension-symlink-runner" - mkdir -p "$mobile_target/.agent/recipe-harness/mobile" "$mobile_target/scripts/perps/agentic" - mkdir -p "$extension_target/.agent/recipe-harness/extension" - ( - cd "$mobile_target" - git init -q - printf 'product-owned\n' > scripts/perps/agentic/preflight.sh - git add scripts/perps/agentic/preflight.sh - ) - ( - cd "$extension_target" - git init -q - ) - ln -s "$tmpdir/outside-mobile-runner" "$mobile_target/.agent/recipe-harness/mobile/runner" - ln -s "$tmpdir/outside-extension-runner" "$extension_target/.agent/recipe-harness/extension/runner" - - set +e - run_mobile_install --target "$mobile_target" >/tmp/recipe-harness-mobile-symlink-runner.log 2>&1 - local mobile_rc=$? - FARMSLOT_ROOT="$FARMSLOT_FIXTURE" \ - METAMASK_RECIPE_RUNNER_SOURCE="$RUNNER_FIXTURE" \ - "$SKILL_DIR/adapters/extension/scripts/install.sh" --target "$extension_target" \ - >/tmp/recipe-harness-extension-symlink-runner.log 2>&1 - local extension_rc=$? - set -e - - [ "$mobile_rc" -ne 0 ] || fail "mobile install followed symlinked runner destination" - [ "$extension_rc" -ne 0 ] || fail "extension install followed symlinked runner destination" - grep -q 'symlink' /tmp/recipe-harness-mobile-symlink-runner.log \ - || fail "mobile symlink refusal did not explain symlink risk" - grep -q 'symlink' /tmp/recipe-harness-extension-symlink-runner.log \ - || fail "extension symlink refusal did not explain symlink risk" -} - -assert_runner_source_precedence_allows_stale_lower_priority_env() { - local target="$tmpdir/runner-source-precedence" - mkdir -p "$target/scripts/perps/agentic" - ( - cd "$target" - git init -q - printf 'product-owned\n' > scripts/perps/agentic/preflight.sh - git add scripts/perps/agentic/preflight.sh - ) - - FARMSLOT_ROOT="$FARMSLOT_FIXTURE" \ - METAMASK_RECIPE_RUNNER_SOURCE="$RUNNER_FIXTURE" \ - RECIPE_RUNNER_SOURCE="$tmpdir/missing-lower-priority-runner" \ - "$SKILL_DIR/adapters/mobile/scripts/install.sh" --target "$target" \ - >/tmp/recipe-harness-runner-source-precedence.log 2>&1 - - node - "$target/.agent/recipe-harness/mobile/manifest.json" <<'NODE' -const fs = require('fs'); -const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (manifest.source.runnerSourceKind !== 'env:METAMASK_RECIPE_RUNNER_SOURCE') { - throw new Error(`expected highest-priority runner source, got ${manifest.source.runnerSourceKind}`); -} -NODE -} - -assert_public_git_runner_fallback_installs_without_farmslot_root() { - local source_repo="$tmpdir/public-runner-source" - local target="$tmpdir/public-runner-target" - local cache="$tmpdir/public-runner-cache" - mkdir -p "$source_repo/bin" "$source_repo/manifests" "$target" - printf '{"name":"@metamask/recipe-runner-fixture"}\n' > "$source_repo/package.json" - printf '#!/usr/bin/env bash\nprintf "public fixture runner\\n"\n' > "$source_repo/bin/metamask-recipe" - chmod +x "$source_repo/bin/metamask-recipe" - printf '{"runner_protocol_version":1,"action_registry_version":1,"supported_official_actions":[],"action_metadata":{}}\n' \ - > "$source_repo/manifests/mobile.action-manifest.json" - printf '{"runner_protocol_version":1,"action_registry_version":1,"supported_official_actions":[],"action_metadata":{}}\n' \ - > "$source_repo/manifests/extension.action-manifest.json" - ( - cd "$source_repo" - git init -q - git checkout -q -b main - git add . - git -c user.email=fixture@example.com -c user.name=Fixture commit -q -m 'fixture runner' - ) - ( - cd "$target" - git init -q - ) - - env -u FARMSLOT_ROOT -u METAMASK_RECIPE_RUNNER_SOURCE -u RECIPE_RUNNER_SOURCE -u METAMASK_RECIPE_RUNNER_PACKAGE_DIR \ - METAMASK_RECIPE_RUNNER_GIT_URL="file://$source_repo" \ - METAMASK_RECIPE_RUNNER_GIT_REF=main \ - METAMASK_RECIPE_RUNNER_CACHE_DIR="$cache" \ - METAMASK_RECIPE_RUNNER_SIBLING_DISABLED=1 \ - "$SKILL_DIR/adapters/extension/scripts/install.sh" --target "$target" \ - >/tmp/recipe-harness-public-runner-fallback.log 2>&1 - - node - "$target/.agent/recipe-harness/extension/manifest.json" <<'NODE' -const fs = require('fs'); -const path = require('path'); -const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (!manifest.source.runnerSourceKind.startsWith('git:file://')) { - throw new Error(`expected git fallback source kind, got ${manifest.source.runnerSourceKind}`); -} -const runnerDir = path.dirname(path.dirname(manifest.runnerEntrypoint)); -if (fs.existsSync(path.join(runnerDir, '.farmslot-root'))) { - throw new Error('git fallback should not record a FARMSLOT_ROOT when published packages are used'); -} -NODE - "$target/.agent/recipe-harness/extension/runner/bin/metamask-recipe" | grep -qxF 'public fixture runner' \ - || fail "installed git fallback delegate did not execute the cached runner" -} - -assert_mobile_adapter_scripts_parse_with_macos_bash() { - local bash_bin="${BASH_SYNTAX_BIN:-/bin/bash}" - if [ ! -x "$bash_bin" ]; then - bash_bin="bash" - fi - - local script - for script in "$SKILL_DIR"/adapters/mobile/scripts/*.sh; do - [ -f "$script" ] || continue - "$bash_bin" -n "$script" || fail "mobile adapter script is not parseable by $bash_bin: $script" - done -} - -assert_extension_start_test_watch_is_target_scoped() { - local live="$SKILL_DIR/adapters/extension/scripts/live.sh" - node - "$live" <<'NODE' -const fs = require('fs'); -const src = fs.readFileSync(process.argv[2], 'utf8'); -if (src.includes("pgrep -f 'yarn start:test'")) { - throw new Error('extension live --start-test-watch must not use machine-global pgrep'); -} -if (!src.includes('watch_pid_file=temp/runtime/recipe-harness-webpack.pid')) { - throw new Error('extension live --start-test-watch must use a target-scoped watcher pid file'); -} -if (!src.includes('compiled=false') || !src.includes('Timed out waiting for target-scoped yarn start compilation marker')) { - throw new Error('extension live --start-test-watch compile wait must fail when marker is not observed'); -} -NODE -} - -assert_recipe_docs_validate_clean() { - # All committed recipe-authoring docs + embedded smoke recipes must validate - # against the vendored manifest vocabulary (offline; no external runner needed). - node "$SKILL_DIR/scripts/validate-recipe-docs.js" >/tmp/recipe-harness-validate-docs.log 2>&1 \ - || { cat /tmp/recipe-harness-validate-docs.log >&2; fail "recipe-doc validator reported violations on committed docs"; } -} - -assert_recipe_docs_validator_catches_bad_recipe() { - # Negative test: prove the validator actually catches drift (unknown action, - # an invalid assert_json field, and a stale field token in PROSE), so a green - # run means something. - local bad="$tmpdir/bad-recipe-doc.md" - cat > "$bad" <<'MD' -# deliberately broken recipe doc (negative test fixture) - -Wait with `ui.wait_for` using `text_contains` (stale prose field). - -```json -{ "action": "assert_json", "path": "x.json", "equals": { "a": 1 } } -``` - -```json -{ "action": "metamask.not_a_real_action" } -``` -MD - set +e - node "$SKILL_DIR/scripts/validate-recipe-docs.js" "$bad" >/tmp/recipe-harness-validate-bad.log 2>&1 - local rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validator did not fail on a deliberately-broken recipe doc" - grep -q 'equals' /tmp/recipe-harness-validate-bad.log || fail "validator did not flag the invalid assert_json 'equals' field" - grep -q 'unknown action' /tmp/recipe-harness-validate-bad.log || fail "validator did not flag the unknown action name" - grep -q 'text_contains' /tmp/recipe-harness-validate-bad.log || fail "validator did not flag the stale prose field token" -} - -assert_recipe_docs_validator_catches_vocab_drift() { - # Two-way reconcile regression: the real fixture vs a minimal manifest must FAIL - # on fixture-only (stale/removed) actions; an empty manifest must hard-fail. - local cleanmd="$tmpdir/clean-recipe-doc.md" - : > "$cleanmd" - local minman="$tmpdir/min-action-manifest.json" - printf '{"supported_official_actions":["command"],"custom_actions":[{"name":"metamask.wallet.setup"}]}\n' > "$minman" - set +e - node "$SKILL_DIR/scripts/validate-recipe-docs.js" --manifest "$minman" "$cleanmd" >/tmp/recipe-harness-validate-drift.log 2>&1 - local rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validator did not fail on a fixture-vs-minimal-manifest divergence" - grep -q 'in the fixture but not in the manifest' /tmp/recipe-harness-validate-drift.log \ - || fail "validator did not report fixture-only (stale) action divergence" - - local emptyman="$tmpdir/empty-action-manifest.json" - printf '{}\n' > "$emptyman" - set +e - node "$SKILL_DIR/scripts/validate-recipe-docs.js" --manifest "$emptyman" "$cleanmd" >/tmp/recipe-harness-validate-empty.log 2>&1 - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validator did not hard-fail on an empty manifest" - grep -qi 'empty/absent' /tmp/recipe-harness-validate-empty.log || fail "validator did not report empty manifest action list" -} - -assert_extension_verify_does_not_autostart_by_default -assert_no_mobile_harness_bundled_in_skill -assert_force_overlay_requires_external_mobile_source -assert_verify_marks_harness_owned_only_after_preflight_success -assert_partial_product_harness_install_is_metadata_only -assert_install_cleanup_restores_exclude_baseline_byte_identical -assert_cleanup_removes_only_one_copy_per_recorded_exclude_entry -assert_installer_refuses_symlinked_runner_destinations -assert_runner_source_precedence_allows_stale_lower_priority_env -assert_public_git_runner_fallback_installs_without_farmslot_root -assert_mobile_adapter_scripts_parse_with_macos_bash -assert_extension_start_test_watch_is_target_scoped -assert_recipe_docs_validate_clean -assert_recipe_docs_validator_catches_bad_recipe -assert_recipe_docs_validator_catches_vocab_drift - -echo "recipe-harness safety contracts OK" diff --git a/domains/agentic/skills/recipe-harness/scripts/validate-recipe-docs.js b/domains/agentic/skills/recipe-harness/scripts/validate-recipe-docs.js deleted file mode 100755 index 1030235..0000000 --- a/domains/agentic/skills/recipe-harness/scripts/validate-recipe-docs.js +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// validate-recipe-docs.js — offline validator that keeps every recipe-AUTHORING -// example (fenced ```json recipe blocks in the recipe-* skill docs) and the -// adapter verify.sh embedded smoke recipes consistent with the MetaMask v1 runner -// manifest, so doc/recipe field-schema drift is caught mechanically. -// -// Source of truth: the runner manifest. Action NAMES + field SHAPES are encoded -// in the committed vendored fixture recipe-action-vocab.fixture.json (derived -// from the metamask-recipe-runner manifests AND the runner's shipped recipes, -// because the installed manifest's action_metadata examples are minimal while the -// shipped recipes reveal the full accepted field set). The fixture is the offline -// fallback so this does not hard-depend on an external runner checkout. -// -// If an installed manifest is found under the harness root (or passed via -// --manifest ), the validator RECONCILES the fixture's action-name lists -// against it and fails on divergence — that is the "prefer the installed -// manifest" drift guard. Field schemas always come from the fixture. -// -// Checks (exit nonzero on any): (1) a fenced json recipe block that does not parse -// as a single JSON value; (2) an unknown action name; (3) a node field that -// contradicts the action's known field set; (4) a removed/stale field token in -// PROSE (denylist in the fixture's prose.forbiddenFieldPatterns). Reports -// file:line for each. -// -// When a manifest is available it is reconciled BOTH ways (fail on actions only in -// the manifest AND only in the fixture) and hard-fails on an unreadable/empty -// manifest — a stale fixture or drifted/empty manifest can never report OK. -// -// SCOPE LIMIT: only fenced json recipe blocks + the adapter verify.sh embedded -// recipes are fully field-validated. Free prose is checked ONLY against the -// stale-field denylist, not the full schema, so a brand-new wrong field in prose -// (not on the denylist) can still slip through. Keep authoring field guidance in -// fenced json examples; add removed fields to the denylist via gen-action-vocab.js. -// nameOnly actions (no action_metadata) are validated against universal + their -// shipped-recipe field set; a valid-but-never-yet-shipped field could be flagged. -// -// Usage: -// validate-recipe-docs.js [--manifest ] [--target ] -// [--fixture ] [file ...] -// With no file args it scans the default recipe-* docs + adapter verify.sh recipes. - -const fs = require('node:fs'); -const path = require('node:path'); - -// __dirname = domains/agentic/skills/recipe-harness/scripts → up 2 = the skills dir. -const SKILL_ROOT = path.resolve(__dirname, '../..'); // domains/agentic/skills - -function parseArgs(argv) { - const a = { manifest: '', target: '', fixture: '', files: [] }; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--manifest') a.manifest = argv[++i] || ''; - else if (arg === '--target') a.target = argv[++i] || ''; - else if (arg === '--fixture') a.fixture = argv[++i] || ''; - else if (arg === '-h' || arg === '--help') { printHelp(); process.exit(0); } - else a.files.push(arg); - } - return a; -} - -function printHelp() { - console.error('Usage: validate-recipe-docs.js [--manifest ] [--target ] [--fixture ] [file ...]'); -} - -function loadFixture(fixturePath) { - const p = fixturePath || path.join(__dirname, 'recipe-action-vocab.fixture.json'); - const v = JSON.parse(fs.readFileSync(p, 'utf8')); - return { - official: new Set(v.officialActions || []), - custom: new Set(v.customActions || []), - nameOnly: new Set(v.nameOnlyActions || []), - universal: new Set(v.universalFields || []), - actionFields: v.actionFields || {}, - forbidden: (v.prose && v.prose.forbiddenFieldPatterns) || [], - meta: { protocolVersion: v.protocolVersion, registryVersion: v.registryVersion }, - }; -} - -// Two-way "prefer installed manifest" drift guard. Hard-fails on an -// unreadable/empty manifest, and on action sets that diverge in EITHER direction -// (only-in-manifest = stale fixture; only-in-fixture = removed from manifest), so a -// stale fixture or a drifted/empty manifest can never report OK. -function reconcileNames(vocab, manifestPath) { - let m; - try { m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } - catch (e) { return [`manifest ${manifestPath} is unreadable/unparseable: ${e.message}`]; } - const official = m.supported_official_actions || []; - const custom = (m.custom_actions || []).map((c) => (typeof c === 'string' ? c : c && c.name)).filter(Boolean); - if (!official.length) return [`manifest ${manifestPath} has an empty/absent supported_official_actions list`]; - if (!custom.length) return [`manifest ${manifestPath} has an empty/absent custom_actions list`]; - const mOff = new Set(official); - const mCus = new Set(custom); - const errs = []; - for (const n of official) if (!vocab.official.has(n)) errs.push(`official action '${n}' is in the manifest but missing from the fixture — regenerate recipe-action-vocab.fixture.json`); - for (const n of custom) if (!vocab.custom.has(n)) errs.push(`custom action '${n}' is in the manifest but missing from the fixture — regenerate recipe-action-vocab.fixture.json`); - for (const n of vocab.official) if (!mOff.has(n)) errs.push(`official action '${n}' is in the fixture but not in the manifest (removed/renamed?) — regenerate recipe-action-vocab.fixture.json`); - for (const n of vocab.custom) if (!mCus.has(n)) errs.push(`custom action '${n}' is in the fixture but not in the manifest (removed/renamed?) — regenerate recipe-action-vocab.fixture.json`); - return errs; -} - -function findInstalledManifest(target) { - const root = process.env.RECIPE_HARNESS_ROOT || 'temp/agentic/recipe-harness'; - for (const adapter of ['mobile', 'extension']) { - const p = path.join(target || process.cwd(), root, adapter, 'action-manifest.json'); - if (fs.existsSync(p)) return p; - } - return ''; -} - -// Deep-collect every object with a string `action` anywhere in the value tree. -// This covers full recipes (validate.workflow.nodes), single nodes, node arrays, -// AND inline action nodes nested under cases/default/setup/teardown branches. -function collectActionNodes(value, out = []) { - if (Array.isArray(value)) { for (const v of value) collectActionNodes(v, out); return out; } - if (value && typeof value === 'object') { - if (typeof value.action === 'string') out.push(value); - for (const v of Object.values(value)) if (v && typeof v === 'object') collectActionNodes(v, out); - } - return out; -} - -function validateNode(node, vocab, where, violations) { - const action = node.action; - if (!vocab.official.has(action) && !vocab.custom.has(action)) { - violations.push(`${where}: unknown action "${action}" (not in supported_official_actions or custom_actions)`); - return; - } - validateIntent(node, where, violations); - // Every action (including nameOnly actions, whose field set is derived from - // shipped-recipe usage) is checked against universal + its known field set. - const allowed = new Set([...vocab.universal, ...(vocab.actionFields[action] || [])]); - for (const field of Object.keys(node)) { - if (!allowed.has(field)) { - violations.push(`${where}: action "${action}" has field "${field}" not in its manifest field set [${[...allowed].sort().join(', ')}]`); - } - } -} - -function validateIntent(node, where, violations) { - if (node.action === 'end') return; - if (typeof node.intent !== 'string' || !node.intent.trim()) { - violations.push(`${where}: action "${node.action}" must include human-facing node.intent`); - return; - } - const normalized = normalizeIntent(node.intent); - const blocked = [ - 'executing recipe step', - 'run', - 'setup', - 'ui', - 'wallet', - 'perps', - 'test', - 'step', - 'execute', - 'click', - 'press', - 'wait', - 'screenshot', - 'capture', - 'assert', - 'command', - 'selector', - 'test id', - 'node id', - node.action, - node.id, - node.ref, - node.selector, - node.test_id, - node.testID, - ] - .filter((value) => typeof value === 'string' && value.trim()) - .map(normalizeIntent); - if (blocked.includes(normalized)) { - violations.push(`${where}: action "${node.action}" has generic/debug-ish node.intent "${node.intent}"`); - } -} - -function normalizeIntent(value) { - return String(value).trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' '); -} - -// Extract ```json ... ``` fences with their starting line numbers. -function jsonFences(src) { - const lines = src.split('\n'); - const out = []; - for (let i = 0; i < lines.length; i += 1) { - if (/^\s*```json\s*$/i.test(lines[i])) { - const startLine = i + 1; - const buf = []; - i += 1; - while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; } - out.push({ startLine, text: buf.join('\n') }); - } - } - return out; -} - -function validateMarkdown(file, vocab, violations) { - const src = fs.readFileSync(file, 'utf8'); - for (const fence of jsonFences(src)) { - if (!fence.text.includes('"action"')) continue; // not a recipe block - const where = `${file}:${fence.startLine}`; - let value; - try { value = JSON.parse(fence.text); } catch (e) { - violations.push(`${where}: json recipe block does not parse as a single JSON value (${e.message}). Wrap multiple node examples in a JSON array.`); - continue; - } - const nodes = collectActionNodes(value); - if (!nodes.length) continue; // parsed JSON but not a recipe/node shape - for (const node of nodes) validateNode(node, vocab, where, violations); - } -} - -// Prose isn't fully field-validated; this catches the curated denylist of -// stale/removed field tokens (fixture prose.forbiddenFieldPatterns) anywhere in a -// doc — code or prose — so removed fields can't silently linger in guidance. -function scanProseForbidden(file, vocab, violations) { - if (!vocab.forbidden.length) return; - const lines = fs.readFileSync(file, 'utf8').split('\n'); - const res = vocab.forbidden.map((p) => ({ src: p, re: new RegExp(p) })); - for (let i = 0; i < lines.length; i += 1) { - for (const { src, re } of res) { - if (re.test(lines[i])) violations.push(`${file}:${i + 1}: stale/removed field token matching /${src}/ in prose — reconcile to the manifest field set`); - } - } -} - -// Extract a single heredoc recipe ( <<'JSON' ... JSON ) from an adapter verify.sh. -function validateEmbeddedRecipe(file, vocab, violations) { - if (!fs.existsSync(file)) return; - const lines = fs.readFileSync(file, 'utf8').split('\n'); - for (let i = 0; i < lines.length; i += 1) { - if (/<<'JSON'/.test(lines[i])) { - const startLine = i + 1; - const buf = []; - i += 1; - while (i < lines.length && !/^JSON$/.test(lines[i])) { buf.push(lines[i]); i += 1; } - const where = `${file}:${startLine} (embedded smoke recipe)`; - let value; - try { value = JSON.parse(buf.join('\n')); } catch (e) { - violations.push(`${where}: embedded recipe does not parse (${e.message})`); - continue; - } - for (const node of collectActionNodes(value)) validateNode(node, vocab, where, violations); - } - } -} - -function defaultMarkdownTargets() { - const skills = ['recipe-cook', 'recipe-wallet-control', 'recipe-dev', 'recipe-fix-ticket', 'recipe-doctor', 'recipe-evidence', 'recipe-quality', 'recipe-harness']; - const files = []; - const walk = (dir) => { - for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, ent.name); - if (ent.isDirectory()) walk(p); - else if (ent.name.endsWith('.md')) files.push(p); - } - }; - for (const s of skills) { const d = path.join(SKILL_ROOT, s); if (fs.existsSync(d)) walk(d); } - return files; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - const vocab = loadFixture(args.fixture); - const violations = []; - - const manifestPath = args.manifest || findInstalledManifest(args.target); - if (manifestPath) { - const nameErrs = reconcileNames(vocab, manifestPath); - if (nameErrs.length) { for (const e of nameErrs) console.error(`[vocab-drift] ${e}`); violations.push(...nameErrs); } - else console.error(`[validate-recipe-docs] reconciled fixture action names against ${manifestPath} — OK`); - } else { - console.error('[validate-recipe-docs] no installed manifest found; using committed vocabulary fixture (offline).'); - } - - const mdTargets = args.files.length ? args.files.filter((f) => f.endsWith('.md')) : defaultMarkdownTargets(); - for (const f of mdTargets) { validateMarkdown(f, vocab, violations); scanProseForbidden(f, vocab, violations); } - - const verifyScripts = args.files.length - ? args.files.filter((f) => f.endsWith('verify.sh')) - : [ - path.join(SKILL_ROOT, 'recipe-harness/adapters/mobile/scripts/verify.sh'), - path.join(SKILL_ROOT, 'recipe-harness/adapters/extension/scripts/verify.sh'), - ]; - for (const f of verifyScripts) validateEmbeddedRecipe(f, vocab, violations); - - if (violations.length) { - console.error(`\n${violations.length} recipe-doc validation violation(s):`); - for (const v of violations) console.error(` - ${v}`); - process.exit(1); - } - console.error(`[validate-recipe-docs] OK — all recipe blocks valid against vocab (protocol ${vocab.meta.protocolVersion}/registry ${vocab.meta.registryVersion}).`); -} - -main(); diff --git a/domains/agentic/skills/recipe-harness/skill.md b/domains/agentic/skills/recipe-harness/skill.md deleted file mode 100644 index a77f028..0000000 --- a/domains/agentic/skills/recipe-harness/skill.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -name: recipe-harness -description: Install, verify, and clean up MetaMask recipe runtimes for Mobile and Extension checkouts. Use before recipe-cook, recipe-wallet-control, recipe-evidence, or recipe-quality when runtime evidence needs CDP/browser/mobile recipe execution, especially on historical commits or fresh checkouts. -maturity: experimental ---- - -# Recipe Harness - -`recipe-harness` makes a product checkout recipe-capable without making the product repo permanently own the runtime files. - -The skill is a thin UX wrapper. It does **not** define the graph executor or final runtime source. Install resolves a MetaMask recipe runner package/source, copies that runner into the ignored checkout overlay, and records the resolved source in `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//manifest.json`. - -Runner source resolution order: - -1. `METAMASK_RECIPE_RUNNER_SOURCE` -2. `RECIPE_RUNNER_SOURCE` -3. `METAMASK_RECIPE_RUNNER_PACKAGE_DIR` -4. sibling checkout `../metamask-recipe-runner` next to `metamask-skills` -5. cached public git fallback: `https://github.com/deeeed/metamask-recipe-runner.git` at `main` - -The public fallback is temporary for the ADR-58 proof-of-concept period: the -runner is hosted under Arthur's personal `deeeed` account so engineers and cloud -agents can test the experimental skills before the runner is fully validated -and migrated to the MetaMask organization. - -Override the fallback with `METAMASK_RECIPE_RUNNER_GIT_URL`, -`METAMASK_RECIPE_RUNNER_GIT_REF`, or `METAMASK_RECIPE_RUNNER_CACHE_DIR`. -Set `METAMASK_RECIPE_RUNNER_GIT_DISABLED=1` to require an explicit local source. -Set `METAMASK_RECIPE_RUNNER_SIBLING_DISABLED=1` to ignore a sibling checkout and -force the cached public git fallback. - -The runner is a separate project. It only resolves a runner source, copies it into `${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}//runner/`, and records the source path/revision in the install manifest. - -## Rules - -- Run `install` before claiming runtime recipe proof. -- Run `verify`; failed harness verification blocks runtime claims and is not a product failure. -- Keep product diffs/evidence separate from harness overlay files. -- Record the harness manifest path, source version, adapter, verification status, and artifacts in PR evidence. See references/contract.md (source revision caveat). -- Call direct injected scripts for automation. `yarn a:*` aliases are developer convenience only. -- Treat "app/browser is open" as insufficient. Verification must prove the Recipe v1 observability layer is present: CDP target, recipe bridge, log capture, screenshot capture, fixture/profile status, and cleanup ownership. -- Avoid full rebuilds by default. Reuse an already-compatible harness runtime, installed app, shared build cache, Expo/native build artifacts, or Extension watch output before starting expensive builds. Mobile verify defaults to `--preflight-mode fast`; only use `auto`, `rebuild-native`, or `clean` after the caller/human explicitly opts into a rebuild. -- Report fixture status before long runtime debugging. If fixtures are missing, tell the human the run may spend time repairing wallet/perps state manually and give the exact fixture setup path. - -## Consent gates (ask the human first) - -`recipe-harness` runs headless and never prompts. Two install actions change local state outside the ignored overlay and **require explicit user confirmation before you invoke them** — surface exactly what will change and wait for a yes: - -1. **Overwriting the in-repo agentic bridge/HUD** (`install --force-overlay`). This replaces tracked product files (`scripts/perps/agentic`, `app/core/AgenticService`, `package.json`, `app/core/NavigationService/NavigationService.ts`, `app/components/Nav/App/App.tsx`) with the skills overlay. It is the intended way to refresh a stale or older-commit checkout to the current bridge/HUD, but it mutates product-owned source — confirm first. Files are backed up and restored by `cleanup`. -2. **Adding local `.git/info/exclude` entries.** Install appends harness paths (`${RECIPE_HARNESS_ROOT:-temp/agentic/recipe-harness}/`, `.skills-cache/`, `temp/agentic/recipe-harness/`, and on a full install `scripts/perps/agentic/`, `app/core/AgenticService/`) to the checkout's local exclude so overlay files don't surface as untracked. Entries are tracked and removed by `cleanup`. Pass `--no-git-exclude` to skip. Confirm before mutating a checkout's git config. - -Default (no `--force-overlay`) is non-destructive: a product-owned checkout keeps its own source and install writes only ignored metadata. Prefer that path unless the human approved an overwrite. - -## Command Shape - -For humans, prefer the portable smart wrapper from either the source skill checkout or the installed target skill. Do not require personal shell aliases; call the skill-owned script by path: - -```bash -/scripts/recipe-harness # auto-detect current repo and install -/scripts/recipe-harness launch --platform ios --preflight-mode fast -/scripts/recipe-harness live --platform ios --preflight-mode fast -/scripts/recipe-harness launch --platform android --preflight-mode fast -/scripts/recipe-harness live --cdp-port --out -/scripts/recipe-harness verify --static-only -/scripts/recipe-harness verify --cdp-port -/scripts/recipe-harness verify --preflight-mode fast -``` - -`recipe-harness` auto-detects `metamask-mobile` vs `metamask-extension`, defaults `--target` to the current directory, prints progress, and defaults to `install` when no action is supplied. `launch` starts or reuses the app/browser runtime and waits for app-control readiness; it does not run a recipe or claim validation evidence. - -Use `live` when a developer wants the easiest manual validation command: it runs `launch` and then live `verify` in one skill-owned command, writing a top-level `summary.json` that links to both phases. - -For Mobile launch/live verification, `--preflight-mode fast` is the default cache-first mode: it can reuse an installed matching app or a shared cache artifact, but it fails instead of launching a native rebuild. If a rebuild is genuinely needed, the caller should rerun explicitly with `--preflight-mode auto` after the human accepts the rebuild cost. - -For Extension launch/live: - -- Reuse an open CDP runtime with `--cdp-port`, or pass a startup command via `--prepare-cmd` / `RECIPE_HARNESS_EXTENSION_LAUNCH_CMD`. -- Or provide `RECIPE_RUNTIME_CONTEXT` / `temp/runtime/agentic-runtime.json` with `runtimeStart.approved: true` and `runtimeStart.command`; the wrapper forwards it as `--prepare-cmd`. Outside managed runtimes, set `RECIPE_RUNTIME_START_APPROVED=1` plus `RECIPE_RUNTIME_START_CMD`. -- If recipes were installed to a task-local path, pass it with `live --out ` to avoid falling back to stale defaults. -- Do not invent a build command if no startup approval is present. -- `live --cdp-port --launch-existing-dist` launches Chrome against an already-built `dist/chrome`. -- `live --cdp-port --start-test-watch` starts test watch then Chrome; use only after caller/human accepted the build cost. - -For orchestration or explicit automation, keep using the low-level stable form: - -```bash -/scripts/recipe-harness.sh --target [...] -``` - -See `references/contract.md` for the manifest and validation contract. diff --git a/domains/agentic/skills/recipe-quality/references/examples.md b/domains/agentic/skills/recipe-quality/references/examples.md deleted file mode 100644 index b00310f..0000000 --- a/domains/agentic/skills/recipe-quality/references/examples.md +++ /dev/null @@ -1,41 +0,0 @@ -# Critique Examples - -## Weak Critique - -Bad: - -```text -Verdict: pass-with-gaps. Add better waits and more artifacts. -``` - -Why it fails: it does not identify the missing proof target, the exact node, or the next edit. - -## Strong Critique - -Good: - -```text -Verdict: fail. PT-2 is not proven because the recipe can pass after tapping Continue without verifying the error cleared. - -Coverage Gaps -- must-fix: PT-2 needs an assertion after `enter-valid`. Add `assert_json` or a manifest-declared state assertion for `send.amount.validState` expecting `{ "errorVisible": false, "continueEnabled": true }`. - -Graph / Flow Issues -- should-fix: `capture` runs immediately after `enter-valid`. Insert `ui.wait_for` or the state assertion before the screenshot. - -Evidence Mismatches -- must-fix: `screenshots/after.png` is labeled as the settled valid screen, but no trace node proves the screen settled before capture. Link it to the new assertion node. - -Suggested Fixes -1. Add the missing valid-state assertion. -2. Move screenshot capture after the assertion. -3. Add `index_artifacts` with the screenshot and trace linked to PT-2. -``` - -## Evidence Verdict Examples - -Use `pass` only when the recipe and artifacts prove the claims. - -Use `pass-with-gaps` when the core claim is likely proven but a non-blocking gap remains, such as missing manifest metadata or an unimportant artifact label. - -Use `fail` when a claim is unproven, the graph can pass unconditionally, the runner did not execute the relevant path, or the evidence contradicts the recipe. diff --git a/domains/agentic/skills/recipe-quality/references/rubric.md b/domains/agentic/skills/recipe-quality/references/rubric.md deleted file mode 100644 index 6c2cca9..0000000 --- a/domains/agentic/skills/recipe-quality/references/rubric.md +++ /dev/null @@ -1,100 +0,0 @@ -# Recipe Quality Rubric - -Canonical protocol source of truth: `$FARMSLOT_ROOT/docs/RECIPE-PROTOCOL-V1.md`. This rubric judges recipe quality on top of that contract. - -Use this rubric to produce findings, not a scorecard. Mark each issue as `must-fix`, `should-fix`, or `nit`. - -## Coverage - -Every acceptance criterion or proof target needs: - -- an executable path; -- a clear assertion or observation; -- reviewer-visible evidence when the behavior is visible; -- an explicit note if it is manual, untestable, or environment-dependent. - -`must-fix`: a named PR claim has no action path or no assertion. - -## Graph Structure - -For v1 recipes, check: - -- `schema_version: 1`, `validate.workflow.entry`, and `validate.workflow.nodes`; top-level `title`/`description` are optional metadata only; -- every non-terminal executable node has specific human-facing `intent`; -- entry node exists; -- every non-terminal node has `next`, `cases`, or `default`; -- transition targets exist; -- at least one terminal `end` node exists; -- `assert_exit_code` nodes use numeric `expected`, not ambiguous fields such as `code`; -- setup, action, assertion, evidence, and teardown are not collapsed into one opaque node. - -`must-fix`: the graph cannot execute, can pass unconditionally, or uses missing/generic/debug-ish `intent` such as action names, node ids, selectors, test ids, recipe title/description, screenshot notes, or labels like `run`, `setup`, `ui`, `wallet`, or `perps`. - - -## Composition and Start State - -A production recipe should behave like a composed program: - -- each AC maps to a focused proof flow; -- setup uses reusable parameterized idempotent `ensure_*` flows when available; -- the start state is explicit and parameterized; -- proof media starts at the relevant user interaction, not at generic setup; -- setup and start-state work still appear in trace/summary artifacts. - -`must-fix`: the recipe depends on hidden wallet/account/network/provider/page state. - -`should-fix`: the recipe duplicates unlock, navigation, provider selection, fixture setup, or positive/negative position variants that a published parameterized `ensure_*`/assert flow should own. - -## Adapter and Reuse - -Recipes may use project-specific actions, but the contract must be explicit. Flag: - -- undocumented local helpers; -- implicit skill-only actions with no manifest-declared behavior or no human-facing node intent; -- raw eval when a named project action exists; -- duplicated setup that existing fixtures or `ensure_*` flows already solve; -- `/recipe-wallet-control` used as a hard dependency instead of an optional mobile implementation layer. - -`should-fix`: the recipe works only because the author knows hidden local context. - -## Evidence Fit - -Evidence must prove the claim: - -- Use UI screenshots/videos for user-visible behavior. -- Use test reports, logs, state JSON, or metrics for internal behavior. -- Capture screenshots after settle conditions. -- Link every artifact to a node and proof target. -- Do not let success rely on an artifact whose content is never asserted. -- Negative log assertions must prove the watched log source was live. Prefer a benign marker or heartbeat after the baseline; otherwise record baseline/end offsets and treat `0` appended bytes as a gap, not clean proof. -- For portable recipes, do not fail an otherwise complete evidence package only because an in-graph `index_artifacts` omits runner-generated `summary.json` or `trace.json`; those files may be written after the manifest node. Do flag missing summary/trace files themselves. -- Before marking trace evidence missing, search runner output locations named by the runner, such as `.agent/recipe-runs//summary.json` and `.agent/recipe-runs//trace.json`. If trace exists outside the task artifact directory, use it as authoritative evidence instead of stdout-only counts. - -`must-fix`: evidence exists but does not prove the acceptance criterion. - -## Flake Risk - -Flag: - -- sleeps without state waits; -- long-running command nodes without `timeout_ms`; -- assertions against loading, empty, or transitional UI; -- hidden wallet/account/network/provider/page prerequisites; -- missing fixture reset or teardown; -- device, port, browser, or branch assumptions; -- raw eval that bypasses the user flow under validation; -- artifact paths overwritten by repeated runs. - -`should-fix`: timing or environment assumptions make repeated runs unreliable. - -## Actionability - -Each finding should end with the next concrete edit, for example: - -- split `PT-2` into two proof targets; -- add `ui.wait_for` before `capture-after`; -- replace raw eval with a manifest-declared state assertion; -- add an `index_artifacts` node for screenshots and logs; -- replace inline setup with a domain `ensure_*` flow; -- add teardown to reset wallet state; -- add a temporary UI marker that distinguishes loading from settled empty state. diff --git a/domains/agentic/skills/recipe-quality/repos/metamask-extension.md b/domains/agentic/skills/recipe-quality/repos/metamask-extension.md deleted file mode 100644 index f0fd339..0000000 --- a/domains/agentic/skills/recipe-quality/repos/metamask-extension.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-quality ---- - -# MetaMask Extension Review Notes - -Check these Extension-specific risks: - -- Is the browser/channel, extension build, fixture, and dapp/network dependency stated? -- Does the recipe use existing e2e fixtures and browser helpers where available? -- Are popup, full-screen, service worker, and dapp contexts clearly distinguished? -- Are service worker or controller probes tied to internal claims rather than replacing UI proof? -- Does each screenshot happen after a route, selector, service worker, or controller-state settle condition? -- Are test reports, traces, console logs, and screenshots linked to proof targets? -- Does teardown close browser contexts or reset extension state where needed? - -Fail Extension recipes that rely on raw CDP or service worker eval as the only proof of a popup UI claim. diff --git a/domains/agentic/skills/recipe-quality/repos/metamask-mobile.md b/domains/agentic/skills/recipe-quality/repos/metamask-mobile.md deleted file mode 100644 index 43500e9..0000000 --- a/domains/agentic/skills/recipe-quality/repos/metamask-mobile.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-quality ---- - -# MetaMask Mobile Review Notes - -Check these Mobile-specific risks: - -- Is the simulator/device, platform, build type, and app state stated? -- Is the wallet unlocked through a fixture or documented primitive? -- Are account, balance, network, permissions, and feature flags deterministic? -- Does the recipe use existing page objects, test ids, fixtures, and manifest-declared state/domain actions where available? -- Does each screenshot happen after `ui.wait_for`, route assertion, or a manifest-declared state/domain assertion? -- Does teardown prevent balances, pending transactions, selected network, permissions, or onboarding state from leaking into the next run? -- If `/recipe-wallet-control` appears, is it just the implementation layer for named wallet actions? - -Fail Mobile recipes that prove a visible flow only through runtime state while skipping the user path. diff --git a/domains/agentic/skills/recipe-quality/skill.md b/domains/agentic/skills/recipe-quality/skill.md deleted file mode 100644 index 3ec466c..0000000 --- a/domains/agentic/skills/recipe-quality/skill.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -name: recipe-quality -description: Critique per-PR validation recipes and their evidence. Use when an agent or reviewer needs a structured verdict on acceptance-criteria coverage, recipe graph quality, adapter independence, evidence fit, flake risk, and the highest-value fixes before trusting a recipe. -maturity: experimental ---- - -# Recipe Quality - -`recipe-quality` is the review loop for executable validation recipes. Use it after `/recipe-cook`, after a recipe run, or whenever recipe evidence looks weak. The job is to decide whether the recipe actually proves the intended PR claims and to return concrete fixes, not generic QA advice. - -Load only what applies: - -- Canonical protocol: `$FARMSLOT_ROOT/docs/RECIPE-PROTOCOL-V1.md` -- Full rubric: `references/rubric.md` -- Critique examples: `references/examples.md` -- Target-repo review notes are appended below when installed. - -## Inputs - -Use whatever is available: - -- PR/task context, acceptance criteria, changed files, or proof targets. -- `recipe.json` or another recipe graph. -- Optional run artifacts: `summary.json`, `trace.json`, `artifact-manifest.json`, screenshots, videos, logs, reports, and command output. -- Optional runner/adapter notes from the target repo. - -If evidence is missing, say so. Do not infer that a recipe is validated because the graph looks plausible. - -## Modes - -### Recipe-Only Mode - -Use when only the recipe and task context are present. Judge whether the graph is executable, complete, well decomposed, and likely to produce useful artifacts. - -### Recipe + Evidence Mode - -Use when run artifacts are present. Judge whether the artifacts prove the recipe's named claims, whether the run settled before screenshots/assertions, and whether logs/traces match the expected nodes. - - -## Composition / Start-State Checks - -Treat recipe composition as a first-class quality gate. A clean pass requires the recipe to preserve a concise proof story: - -- each AC/proof target maps to a focused proof flow; -- repeated setup/navigation/teardown is handled by reusable parameterized `ensure_*` flows when the runner publishes them; -- the recipe declares the domain starting state before proof begins; -- proof screenshots/videos capture the user-visible AC interaction, not generic setup noise; -- setup remains reproducible in `trace.json`/`summary.json` even when excluded from proof media. - -For MetaMask Perps, expect setup/start-state flows such as `metamask.wallet.ensure_unlocked` and `metamask.perps.start_state({ network, provider, page, market, position })` when they are available in the installed manifest/flow catalog. - -## Mandatory Failure Conditions - -Return `fail` or `pass-with-gaps` (never clean `pass`) when any of these apply: - -- a visible UI acceptance criterion has no screenshot/video or equivalent - reviewer-visible artifact; -- screenshot/video artifacts for visible UI acceptance criteria are blank, black, - or otherwise non-reviewable unless an alternate reviewer-visible proof is - included and the gap is explicit; -- screenshot/video artifacts for visible UI acceptance criteria do not visibly - show the claimed component/text/state, show it below the fold or obscured, or - show the wrong screen/tab/panel; -- a visual/mixed acceptance criterion relies only on fiber-tree/DOM presence, - `manifest-declared state assertions`, controller state, or recipe pass status without a viewport - assertion (`ui.scroll` `scroll_into_view` + `ui.wait_for` `visible`) and a - screenshot backed by a state assertion; -- DOM-rendered fallback screenshots may satisfy reviewer-visible evidence only - when they are labelled as fallback artifacts and derived from the live page in - the same recipe run; -- `summary.json` or `trace.json` is missing for a claimed runtime recipe pass; -- a production recipe inlines repeated setup/navigation despite available domain `ensure_*` flows; -- a production recipe has no declared setup/start-state contract and relies on hidden wallet/account/network/provider/page state; -- `artifact-manifest.json`/evidence manifest is missing or references files that - do not exist; -- the agent claims acceptance criteria from unit tests only when the task - requested runtime/visual proof; -- the recipe never ran and the response does not clearly mark the gap; -- the run depends on hidden local-runtime-only context when the goal is standalone - skills validation. - -When failing, name the weak layer: product, recipe, fixture/state setup, -harness/runtime, skill instruction, evidence packaging, or runner steering. - -## Required Output Sections - -Always return these sections: - -1. `Verdict` — `pass`, `pass-with-gaps`, or `fail`, with one sentence why. -2. `Coverage Gaps` — missing or weak acceptance-criteria/proof-target coverage. -3. `Graph / Flow Issues` — broken transitions, over-broad nodes, bad boundaries, or missing teardown. -4. `Adapter / Reuse Issues` — unnecessary raw steps, project-specific coupling, or missed existing runner/action reuse. -5. `Evidence Mismatches` — artifacts that do not prove the claim, are mislabeled, or capture intermediate state. -6. `Flake Risks` — weak waits, ambiguous assertions, hidden preconditions, timing hazards, environment assumptions. -7. `Suggested Fixes` — top 3 highest-value changes first, then lower-priority polish. -8. `Suggested Debug Markers` — temporary markers or probes that would make ambiguous evidence decisive. - -## Critique Standards - -Use `references/rubric.md` for the full bar. In short, a good recipe is executable, covers each proof target, uses documented repo actions, waits on state rather than time, produces artifacts that prove the claims, and states any unrun gap. - -Recipe action/field shapes are mechanically checked by `mms-recipe-harness/scripts/validate-recipe-docs.js` (run in the harness safety contracts) against the committed manifest vocabulary fixture. Note its scope limit: it fully validates fenced ` ```json ` recipe blocks and the adapter embedded recipes, but free prose is only checked against a denylist of removed field tokens. When you cite an action's fields in prose, mirror a fenced JSON example and use only manifest-declared fields (e.g. `ui.wait_for` → `intent` plus `test_id`/`text`/`expected`/`visible`; `ui.screenshot` → `intent` plus `path`/optional artifact `note`). - -For each screenshot/video artifact, read the image/video itself before giving a -clean pass. Do not infer visual evidence from filename, recipe status, trace -status, fiber tree, DOM query, or controller assertions. If the caption or -coverage matrix claims a visible component, the component must be visibly -present in the artifact and backed by the shared recipe protocol: - -- the preceding `ui.wait_for` for the target uses `visible` (with `ui.scroll` - `scroll_into_view` when below the fold) when a user is supposed to see it; -- screenshot nodes carry a human-facing `intent` (`note` is only an optional artifact caption), and "must (not) show" conditions are - proven with `assert_json`/`assert_output` (e.g. absence of a misleading generic - state such as empty funding banners); -- if the target is off-screen, the recipe scrolls/navigates first and reruns the - capture instead of shipping a caveat. - -## Debug Marker Guidance - -Recommend temporary debug markers when evidence is ambiguous. Prefer, in order: - -1. UI-state assertions; -2. UI-state debug markers; -3. local runtime state capture; -4. backend/network probes only when explicitly needed. - -The marker should name where to add it and what ambiguity it resolves. diff --git a/domains/agentic/skills/recipe-wallet-control/references/action-vocab.md b/domains/agentic/skills/recipe-wallet-control/references/action-vocab.md deleted file mode 100644 index 4038bdd..0000000 --- a/domains/agentic/skills/recipe-wallet-control/references/action-vocab.md +++ /dev/null @@ -1,43 +0,0 @@ -# Recipe Wallet Control Action Vocabulary - -Use this vocabulary when composing `/recipe-cook` recipes or recording wallet validation evidence. See /recipe-harness for injection. - -## Shared Recipe v1 Actions - -| Action | Args | Use | Return/proof shape | -|---|---|---|---| -| `app.status` | none | Confirm runner compatibility and project shape. | platform, project root, compatibility summary | -| `cdp.target` | optional `cdp_port`, `required` | Prove the automation channel is reachable before UI work. | target/probe metadata or a hard failure when required | -| `app.hud` | `intent`, optional `status`, `progress`, `display` | Communicate the current recipe intent to a human reviewer. | HUD update result | -| `ui.navigate` | `route` (raw app route), optional `params` | Open any app/wallet/perps destination by route. | previous/current route proof | -| `ui.press` | `target` | Drive a real visible press/tap/click. | pressed target proof | -| `ui.scroll` | `test_id`/`selector`, `offset`, optional `scroll_into_view` | Reveal content or controls. | scroll result proof | -| `ui.wait_for` | `test_id`/`selector`/`text`, `expected`, timeout | Wait for settled UI state before proof. | matched/visible proof | -| `ui.screenshot` | `path` | Capture reviewer-visible proof after a settle condition. | registered screenshot artifact | - -## MetaMask Wallet Actions - -| Action | Args | Use | Return/proof shape | -|---|---|---|---| -| `metamask.wallet.fixture_status` | none | Check fixture/profile readiness before wallet setup. | redacted fixture summary | -| `metamask.wallet.setup` | fixture-backed setup | Materialize the configured debug wallet/profile. | setup proof, redacted fixture/account summary | -| `metamask.wallet.ensure_unlocked` | optional password source | Unlock only if the runtime is locked. | unlocked/already-unlocked proof | -| `metamask.wallet.select_account` | `address` | Select a deterministic fixture account. | selected-account proof | -| `metamask.wallet.read_state` | none | Read wallet state without mutating UI. | selected account/network/runtime state | - -Navigation has no wallet- or perps-specific action: use `ui.navigate` with a raw `route` (and optional `params`), e.g. `{ "action": "ui.navigate", "route": "PerpsMarketListView" }`. - -## MetaMask Perps Actions - -| Action | Args | Use | -|---|---|---| -| `metamask.perps.start_state` | `market`, `page`, optional position/order expectations | Converge a recipe to a reproducible Perps starting state. | -| `metamask.perps.teardown_state` | cleanup parameters | Return the account/domain to a reusable state. | -| `metamask.perps.read_positions` / `metamask.perps.read_orders` | optional `market` | Collect domain state for proof. | -| `metamask.perps.assert_positions` / `metamask.perps.assert_orders` | expected `state`, optional `market`/mode | Assert domain state without relying on screenshots alone. | -| `metamask.perps.ensure_positions` / `metamask.perps.ensure_orders` | desired state/mode | Idempotent setup/cleanup building blocks. | -| `metamask.perps.place_order` / `metamask.perps.close_positions` / `metamask.perps.close_orders` | market/order parameters | Execute controlled Perps validation actions. | - -## Boundary - -See `/recipe-harness` `references/contract.md`. Use `ui.*` or a domain action for human-visible criteria; capture `ui.screenshot` as visual proof. diff --git a/domains/agentic/skills/recipe-wallet-control/references/fixture-format.md b/domains/agentic/skills/recipe-wallet-control/references/fixture-format.md deleted file mode 100644 index 852b066..0000000 --- a/domains/agentic/skills/recipe-wallet-control/references/fixture-format.md +++ /dev/null @@ -1,62 +0,0 @@ -# Wallet Fixture Format - -Fixtures are for local MetaMask Mobile and Extension debug runs only. Keep them in a gitignored path such as `.agent/wallet-fixture.json` or `temp/runtime/wallet-fixture.json` and use throwaway test wallets. - -## Schema - -```json -{ - "password": "throwaway-password", - "accounts": [ - { - "type": "mnemonic", - "value": "test test test test test test test test test test test junk", - "name": "Primary" - }, - { - "type": "privateKey", - "value": "0xabc123...", - "name": "Trading" - }, - { - "type": "privateKey", - "value": "0xdef456...", - "name": "MYXTrading" - } - ], - "settings": { - "metametrics": true, - "skipGtmModals": true, - "skipPerpsTutorial": true, - "autoLockNever": true, - "deviceAuthEnabled": true - } -} -``` - -## Fields - -| Field | Required | Meaning | -|---|---:|---| -| `password` | yes | Debug wallet password used for setup/unlock. | -| `accounts[]` | yes | Accounts to seed. Include at least one throwaway mnemonic for first vault setup. | -| `accounts[].type` | yes | `mnemonic` or `privateKey`. | -| `accounts[].value` | yes | SRP words or `0x` private key. Use throwaway values only. | -| `accounts[].name` | no | Human-readable label for imported accounts when supported. | -| `settings.metametrics` | no | Disable/enable metrics opt-in for debug setup; shared-fixture-compatible fixtures opt in to match prepared slot state. | -| `settings.skipGtmModals` | no | Skip growth/marketing modals where supported. | -| `settings.skipPerpsTutorial` | no | Skip Perps tutorial where supported. | -| `settings.autoLockNever` | no | Keep debug wallet unlocked where supported. | -| `settings.deviceAuthEnabled` | no | Enable device-auth-backed auto-unlock where supported; the Mobile harness only applies this on Android. | - -## Security Rules - -- Never use production SRPs, private keys, accounts, or funds. -- Never commit fixture files or raw secret material. -- Redact raw `password`, `mnemonic`, and `privateKey` values from command transcripts and PR bodies. -- Fixture seeding is setup/reset only. It is not a valid way to fabricate a mid-test state or bypass the user flow under validation. -- Existing mobile template: `scripts/perps/agentic/wallet-fixture.example.json` in MetaMask Mobile. - -## Extension Parity - -Mobile fixture shape is canonical for both platforms; Extension derives address/vault/persisted state from it. diff --git a/domains/agentic/skills/recipe-wallet-control/repos/metamask-extension.md b/domains/agentic/skills/recipe-wallet-control/repos/metamask-extension.md deleted file mode 100644 index c17b2b7..0000000 --- a/domains/agentic/skills/recipe-wallet-control/repos/metamask-extension.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -repo: metamask-extension -parent: recipe-wallet-control ---- - -# Recipe Wallet Control - MetaMask Extension - -Use the Extension recipe runtime injected by `/recipe-harness` to drive wallet-semantic flows through browser/CDP contexts. Install/launch via /recipe-harness; this overlay names the wallet primitives. - -## Prerequisites - -Before any primitive: - -1. Confirm you are in a `metamask-extension` checkout. -2. Run `/recipe-harness extension install --target .`. -3. Confirm the intended browser is reachable over CDP. -4. Run `/recipe-harness extension verify --target . --cdp-port `. -5. Use only local debug profiles and throwaway fixture wallets. - -If harness verify fails, report wallet-control proof as blocked by runtime readiness, not as product failure. - -**Dist-freshness gate.** Verify's `dist-freshness` check compares the git id in `dist/chrome/manifest.json` to HEAD: - -- `stale` (verify fails) — dist built from another commit, or source edited since build. Stop; ask: reuse / `yarn start` (watch) / rebuild. (`build:test` = e2e baseline only.) -- `no-build` / `unknown` — can't prove parity; confirm before relying on it. -- `fresh` — proceed. - -## Core Wallet Primitives - -### `metamask.wallet.ensure_unlocked` - -Use when a vault/profile already exists and may be locked: - -```json -{ - "action": "metamask.wallet.ensure_unlocked", - "intent": "Ensure the wallet is unlocked before proof" -} -``` - -Expected proof: the unlock form is absent after the action and wallet state can be read. - -### `metamask.wallet.select_account` - -Use with a deterministic fixture address: - -```json -{ - "action": "metamask.wallet.select_account", - "address": "0x...", - "intent": "Select the wallet account needed for proof" -} -``` - -Expected proof: `metamask.wallet.read_state` reports the selected account/address expected by the recipe. - -### `ui.navigate` - -```json -{ - "action": "ui.navigate", - "hash": "#/?tab=perps", - "intent": "Open the target screen through UI navigation" -} -``` - -### `metamask.wallet.read_state` - -Read wallet state without mutating UI: - -```json -{ - "action": "metamask.wallet.read_state", - "intent": "Read wallet state for recipe evidence" -} -``` - -Use this as internal-state proof alongside visible UI proof. Do not use raw page/service-worker evaluation to fabricate a visible result. - -### `ui.screenshot` - -Capture visual proof after a route, selector, or state settle condition: - -```json -{ - "action": "ui.screenshot", - "path": "screenshots/wallet-state.png", - "intent": "Capture reviewer-visible proof of the current screen" -} -``` - -Do not screenshot a loading or transitional page as proof. - -## Interaction Helpers - -Use namespaced Recipe v1 UI actions for real UI paths: `ui.press`, `ui.wait_for`, `ui.scroll`, and `ui.screenshot`. No text-entry ui.* yet; use a manifest domain action. - -## Current Boundary - -```bash -/mms-recipe-harness live --cdp-port --launch-existing-dist # fixture at temp/runtime/wallet-fixture.json or .agent/wallet-fixture.json -``` - -Harness injects fixture state and unlocks before proof. diff --git a/domains/agentic/skills/recipe-wallet-control/repos/metamask-mobile.md b/domains/agentic/skills/recipe-wallet-control/repos/metamask-mobile.md deleted file mode 100644 index 8a05b22..0000000 --- a/domains/agentic/skills/recipe-wallet-control/repos/metamask-mobile.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -repo: metamask-mobile -parent: recipe-wallet-control ---- - -# Recipe Wallet Control — MetaMask Mobile - -Drive a debug MetaMask Mobile app through actions declared by the installed runner manifest (`metamask.wallet.*`, `metamask.perps.*`, `ui.*`, `app.*` when present). The mobile bridge under `scripts/perps/agentic/` (e.g. `cdp-bridge.js`) is the runtime those actions call for Hermes/CDP evaluation, route changes, presses, inputs, scrolling, unlock, and eval refs — it is a runtime implementation detail, not the authoring surface. Discover the active manifest/schema before authoring; reach for raw bridge shell commands only for interactive debugging/inspection. Reuse `simulator-control` or `agent-device` for generic device inspection when useful. - -## Harness Launch Requirement - -Launch via harness only (`recipe-harness launch` / `preflight.sh --mode fast`). Non-harness launch lacks Metro/CDP wiring and fixtures. Never use `yarn start:ios`, `xcrun simctl launch`, or manual taps. Prefer `--mode fast`; if it reports a cache miss, stop and ask for explicit approval before escalating to `auto`, `rebuild-native`, or `clean`. - -## Prerequisites - -1. `metamask-mobile` checkout with `scripts/perps/agentic/` present. -2. Simulator/emulator booted, matching `.js.env` (`IOS_SIMULATOR`, `WATCHER_PORT`). -3. Fixture files contain only throwaway test wallets. - -If not met, interrupt and ask the user to fix via the recovery table below. - -## Status and Recovery - -```bash -bash scripts/perps/agentic/app-state.sh status -``` - -**Status succeeds** → proceed. **Status fails** → diagnose and recover: - -| State | Detection | Recovery | -|---|---|---| -| Not installed | `xcrun simctl listapps \| grep io.metamask` empty | Ask user to approve: `preflight.sh --platform --mode fast`. | -| Installed, not launched | Home screen visible, "0 targets" | Ask user to approve: `preflight.sh --platform --mode fast` or `start-metro.sh --platform --launch`. | -| Running, wrong port/no CDP | App visible but status fails ("0 targets" / "Cannot reach Metro") | Ask user before killing/relaunching: kill app + kill stale Metro (`lsof -i :`) + `preflight.sh --platform --mode fast`. | - -### Preflight modes - -| Mode | Behavior | -|---|---| -| `--mode fast` | No build — reuses an installed matching app or shared cache, and fails loudly on cache/fingerprint miss. Default for agent/human validation lanes. | -| `--mode auto` | Fingerprint-gated reuse; builds on cache miss. Use only after explicit runtime/rebuild approval or in a dedicated cache-warming lane. | -| `--mode clean` | Full: `yarn setup` → `pod install --repo-update` → build → Metro → CDP. Use only after explicit clean-rebuild approval for corrupted state. | - -Fresh wallet validation (bypasses existing vault): - -```bash -bash scripts/perps/agentic/preflight.sh \ - --platform ios --mode fast \ - --wallet-setup --wallet-fixture .agent/wallet-fixture.json -# If fast reports a missing/stale cache, stop and ask before rerunning with auto/clean. -``` - -## Core Wallet Primitives - -### `metamask.wallet.ensure_unlocked` - -Unlock an existing vault with the seeded fixture password. The action is idempotent — it inspects lock state and only unlocks if needed: - -```json -{ - "action": "metamask.wallet.ensure_unlocked", - "timeout_ms": 45000, - "intent": "Ensure the wallet is unlocked before proof" -} -``` - -The password comes from the wallet fixture supplied to the run, not a node field. Failure usually means the app is not on the login screen, the fixture password is wrong, or CDP is disconnected. - -### `metamask.wallet.setup` - -Seed a debug wallet from the run's JSON fixture: - -```json -{ - "action": "metamask.wallet.setup", - "timeout_ms": 45000, - "intent": "Prepare the wallet fixture for recipe proof" -} -``` - -The fixture (accounts, password, settings) is provided to the run by the harness, not a node field. Setup validates the fixture, creates or unlocks the vault, and yields an account summary. For validation evidence, start from clean state or capture a before/after account assertion, because setup intentionally skips creation when a vault already exists. - -### `ui.navigate` - -Use the official `ui.navigate` action with a raw app `route` (and optional `params`) for any app, wallet, or Perps destination. There is no wallet- or perps-specific navigate action: - -```json -[ - { - "action": "ui.navigate", - "route": "WalletTabHome", - "timeout_ms": 30000, - "intent": "Open the target screen through UI navigation" - }, - { - "action": "ui.navigate", - "route": "PerpsMarketDetails", - "params": { - "market": { - "symbol": "BTC", - "name": "BTC", - "price": "0", - "change24h": "0", - "change24hPercent": "0", - "volume": "0", - "maxLeverage": "100" - } - }, - "timeout_ms": 30000, - "intent": "Open the target screen through UI navigation" - } -] -``` - -`ui.navigate` reports the previous and current routes; pair it with a `ui.wait_for` on a screen `test_id` to prove the destination settled. Some routes are idempotent when the app is already on the target tab/screen — treat "previous route equals current route" as success only when a following `ui.wait_for`/screenshot confirms the intended destination. If a route name is wrong the action fails with the attempted route; confirm route names against the app's navigation config. - -### `ui.screenshot` - -Capture the current simulator/emulator screen through the official screenshot action: - -```json -{ - "action": "ui.screenshot", - "path": "screenshots/recipe-wallet-control-home.png", - "intent": "Capture reviewer-visible proof of the current screen" -} -``` - -The runner writes the PNG under the run's artifacts dir. Failure usually means no matching booted simulator or connected Android device was found. - -### `metamask.wallet.read_state` - -Read wallet/controller state through manifest-backed state actions where available; use raw CDP inspection only for debugging/setup evidence: - -```json -[ - { - "action": "metamask.wallet.read_state", - "intent": "Read wallet state for recipe evidence" - }, - { - "action": "metamask.perps.read_positions", - "market": "ETH", - "intent": "Read Perps positions for recipe evidence" - }, - { - "action": "metamask.perps.read_orders", - "market": "ETH", - "intent": "Read Perps open orders for recipe evidence" - } -] -``` - -## Interaction Helpers - -Use these only to complete real UI flows around the wallet primitives. Do not inject final validation state directly; drive the same UI code path a user would hit. - -### `ui.press` - -```json -{ - "action": "ui.press", - "target": "", - "intent": "Press the target control through the UI" -} -``` - -### `ui.set_input` - -```json -{ - "action": "ui.set_input", - "test_id": "", - "value": "text value", - "intent": "Enter text through the real UI input path" -} -``` - -### `ui.scroll` - -```json -[ - { - "action": "ui.scroll", - "test_id": "", - "scroll_into_view": true, - "intent": "Scroll until the target UI area is visible" - }, - { - "action": "ui.scroll", - "delta_y": 600, - "intent": "Scroll until the target UI area is visible" - } -] -``` - -### `ui.wait_for` - -```json -[ - { - "action": "ui.wait_for", - "test_id": "", - "expected": "present", - "timeout_ms": 30000, - "intent": "Wait until the target UI state is present" - }, - { - "action": "ui.wait_for", - "text": "Perps", - "timeout_ms": 30000, - "intent": "Wait until the target UI state is present" - } -] -``` - -Prefer `ui.wait_for` over fixed sleeps for any settle/poll condition; fail loudly on timeout. - -### go back (bridge debug) - -There is no v1 "go back" action. In recipes, drive back-navigation through the real UI (`ui.press` a back control). For interactive debugging only, the installed bridge exposes: - -```bash -bash scripts/perps/agentic/app-state.sh can-go-back -bash scripts/perps/agentic/app-state.sh go-back -``` - -### guarded raw CDP inspection (bridge debug) - -There is no v1 eval action. For inspection or debug-only setup only, the installed bridge exposes raw eval: - -```bash -bash scripts/perps/agentic/app-state.sh eval 'JSON.stringify({route: globalThis.__AGENTIC__.getRoute().name})' -bash scripts/perps/agentic/app-state.sh eval-async '(async function(){ return JSON.stringify(await someDebugCall()); })()' -``` - -Use raw eval for inspection or debug-only setup, not to fabricate a passing assertion. Recipes must prove state through manifest actions (`metamask.wallet.read_state`, `metamask.perps.read_*`, `assert_json`), never raw eval. diff --git a/domains/agentic/skills/recipe-wallet-control/skill.md b/domains/agentic/skills/recipe-wallet-control/skill.md deleted file mode 100644 index cf16c6c..0000000 --- a/domains/agentic/skills/recipe-wallet-control/skill.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: recipe-wallet-control -description: Control MetaMask debug wallets through harness-backed wallet-aware setup/unlock, account selection, route navigation, screenshots, UI interaction, CDP/state introspection, fixture handling, recovery, and recipe handoff. Use when an agent needs to validate Mobile or Extension wallet behavior end-to-end or collect PR evidence on a live debug runtime. -maturity: experimental ---- - -# Recipe Wallet Control - -**DEBUG BUILDS ONLY.** Use only local debug builds and throwaway test wallets. Never use production seed phrases, private keys, accounts, or balances. - -`recipe-wallet-control` is a harness-backed MetaMask wallet-control layer. It covers manifest-backed Recipe v1 wallet semantics (`metamask.wallet.setup`, `metamask.wallet.ensure_unlocked`, `metamask.wallet.select_account`, `metamask.wallet.read_state`) plus practical UI controls (`ui.navigate`, `ui.press`, `ui.scroll`, `ui.wait_for`, `ui.screenshot`, guarded CDP inspection, recovery, and recipe handoff). - -- Evidence hygiene: save logs/screenshots under `/tmp` or repo-local ignored folders; never commit artifacts. Redact fixture secrets; prefer counts and shape-only output over raw account arrays. - -Stack: device tools → recipe-wallet-control → /recipe-cook recipes. - -Load the repo overlay for the checkout you are controlling: - -- MetaMask Mobile: `repos/metamask-mobile.md` -- MetaMask Extension: `repos/metamask-extension.md` diff --git a/tools/install b/tools/install index 3e4f6f0..22bb83b 100755 --- a/tools/install +++ b/tools/install @@ -209,6 +209,24 @@ is_truthy() { esac } +yaml_field() { + local key="$1" value="$2" indent="" + if [[ "$key" =~ ^([[:space:]]*) ]]; then + indent="${BASH_REMATCH[1]}" + fi + printf '%s: >-\n' "$key" + while IFS= read -r line || [[ -n "$line" ]]; do + printf '%s %s\n' "$indent" "$line" + done <<< "$value" +} + +yaml_quoted() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//"/\\"}" + printf '"%s"' "$value" +} + maturity_allowed() { local skill_maturity="${1:-stable}" case "$MATURITY_FILTER" in @@ -229,14 +247,14 @@ write_claude() { action "claude: .claude/skills/$out_name/SKILL.md" $DRY_RUN && return mkdir -p "$dir" - cat > "$file" < "$file" } write_cursor() { @@ -246,14 +264,14 @@ write_cursor() { action "cursor: .cursor/rules/$out_name/RULE.md" $DRY_RUN && return mkdir -p "$dir" - cat > "$file" < "$file" } write_agents() { @@ -266,20 +284,20 @@ write_agents() { action "agents: .agents/skills/$out_name/agents/openai.yaml" $DRY_RUN && return mkdir -p "$yaml_dir" - cat > "$file" < "$yaml" < "$file" + { + echo 'interface:' + printf ' display_name: '; yaml_quoted "$name"; echo + yaml_field ' short_description' "$description" + printf ' default_prompt: '; yaml_quoted "Use \$${out_name} for this task."; echo + } > "$yaml" } # User-scope writers — write to the engineer's home dirs instead of the @@ -293,14 +311,14 @@ write_user_claude() { action "user-claude: ~/.claude/skills/$out_name/SKILL.md" $DRY_RUN && return mkdir -p "$dir" - cat > "$file" < "$file" } write_user_codex() { @@ -311,14 +329,14 @@ write_user_codex() { action "user-codex: ~/.codex/skills/$out_name/SKILL.md" $DRY_RUN && return mkdir -p "$dir" - cat > "$file" < "$file" } copy_bundle_dirs() {