Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/perps/agentic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ Executable gate checks that must pass before a flow runs. Defined in `teams/<tea
| `select_account` | `address` | Switch Ethereum account |
| `toggle_testnet` | | Enable/disable testnet mode |
| `switch_provider` | `provider` | Switch perps provider |
| `app_background` | `duration_ms` | Send app to home screen (iOS: Cmd+Shift+H). Waits `duration_ms` (default 5000) |
| `app_foreground` | | Bring app back to foreground via `xcrun simctl launch` |
| `app_restart` | `boot_wait_ms` | Terminate + relaunch app. Waits `boot_wait_ms` (default 15000) for Metro reconnect |
| `switch` | `cases` | Branch based on assertions (workflow only) |
| `end` | | Terminal node with pass/fail (workflow only) |

Expand Down
22 changes: 22 additions & 0 deletions scripts/perps/agentic/cdp-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,28 @@ Environment:
const port = loadPort();
const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);

// `status` probes ALL connected targets so both platforms are visible.
if (command === 'status') {
const { discoverAllTargets } = require('./lib/target-discovery');
const allTargets = await discoverAllTargets(port);
const results = [];
for (const target of allTargets) {
let client;
try {
client = await createWSClient(target.wsUrl, timeout);
const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
results.push(result);
} catch {
// Target not responsive — skip
} finally {
if (client) client.close();
}
}
console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
return;
}

const { wsUrl, deviceName } = await discoverTarget(port);
const client = await createWSClient(wsUrl, timeout);

Expand Down
186 changes: 186 additions & 0 deletions scripts/perps/agentic/lib/app-lifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
'use strict';

const { spawnSync } = require('node:child_process');

// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------

function detectPlatform() {
if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL) {
return 'android';
}
if (process.platform === 'darwin') {
return 'ios';
}
throw new Error(
'Cannot detect simulator platform. Set ADB_SERIAL for Android or run on macOS for iOS.',
);
}

// ---------------------------------------------------------------------------
// iOS helpers
// ---------------------------------------------------------------------------

function resolveBootedSimulatorUdid() {
const result = spawnSync(
'xcrun',
['simctl', 'list', 'devices', 'booted', '-j'],
{ encoding: 'utf8' },
);
const parsed = JSON.parse(result.stdout || '{}');
for (const devices of Object.values(parsed.devices || {})) {
for (const d of devices) {
if (d.state === 'Booted') {
return d.udid;
}
}
}
throw new Error('No booted iOS simulator found');
}

function resolveIosBundleId(udid) {
const result = spawnSync('xcrun', ['simctl', 'listapps', udid], {
encoding: 'utf8',
});
const match = (result.stdout || '').match(
/CFBundleIdentifier\s*=\s*"(io\.metamask\.[^"]+)"/,
);
return match ? match[1] : 'io.metamask.MetaMask';
}

function iosBackground() {
spawnSync(
'osascript',
[
'-e', 'tell application "Simulator" to activate',
'-e', 'delay 0.3',
'-e', 'tell application "System Events" to keystroke "h" using {command down, shift down}',
],
{ encoding: 'utf8' },
);
}

function iosForeground(udid, bundleId) {
spawnSync('xcrun', ['simctl', 'launch', udid, bundleId], {
encoding: 'utf8',
});
}

function iosTerminate(udid, bundleId) {
spawnSync('xcrun', ['simctl', 'terminate', udid, bundleId], {
encoding: 'utf8',
});
}

// ---------------------------------------------------------------------------
// Android helpers
// ---------------------------------------------------------------------------

function resolveAdbSerial() {
return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
}

function resolveAndroidPackage() {
// Check for running MetaMask activity
const serial = resolveAdbSerial();
const args = serial ? ['-s', serial] : [];
const result = spawnSync(
'adb',
[...args, 'shell', 'dumpsys', 'activity', 'activities'],
{ encoding: 'utf8' },
);
const match = (result.stdout || '').match(
/(io\.metamask[.\w]*)\/.+Activity/,
);
return match ? match[1] : 'io.metamask';
}

function adbShell(...shellArgs) {
const serial = resolveAdbSerial();
const args = serial ? ['-s', serial] : [];
return spawnSync('adb', [...args, 'shell', ...shellArgs], {
encoding: 'utf8',
});
}

function androidBackground() {
adbShell('input', 'keyevent', 'KEYCODE_HOME');
}

function androidForeground(packageName) {
adbShell(
'monkey',
'-p', packageName,
'-c', 'android.intent.category.LAUNCHER',
'1',
);
}

function androidTerminate(packageName) {
adbShell('am', 'force-stop', packageName);
}

// ---------------------------------------------------------------------------
// Public API — platform-agnostic
// ---------------------------------------------------------------------------

/**
* Send the app to the OS home screen.
* @returns {{ bundleId: string }} Resolved app identifier
*/
function backgroundApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosBackground();
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidBackground();
return { bundleId: packageName };
}

/**
* Bring the app back to the foreground.
* @returns {{ bundleId: string }}
*/
function foregroundApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosForeground(udid, bundleId);
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidForeground(packageName);
return { bundleId: packageName };
}

/**
* Terminate and relaunch the app.
* @returns {{ bundleId: string }}
*/
function restartApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosTerminate(udid, bundleId);
iosForeground(udid, bundleId);
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidTerminate(packageName);
androidForeground(packageName);
return { bundleId: packageName };
}

module.exports = {
backgroundApp,
detectPlatform,
foregroundApp,
restartApp,
};
7 changes: 5 additions & 2 deletions scripts/perps/agentic/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ function loadPort() {

/** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */
function loadSimulatorName() {
return process.env.IOS_SIMULATOR || loadEnvValue('IOS_SIMULATOR') || '';
// Explicit empty string in env means "no simulator" — don't fall through to .js.env
if ('IOS_SIMULATOR' in process.env) return process.env.IOS_SIMULATOR;
return loadEnvValue('IOS_SIMULATOR') || '';
}

/** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */
function loadAndroidDevice() {
return process.env.ANDROID_DEVICE || loadEnvValue('ANDROID_DEVICE') || '';
if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
return loadEnvValue('ANDROID_DEVICE') || '';
}

module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice };
46 changes: 45 additions & 1 deletion scripts/perps/agentic/lib/target-discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,48 @@ async function discoverTarget(port) {
return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
}

module.exports = { discoverTarget };
/**
* Discover ALL Hermes targets with __AGENTIC__ installed (one per platform/device).
* Groups by deviceName so each device returns at most one target.
*/
async function discoverAllTargets(port) {
const listUrl = `http://localhost:${port}/json/list`;
let targets;
try {
targets = await fetchJSON(listUrl);
} catch (e) {
throw new Error(
`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
);
}

const candidates = (targets || []).filter(
(t) =>
t.webSocketDebuggerUrl &&
t.title &&
(/react/i.test(t.title) || /hermes/i.test(t.title)),
);

// Sort by page number descending (JS runtime has higher page number)
candidates.sort((a, b) => {
const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
return bPage - aPage;
});

// Group by deviceName, probe each to find the JS runtime target
const seen = new Set();
const results = [];
for (const candidate of candidates) {
const device = candidate.deviceName || candidate.id || candidate.webSocketDebuggerUrl;
if (seen.has(device)) continue;
const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl);
if (hasAgentic) {
seen.add(device);
results.push({ wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
}
}
return results;
}

module.exports = { discoverTarget, discoverAllTargets };
4 changes: 4 additions & 0 deletions scripts/perps/agentic/lib/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const EXECUTABLE_ACTIONS = new Set([
'select_account',
'toggle_testnet',
'switch_provider',
// App lifecycle actions
'app_background',
'app_foreground',
'app_restart',
]);

const CONTROL_ACTIONS = new Set(['switch', 'end']);
Expand Down
18 changes: 14 additions & 4 deletions scripts/perps/agentic/preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ sweep_port() {
holder_pid=$(lsof -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1 || true)
[ -z "$holder_pid" ] && return 0

# Probe /status first — if Metro responds, it's alive and reusable regardless of process name
if curl -sf "http://localhost:$port/status" >/dev/null 2>&1; then
ok "Port $port ($label) — Metro already running (PID $holder_pid), reusing"
# start-metro.sh will detect running Metro and only launch the app
return 0
fi

local holder_cmd
holder_cmd=$(ps -p "$holder_pid" -o command= 2>/dev/null || echo unknown)

Expand Down Expand Up @@ -531,6 +538,8 @@ fi

# ── Step: Metro ─────────────────────────────────────────────────────
step "Starting Metro" "Bundler on port $PORT → logs at $LOGFILE"
# start-metro.sh detects running Metro and skips start. With --launch it
# opens the app via expo deeplink for the target platform regardless.
bash "$SCRIPTS/start-metro.sh" --platform "$PLAT" $($DO_LAUNCH && echo "--launch" || echo "")
ok "Metro running on port $PORT"

Expand Down Expand Up @@ -589,10 +598,11 @@ for p in json.loads(sys.stdin.read() or "[]"):
fail "CDP did not become available after ${CDP_TIMEOUT}s"
fi

# Verify CDP is connected to the right platform
CDP_PLATFORM=$(node "$SCRIPTS/cdp-bridge.js" status 2>/dev/null | jq -r '.platform // empty' || true)
if [ -n "$CDP_PLATFORM" ] && [ "$CDP_PLATFORM" != "$PLAT" ]; then
fail "CDP connected to $CDP_PLATFORM app but expected $PLAT — launch the $PLAT app first"
# Verify CDP is connected to the right platform (status may return object or array)
CDP_STATUS=$(node "$SCRIPTS/cdp-bridge.js" status 2>/dev/null || true)
CDP_HAS_PLAT=$(echo "$CDP_STATUS" | jq -r 'if type == "array" then [.[].platform] else [.platform] end | map(select(. == "'"$PLAT"'")) | length' 2>/dev/null || echo 0)
if [ "$CDP_HAS_PLAT" = "0" ]; then
warn "CDP did not find $PLAT app — it may still be loading"
fi

# Brief stabilization
Expand Down
5 changes: 5 additions & 0 deletions scripts/perps/agentic/teams/perps/evals/core.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@
"description": "Watchlist markets from controller state",
"expression": "JSON.stringify(Engine.context.PerpsController.state.watchlistMarkets)",
"async": false
},
"available-dexs": {
"description": "HIP-3 DEX list (validates DEX discovery cache)",
"expression": "Engine.context.PerpsController.getAvailableDexs().then(function(dexs){return JSON.stringify({count:dexs.length,dexs:dexs,hasMainDex:dexs.indexOf('')>=0})})",
"async": true
}
}
Loading
Loading