@anuraghq/mobius 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +371 -2
- package/bin/mobiusagent-driver-darwin-arm64 +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +151 -0
- package/dist/core/protocol.d.ts +294 -0
- package/dist/core/protocol.js +82 -0
- package/dist/execution/connector.d.ts +82 -0
- package/dist/execution/connector.js +11 -0
- package/dist/execution/drivers/mobiusagent.d.ts +57 -0
- package/dist/execution/drivers/mobiusagent.js +165 -0
- package/dist/execution/drivers/nodes.d.ts +47 -0
- package/dist/execution/drivers/nodes.js +104 -0
- package/dist/execution/observe.d.ts +48 -0
- package/dist/execution/observe.js +244 -0
- package/dist/execution/session.d.ts +101 -0
- package/dist/execution/session.js +304 -0
- package/dist/infrastructure/plane.d.ts +25 -0
- package/dist/infrastructure/plane.js +16 -0
- package/dist/infrastructure/registry.d.ts +19 -0
- package/dist/infrastructure/registry.js +49 -0
- package/dist/interface/engine.d.ts +24 -0
- package/dist/interface/engine.js +38 -0
- package/dist/interface/http.d.ts +4 -0
- package/dist/interface/http.js +85 -0
- package/dist/interface/mcp.d.ts +21 -0
- package/dist/interface/mcp.js +377 -0
- package/dist/interface/mobius-tools.d.ts +10 -0
- package/dist/interface/mobius-tools.js +288 -0
- package/dist/observability/audit.d.ts +52 -0
- package/dist/observability/audit.js +98 -0
- package/dist/observability/signing.d.ts +37 -0
- package/dist/observability/signing.js +87 -0
- package/dist/policy/policy.d.ts +147 -0
- package/dist/policy/policy.js +347 -0
- package/ios-agent/brand.py +62 -0
- package/ios-agent/brand.sh +19 -0
- package/ios-agent/setup.sh +26 -0
- package/ios-agent/wda-mobius-fast-touch.patch +73 -0
- package/package.json +20 -4
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Element } from '../core/protocol.ts';
|
|
2
|
+
export type Decision = 'allow' | 'approval_required' | 'blocked';
|
|
3
|
+
export interface PolicyConfig {
|
|
4
|
+
/** Apps where agents may not act or read (regex on package / bundle id). */
|
|
5
|
+
blocked_apps: string[];
|
|
6
|
+
/** Apps where every state-changing action needs approval. */
|
|
7
|
+
approval_apps: string[];
|
|
8
|
+
/** Target text/label/id words that make a tap need approval (money, sending, deleting, committing). */
|
|
9
|
+
risky_words: string[];
|
|
10
|
+
/** Typing into password/PIN/OTP fields. */
|
|
11
|
+
secret_entry: Decision;
|
|
12
|
+
/** Typing with submit:true (sends messages, submits forms). */
|
|
13
|
+
submit: Decision;
|
|
14
|
+
approval_timeout_s: number;
|
|
15
|
+
/** After a human clicks Approve: also require the device owner (Touch ID / login password), which a script cannot fake. */
|
|
16
|
+
approval_auth: 'none' | 'device_owner';
|
|
17
|
+
/** Policy as code: versioned rules, first match wins. They can tighten anything; they can never loosen a hard block. */
|
|
18
|
+
version: string;
|
|
19
|
+
rules: PolicyRule[];
|
|
20
|
+
/** Compute and audit decisions but don't enforce approvals (blocks still enforced). For tuning rules on emulators. */
|
|
21
|
+
dry_run: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface PolicyRule {
|
|
24
|
+
/** Stable id, recorded in every audit entry it decides. */
|
|
25
|
+
id: string;
|
|
26
|
+
effect: Decision;
|
|
27
|
+
description?: string;
|
|
28
|
+
/** Regex on the app id (anchored to a segment start, like the app lists). */
|
|
29
|
+
app?: string;
|
|
30
|
+
tool?: string[];
|
|
31
|
+
action_class?: ActionClass[];
|
|
32
|
+
/** Regex on the target's text/label/id. */
|
|
33
|
+
target?: string;
|
|
34
|
+
/** Matches only when the amount involved is known and at most this (same currency units as shown). */
|
|
35
|
+
max_amount?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare const DEFAULT_POLICY: PolicyConfig;
|
|
38
|
+
export declare function loadPolicy(): PolicyConfig;
|
|
39
|
+
export interface PolicyContext {
|
|
40
|
+
tool: string;
|
|
41
|
+
app: string | null;
|
|
42
|
+
target: Element | null;
|
|
43
|
+
/** True when the action changes state (tap/type/press...); observe/wait are read-only. */
|
|
44
|
+
mutating: boolean;
|
|
45
|
+
secretField?: boolean;
|
|
46
|
+
submit?: boolean;
|
|
47
|
+
url?: string;
|
|
48
|
+
/** App being opened (open_app). */
|
|
49
|
+
targetApp?: string;
|
|
50
|
+
/** Tap given as x,y rather than an element id. */
|
|
51
|
+
coordinate?: boolean;
|
|
52
|
+
/** Everything on the current screen, for context rules (checkout screens raise risk). */
|
|
53
|
+
screen?: Element[];
|
|
54
|
+
/** Every element under the tap point (an overlay may sit on top of the real button). */
|
|
55
|
+
under?: Element[];
|
|
56
|
+
}
|
|
57
|
+
/** What a person would read: NFKC, invisible characters dropped, look-alikes mapped to Latin. */
|
|
58
|
+
export declare const readable: (s: string) => string;
|
|
59
|
+
export interface Declaration {
|
|
60
|
+
action_class: ActionClass;
|
|
61
|
+
amount: number | null;
|
|
62
|
+
currency: string | null;
|
|
63
|
+
}
|
|
64
|
+
export declare function declaredAction(e: Element | null | undefined): Declaration | null;
|
|
65
|
+
/** What kind of consequence an action has; shown in approvals and audit. */
|
|
66
|
+
export type ActionClass = 'payment' | 'booking' | 'send' | 'delete' | 'account' | 'install' | 'permission' | 'secret' | 'unknown';
|
|
67
|
+
export declare const classify: (words: string) => ActionClass;
|
|
68
|
+
/** The amount a commit involves: from the target's own text ("Pay ₹438"), else the screen's Total/To-pay line. */
|
|
69
|
+
export declare function amountInvolved(target: Element | null, screen: Element[] | undefined): number | null;
|
|
70
|
+
export declare function moneyOnScreen(screen: Element[] | undefined): boolean;
|
|
71
|
+
export interface Verdict {
|
|
72
|
+
decision: Decision;
|
|
73
|
+
reason: string;
|
|
74
|
+
action_class: ActionClass;
|
|
75
|
+
/** Which rule decided: "builtin.*" or a policy-as-code rule id. */
|
|
76
|
+
rule_id: string;
|
|
77
|
+
amount?: number | null;
|
|
78
|
+
dry_run?: boolean;
|
|
79
|
+
}
|
|
80
|
+
export declare class Policy {
|
|
81
|
+
readonly config: PolicyConfig;
|
|
82
|
+
private blocked;
|
|
83
|
+
private approvalApps;
|
|
84
|
+
private risky;
|
|
85
|
+
constructor(config?: PolicyConfig);
|
|
86
|
+
appBlocked(app: string | null): boolean;
|
|
87
|
+
/** Built-in layers, then the user's rules (policy as code), then dry-run. */
|
|
88
|
+
evaluate(ctx: PolicyContext): Verdict;
|
|
89
|
+
private builtin;
|
|
90
|
+
}
|
|
91
|
+
export interface ApprovalRequest {
|
|
92
|
+
id: string;
|
|
93
|
+
device_id: string;
|
|
94
|
+
app: string | null;
|
|
95
|
+
tool: string;
|
|
96
|
+
/** Composed from device state, never from agent-supplied text. */
|
|
97
|
+
summary: string;
|
|
98
|
+
reason: string;
|
|
99
|
+
action_class?: ActionClass;
|
|
100
|
+
/** From the device connector: an emulator/simulator. */
|
|
101
|
+
virtual_device?: boolean;
|
|
102
|
+
}
|
|
103
|
+
export type ApprovalOutcome = 'approved' | 'rejected' | 'expired';
|
|
104
|
+
/** Asks a human. Implementations must not be satisfiable by the agent itself. */
|
|
105
|
+
export interface Approver {
|
|
106
|
+
request(req: ApprovalRequest, timeoutS: number): Promise<ApprovalOutcome>;
|
|
107
|
+
}
|
|
108
|
+
export declare function confirmDeviceOwner(reason: string): Promise<boolean>;
|
|
109
|
+
/** Native macOS dialog on the user's screen, then (by default) Touch ID / password, so a scripted click can't approve. */
|
|
110
|
+
export declare class MacDialogApprover implements Approver {
|
|
111
|
+
private readonly auth;
|
|
112
|
+
constructor(auth?: PolicyConfig['approval_auth']);
|
|
113
|
+
request(req: ApprovalRequest, timeoutS: number): Promise<ApprovalOutcome>;
|
|
114
|
+
private dialog;
|
|
115
|
+
}
|
|
116
|
+
/** Emulators and simulators only: physical phones hold real accounts and money. */
|
|
117
|
+
export declare const isVirtualDevice: (id: string) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Tests only: fixed answer from MOBIUS_TEST_APPROVAL. Never enabled implicitly.
|
|
120
|
+
* An auto-"approved" never reaches a physical device: an agent that edits its own MCP config can't turn it into a bypass.
|
|
121
|
+
*/
|
|
122
|
+
export declare class FixedApprover implements Approver {
|
|
123
|
+
private readonly answer;
|
|
124
|
+
private readonly anyDevice;
|
|
125
|
+
readonly requests: ApprovalRequest[];
|
|
126
|
+
constructor(answer: ApprovalOutcome, opts?: {
|
|
127
|
+
anyDevice?: boolean;
|
|
128
|
+
});
|
|
129
|
+
request(req: ApprovalRequest): Promise<ApprovalOutcome>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Remote approver: POSTs the request to a webhook (your Slack bot, push service or backend) and waits for its answer.
|
|
133
|
+
* The decision lives off this machine, so an agent with a local shell can't click it. Fail closed on any error.
|
|
134
|
+
* Request body: ApprovalRequest + timeout_s. Response: {"decision":"approved"|"rejected"} (anything else = rejected).
|
|
135
|
+
*/
|
|
136
|
+
export declare class WebhookApprover implements Approver {
|
|
137
|
+
private readonly url;
|
|
138
|
+
private readonly token;
|
|
139
|
+
constructor(url: string, token?: string);
|
|
140
|
+
request(req: ApprovalRequest, timeoutS: number): Promise<ApprovalOutcome>;
|
|
141
|
+
}
|
|
142
|
+
/** No human channel available: fail closed. */
|
|
143
|
+
export declare class DenyApprover implements Approver {
|
|
144
|
+
request(): Promise<"rejected">;
|
|
145
|
+
}
|
|
146
|
+
export declare function defaultApprover(): Approver;
|
|
147
|
+
export declare const newApprovalId: () => string;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
// ponytail: seed lists, user-editable via MOBIUS_POLICY (JSON file). Heuristic by nature; category blocks are the hard guarantee.
|
|
5
|
+
export const DEFAULT_POLICY = {
|
|
6
|
+
// Patterns match from the start of a package/bundle-id segment ("axis" hits com.axis.mobile, not com.taxis.app).
|
|
7
|
+
blocked_apps: [
|
|
8
|
+
'bank', 'netbanking', 'sbi', 'hdfc', 'icici', 'axis', 'kotak', 'paytm\\.money', 'zerodha', 'groww',
|
|
9
|
+
'phonepe', 'paisa', 'gpay', 'paypal', 'venmo', 'cash\\.app', 'coinbase', 'binance',
|
|
10
|
+
// credentials: password managers and authenticators by their real bundle/package ids
|
|
11
|
+
'authenticator', 'authy', '1password', 'onepassword', 'agilebits', 'bitwarden', 'x8bit', 'lastpass', 'dashlane', 'keepass', 'keeper', 'proton\\.pass',
|
|
12
|
+
],
|
|
13
|
+
approval_apps: [
|
|
14
|
+
// messaging / social: everything they do is "send"
|
|
15
|
+
'whatsapp', 'telegram', 'messag', 'mobilesms', 'mms', 'gmail', 'outlook', 'slack', 'instagram', 'twitter', 'x(\\.|$)', 'linkedin', 'facebook',
|
|
16
|
+
// card and fintech apps: usable (travel, rewards) but every state change asks
|
|
17
|
+
'scapia', 'dreamplug', 'cred', 'slice', 'onecard', 'jupiter', 'niyo', 'pnb', 'fi\\.money',
|
|
18
|
+
],
|
|
19
|
+
risky_words: [
|
|
20
|
+
// 'book'/'request'/'checkout': live run found "book Cab Non AC service" (Rapido), "Request Uber Go", "Slide to book" slipping through.
|
|
21
|
+
'pay', 'payment', 'place order', 'order now', 'buy', 'buy now', 'purchase', 'book', 'request', 'checkout', 'book now', 'confirm booking', 'book ride',
|
|
22
|
+
'book ticket', 'book seat', 'confirm ride', 'request ride', 'confirm', 'transfer', 'send', 'delete',
|
|
23
|
+
'remove account', 'unsubscribe', 'cancel order', 'cancel booking', 'submit', 'post', 'publish', 'share', 'donate', 'subscribe',
|
|
24
|
+
'withdraw', 'request money', 'install', 'uninstall', 'reset', 'erase', 'sign out', 'log out', 'accept terms', 'agree',
|
|
25
|
+
// Indian languages: Hindi, Tamil, Telugu, Kannada, Bengali, Marathi (pay / order / book / send / delete / buy / confirm)
|
|
26
|
+
'भुगतान', 'पेमेंट', 'ऑर्डर', 'बुक', 'भेजें', 'भेजो', 'हटाएं', 'हटाएँ', 'खरीदें', 'पुष्टि',
|
|
27
|
+
'செலுத்து', 'அனுப்பு', 'பதிவு செய்', 'చెల్లించు', 'పంపు', 'బుక్', 'ಪಾವತಿ', 'ಕಳುಹಿಸು', 'ಬುಕ್', 'পেমেন্ট', 'পাঠান', 'বুক', 'पाठवा', 'पैसे भरा',
|
|
28
|
+
// icon-only send / submit glyphs
|
|
29
|
+
'➤', '➢', '➣', '⮞',
|
|
30
|
+
],
|
|
31
|
+
secret_entry: 'approval_required',
|
|
32
|
+
submit: 'approval_required',
|
|
33
|
+
approval_timeout_s: 90,
|
|
34
|
+
approval_auth: process.platform === 'darwin' ? 'device_owner' : 'none',
|
|
35
|
+
version: 'default',
|
|
36
|
+
rules: [],
|
|
37
|
+
dry_run: false,
|
|
38
|
+
};
|
|
39
|
+
export function loadPolicy() {
|
|
40
|
+
const p = process.env.MOBIUS_POLICY;
|
|
41
|
+
if (!p)
|
|
42
|
+
return DEFAULT_POLICY;
|
|
43
|
+
return { ...DEFAULT_POLICY, ...JSON.parse(readFileSync(p, 'utf8')) };
|
|
44
|
+
}
|
|
45
|
+
// Look-alike letters (Greek / Cyrillic) that spoof Latin words, mapped back; invisible characters removed.
|
|
46
|
+
const CONFUSABLE = Object.fromEntries([
|
|
47
|
+
...'АаВЕеКкМНОоРрСсТуХхІіЈјЅѕԁ'.split('').map((c, i) => [c, 'AaBEeKkMHOoPpCcTyXxIiJjSsd'[i]]),
|
|
48
|
+
...'ΑΒΕΖΗΙΚΜΝΟΡΤΥΧοραν'.split('').map((c, i) => [c, 'ABEZHIKMNOPTYXopav'[i]]),
|
|
49
|
+
]);
|
|
50
|
+
const INVISIBLE = /[\u00AD\u200B-\u200F\u2060-\u2064\uFEFF]/g;
|
|
51
|
+
/** What a person would read: NFKC, invisible characters dropped, look-alikes mapped to Latin. */
|
|
52
|
+
export const readable = (s) => s.normalize('NFKC').replace(INVISIBLE, '').replace(/[\u0370-\u03FF\u0400-\u04FF\u0500-\u052F]/g, c => CONFUSABLE[c] ?? c);
|
|
53
|
+
/**
|
|
54
|
+
* App-declared sensitive actions (the Mobius declaration convention): the app tags a control's identifier
|
|
55
|
+
* mobius:<class>[:<amount>[:<CURRENCY>]] e.g. Flutter Semantics(identifier: 'mobius:payment:438:INR'), iOS accessibilityIdentifier
|
|
56
|
+
* mobius_<class>[_<amount>[_<CURRENCY>]] e.g. Android resource id com.shop:id/mobius_payment_438_INR
|
|
57
|
+
* A declaration is authoritative: it outranks the visible label (which an app or attacker may word any way).
|
|
58
|
+
*/
|
|
59
|
+
const DECLARED = /(?:^|[^a-z])mobius[:_](payment|booking|send|delete|account|install|secret)(?:[:_](\d+(?:\.\d+)?))?(?:[:_]([a-z]{3}))?(?![a-z])/i;
|
|
60
|
+
export function declaredAction(e) {
|
|
61
|
+
const m = e ? DECLARED.exec(`${e.resource_id ?? ''} ${e.label ?? ''}`) : null;
|
|
62
|
+
return m ? { action_class: m[1].toLowerCase(), amount: m[2] ? Number(m[2]) : null, currency: m[3]?.toUpperCase() ?? null } : null;
|
|
63
|
+
}
|
|
64
|
+
/** Generic "go ahead" buttons: harmless on most screens, a commit on a checkout screen. */
|
|
65
|
+
const PROCEED = /^\s*(continue|proceed|next|done|ok|okay|submit|confirm|complete|finish|go|place|जारी रखें|आगे बढ़ें)\b/i;
|
|
66
|
+
const AMOUNT = /(₹|rs\.?|inr|\$|€|£)\s?\d/i;
|
|
67
|
+
/** "Slide to <verb>" controls that only move around the app (tabs, toggles, galleries). Any other verb is a possible commit. */
|
|
68
|
+
const NAV_SLIDER = /^(switch|change|toggle|view|see|explore|browse|navigate|scroll|zoom|refresh|dismiss|close|cancel|next|previous|more)$/i;
|
|
69
|
+
const CLASSES = [
|
|
70
|
+
['payment', /pay|payment|buy|purchase|checkout|order|transfer|withdraw|donate|subscribe|भुगतान|पेमेंट|ऑर्डर|खरीद|செலுத்து|చెల్లించు|ಪಾವತಿ|পেমেন্ট|पैसे/i],
|
|
71
|
+
['booking', /book|request|ride|ticket|seat|बुक|బుక్|ಬುಕ್|বুক|பதிவு/i],
|
|
72
|
+
['send', /send|share|post|publish|submit|भेज|अनुप्|அனுப்பு|పంపు|ಕಳುಹಿಸು|পাঠান|पाठवा|➤|➢|➣|⮞/i],
|
|
73
|
+
['delete', /delete|remove|erase|reset|cancel|हटा/i],
|
|
74
|
+
['account', /sign out|log out|unsubscribe|agree|accept|confirm|पुष्टि/i],
|
|
75
|
+
['install', /install/i],
|
|
76
|
+
];
|
|
77
|
+
export const classify = (words) => CLASSES.find(([, re]) => re.test(words))?.[0] ?? 'unknown';
|
|
78
|
+
/** A screen that shows money due (₹349, Total, To pay...) makes unidentifiable targets risky. */
|
|
79
|
+
const MONEY = /(₹|rs\.?|inr|\$|€|£)\s?\d/i;
|
|
80
|
+
const DUE = /\b(total|to pay|amount|grand total|payable|fare|subtotal)\b|कुल|देय/i;
|
|
81
|
+
/** The amount a commit involves: from the target's own text ("Pay ₹438"), else the screen's Total/To-pay line. */
|
|
82
|
+
export function amountInvolved(target, screen) {
|
|
83
|
+
const num = (s) => { const m = s.match(/(?:₹|rs\.?|inr|\$|€|£)\s?([\d,]+(?:\.\d+)?)/i); return m ? Number(m[1].replace(/,/g, '')) : null; };
|
|
84
|
+
const own = target ? num(`${target.text ?? ''} ${target.label ?? ''}`) : null;
|
|
85
|
+
if (own !== null)
|
|
86
|
+
return own;
|
|
87
|
+
const lines = (screen ?? []).map(e => `${e.text ?? ''} ${e.label ?? ''}`);
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (!DUE.test(lines[i]))
|
|
90
|
+
continue;
|
|
91
|
+
const v = num(lines[i]) ?? num(lines[i + 1] ?? ''); // "Total ₹438" or "Total" then "₹438"
|
|
92
|
+
if (v !== null)
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
export function moneyOnScreen(screen) {
|
|
98
|
+
if (!screen)
|
|
99
|
+
return false;
|
|
100
|
+
const text = screen.map(e => `${e.text ?? ''} ${e.label ?? ''}`).join(' \n ');
|
|
101
|
+
return MONEY.test(text) && DUE.test(text);
|
|
102
|
+
}
|
|
103
|
+
// OS permission sheets (iOS springboard, Android permission controller): granting is a privacy decision.
|
|
104
|
+
const PERMISSION_HOST = /springboard|permissioncontroller|packageinstaller/i;
|
|
105
|
+
const PERMISSION_GRANT = /^\s*(allow|while using|only this time)\b/i;
|
|
106
|
+
const wordRe = (words) => new RegExp(`(^|[^a-z])(${words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})([^a-z]|$)`, 'i');
|
|
107
|
+
/** App patterns anchor to the start of a bundle/package segment. */
|
|
108
|
+
const appRe = (pats) => pats.length ? new RegExp(`(^|\\.)(${pats.join('|')})`, 'i') : null;
|
|
109
|
+
export class Policy {
|
|
110
|
+
config;
|
|
111
|
+
blocked;
|
|
112
|
+
approvalApps;
|
|
113
|
+
risky;
|
|
114
|
+
constructor(config = loadPolicy()) {
|
|
115
|
+
this.config = config;
|
|
116
|
+
this.blocked = appRe(config.blocked_apps);
|
|
117
|
+
this.approvalApps = appRe(config.approval_apps);
|
|
118
|
+
this.risky = wordRe(config.risky_words);
|
|
119
|
+
}
|
|
120
|
+
appBlocked(app) { return !!app && !!this.blocked?.test(app); }
|
|
121
|
+
/** Built-in layers, then the user's rules (policy as code), then dry-run. */
|
|
122
|
+
evaluate(ctx) {
|
|
123
|
+
const base = this.builtin(ctx);
|
|
124
|
+
const t = ctx.target;
|
|
125
|
+
const words = t ? `${t.text ?? ''} ${t.label ?? ''} ${t.resource_id ?? ''}` : '';
|
|
126
|
+
const cls = base.action_class !== 'unknown' ? base.action_class : classify(words);
|
|
127
|
+
const decl = declaredAction(t) ?? (ctx.under ?? []).map(declaredAction).find(Boolean) ?? null;
|
|
128
|
+
const amount = decl?.amount ?? amountInvolved(t, ctx.screen);
|
|
129
|
+
let v = { ...base, action_class: cls, rule_id: base.rule_id, amount };
|
|
130
|
+
if (base.decision !== 'blocked' && ctx.mutating) {
|
|
131
|
+
const r = this.config.rules.find(r => (!r.app || appRe([r.app]).test(ctx.app ?? '')) && (!r.tool || r.tool.includes(ctx.tool))
|
|
132
|
+
&& (!r.action_class || r.action_class.includes(cls)) && (!r.target || new RegExp(r.target, 'i').test(words))
|
|
133
|
+
&& (r.max_amount === undefined || (amount !== null && amount <= r.max_amount)));
|
|
134
|
+
if (r)
|
|
135
|
+
v = { ...v, decision: r.effect, reason: `rule ${r.id}${r.description ? `: ${r.description}` : ''} (was: ${base.reason})`, rule_id: r.id };
|
|
136
|
+
}
|
|
137
|
+
if (this.config.dry_run && v.decision === 'approval_required')
|
|
138
|
+
v = { ...v, decision: 'allow', reason: `dry run, would ask: ${v.reason}`, dry_run: true };
|
|
139
|
+
return v;
|
|
140
|
+
}
|
|
141
|
+
builtin(ctx) {
|
|
142
|
+
const out = (decision, reason, action_class = 'unknown', rule_id = `builtin.${decision}`) => ({ decision, reason, action_class, rule_id });
|
|
143
|
+
if (this.appBlocked(ctx.targetApp ?? null))
|
|
144
|
+
return out('blocked', `${ctx.targetApp} is in a blocked app category (finance/credentials)`);
|
|
145
|
+
if (this.appBlocked(ctx.app))
|
|
146
|
+
return out('blocked', `${ctx.app} is in a blocked app category (finance/credentials)`);
|
|
147
|
+
if (!ctx.mutating)
|
|
148
|
+
return out('allow', 'read-only');
|
|
149
|
+
if (ctx.url && /^(intent|file|content|javascript|data):/i.test(ctx.url))
|
|
150
|
+
return out('blocked', 'URL scheme not allowed');
|
|
151
|
+
// Anything but a web page jumps into another app (UPI, phone, SMS, store install, deep links): a human decides.
|
|
152
|
+
if (ctx.url && !/^https?:/i.test(ctx.url)) {
|
|
153
|
+
const scheme = ctx.url.split(':')[0].toLowerCase();
|
|
154
|
+
const cls = /upi|pay|tez|phonepe|paytm/.test(scheme) ? 'payment' : /tel|sms|mms|mailto|whatsapp/.test(scheme) ? 'send' : /market|itms/.test(scheme) ? 'install' : 'unknown';
|
|
155
|
+
return out('approval_required', `opens a ${scheme}: link outside the browser`, cls);
|
|
156
|
+
}
|
|
157
|
+
if (ctx.secretField)
|
|
158
|
+
return out(this.config.secret_entry, 'typing into a password/PIN/OTP field', 'secret');
|
|
159
|
+
const t = ctx.target;
|
|
160
|
+
const wordsOf = (e) => readable(`${e.text ?? ''} ${e.label ?? ''} ${e.resource_id ?? ''}`).replace(/[_.]/g, ' ');
|
|
161
|
+
const words = t ? wordsOf(t) : '';
|
|
162
|
+
const gesture = ctx.tool === 'tap' || ctx.tool === 'swipe' || ctx.tool === 'scroll';
|
|
163
|
+
// Layer 0: the app itself declared this control sensitive. Trust the app over any label.
|
|
164
|
+
if (gesture) {
|
|
165
|
+
for (const e of [...(t ? [t] : []), ...(ctx.under ?? [])]) {
|
|
166
|
+
const d = declaredAction(e);
|
|
167
|
+
if (d)
|
|
168
|
+
return out('approval_required', `the app declares this control a ${d.action_class} action${d.amount !== null ? ` of ${d.currency ?? ''} ${d.amount}`.replace(' ', ' ') : ''}`, d.action_class, 'app.declared');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Layer 1: the target, or anything under the tap point (overlays), says it commits: any language, icon glyphs,
|
|
172
|
+
// look-alike letters, or a word split across child views ("P · ay"). Swipes count: "Slide to pay".
|
|
173
|
+
if (gesture) {
|
|
174
|
+
for (const e of [...(t ? [t] : []), ...(ctx.under ?? [])]) {
|
|
175
|
+
const w = wordsOf(e);
|
|
176
|
+
const hit = this.risky.exec(w) ?? this.risky.exec(w.replace(/\s*·\s*/g, ''));
|
|
177
|
+
if (hit)
|
|
178
|
+
return out('approval_required', `${e === t ? 'target' : 'element under the tap'} "${(e.text ?? e.label ?? e.resource_id ?? '').slice(0, 60)}" looks consequential ("${hit[2]}")`, classify(hit[2]));
|
|
179
|
+
}
|
|
180
|
+
// Checkout screen: a generic "Continue" or a button that only shows an amount is the commit.
|
|
181
|
+
if (t && ctx.tool === 'tap' && moneyOnScreen(ctx.screen) && (PROCEED.test(words) || AMOUNT.test(words))) {
|
|
182
|
+
return out('approval_required', `"${words.trim().slice(0, 40)}" on a screen showing an amount due`, 'payment');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// App lifecycle: wiping or removing an app loses the user's data; installing adds code.
|
|
186
|
+
if (ctx.tool === 'uninstall_app' || ctx.tool === 'clear_app_data')
|
|
187
|
+
return out('approval_required', `${ctx.tool === 'uninstall_app' ? 'uninstalling' : 'clearing all data of'} ${ctx.targetApp ?? 'an app'}`, 'delete');
|
|
188
|
+
if (ctx.tool === 'install_app')
|
|
189
|
+
return out('approval_required', 'installing an app', 'install');
|
|
190
|
+
// Driver powers that act, authenticate or capture beyond what the screen shows: a human decides.
|
|
191
|
+
const POWER = {
|
|
192
|
+
webview_eval: ['running JavaScript inside a web page (it can submit forms or pay)', 'payment'],
|
|
193
|
+
simulate_biometric: ['simulating a fingerprint / Face ID match (authenticates as the user)', 'account'],
|
|
194
|
+
push_file: ['writing a file onto the device', 'install'],
|
|
195
|
+
device_setting_write: ['changing a system setting', 'account'],
|
|
196
|
+
set_permission: ['changing an app permission', 'permission'],
|
|
197
|
+
start_screen_recording: ['recording the screen (video cannot be masked)', 'secret'],
|
|
198
|
+
reboot_device: ['rebooting the device', 'unknown'],
|
|
199
|
+
};
|
|
200
|
+
const power = POWER[ctx.tool];
|
|
201
|
+
if (power)
|
|
202
|
+
return out('approval_required', power[0], power[1]);
|
|
203
|
+
// Layer 2: OS permission sheets.
|
|
204
|
+
if (ctx.tool === 'tap' && PERMISSION_HOST.test(ctx.app ?? '') && PERMISSION_GRANT.test(words)) {
|
|
205
|
+
return out('approval_required', `granting an OS permission ("${words.trim().slice(0, 40)}")`, 'permission');
|
|
206
|
+
}
|
|
207
|
+
// Layer 3: fail safe on targets Mobius can't read. A commit button drawn without accessibility is invisible to layer 1.
|
|
208
|
+
// ponytail: x,y anywhere and unlabeled elements on money screens ask; upgrade path is OCR/vision on the tapped region.
|
|
209
|
+
if (ctx.tool === 'tap' && !words.trim() && (ctx.coordinate || moneyOnScreen(ctx.screen))) {
|
|
210
|
+
return out('approval_required', ctx.coordinate
|
|
211
|
+
? 'tap at coordinates on something Mobius cannot identify (could be a hidden Pay/Book button)'
|
|
212
|
+
: 'unlabeled control on a screen showing an amount due (could be a hidden Pay button)', 'payment');
|
|
213
|
+
}
|
|
214
|
+
// Layer 4: a swipe on a screen that offers "slide to …" commits. Sliders that only navigate ("Slide to switch" gender
|
|
215
|
+
// toggle on Slikk, live run) don't count; any other verb, or none we can read, still asks (fail closed).
|
|
216
|
+
if (ctx.tool === 'swipe' && ctx.screen?.some(e => { const m = /\b(?:slide|swipe) to\b\s*(\p{L}*)/iu.exec(wordsOf(e)); return !!m && !NAV_SLIDER.test(m[1]); })) {
|
|
217
|
+
return out('approval_required', 'swipe on a screen with a "slide to …" control (swipe-to-pay/book)', 'payment');
|
|
218
|
+
}
|
|
219
|
+
if (ctx.submit)
|
|
220
|
+
return out(this.config.submit, 'submitting text (may send a message or form)', 'send');
|
|
221
|
+
if (ctx.app && this.approvalApps?.test(ctx.app) && (ctx.tool === 'type' || ctx.tool === 'tap' || ctx.tool === 'swipe')) {
|
|
222
|
+
return out('approval_required', `${ctx.app} is a sensitive app (messaging, social or finance)`, classify(words));
|
|
223
|
+
}
|
|
224
|
+
return out('allow', 'no rule matched');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Device-owner check (Touch ID or login password) via LocalAuthentication. An agent with a shell can script a click on
|
|
228
|
+
// "Approve" (System Events), but it cannot present the owner's fingerprint or password.
|
|
229
|
+
const AUTH_SWIFT = `import LocalAuthentication
|
|
230
|
+
import Foundation
|
|
231
|
+
let reason = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "approve an AI agent action"
|
|
232
|
+
let ctx = LAContext()
|
|
233
|
+
var err: NSError?
|
|
234
|
+
guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) else { exit(2) }
|
|
235
|
+
let sem = DispatchSemaphore(value: 0)
|
|
236
|
+
var ok = false
|
|
237
|
+
ctx.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in ok = success; sem.signal() }
|
|
238
|
+
sem.wait()
|
|
239
|
+
exit(ok ? 0 : 1)
|
|
240
|
+
`;
|
|
241
|
+
let authBin = null;
|
|
242
|
+
/** Compile the helper once into ~/.mobius/bin (needs Xcode command-line tools). null = unavailable. */
|
|
243
|
+
function deviceOwnerHelper() {
|
|
244
|
+
return authBin ??= (async () => {
|
|
245
|
+
const { mkdirSync, writeFileSync, existsSync } = await import('node:fs');
|
|
246
|
+
const { homedir } = await import('node:os');
|
|
247
|
+
const dir = `${homedir()}/.mobius/bin`, bin = `${dir}/mobius-owner-auth`;
|
|
248
|
+
if (existsSync(bin))
|
|
249
|
+
return bin;
|
|
250
|
+
mkdirSync(dir, { recursive: true });
|
|
251
|
+
writeFileSync(`${dir}/owner-auth.swift`, AUTH_SWIFT);
|
|
252
|
+
return new Promise(res => execFile('swiftc', ['-O', '-o', bin, `${dir}/owner-auth.swift`], { timeout: 120_000 }, e => res(e ? null : bin)));
|
|
253
|
+
})();
|
|
254
|
+
}
|
|
255
|
+
export async function confirmDeviceOwner(reason) {
|
|
256
|
+
const bin = await deviceOwnerHelper();
|
|
257
|
+
if (!bin) {
|
|
258
|
+
process.stderr.write(JSON.stringify({ level: 'error', msg: 'device-owner check unavailable (install Xcode command-line tools, or set approval_auth "none")' }) + '\n');
|
|
259
|
+
return false; // fail closed
|
|
260
|
+
}
|
|
261
|
+
return new Promise(res => execFile(bin, [reason], { timeout: 120_000 }, e => res(!e)));
|
|
262
|
+
}
|
|
263
|
+
/** Native macOS dialog on the user's screen, then (by default) Touch ID / password, so a scripted click can't approve. */
|
|
264
|
+
export class MacDialogApprover {
|
|
265
|
+
auth;
|
|
266
|
+
constructor(auth = DEFAULT_POLICY.approval_auth) { this.auth = auth; }
|
|
267
|
+
async request(req, timeoutS) {
|
|
268
|
+
const clicked = await this.dialog(req, timeoutS);
|
|
269
|
+
if (clicked !== 'approved' || this.auth === 'none')
|
|
270
|
+
return clicked;
|
|
271
|
+
return (await confirmDeviceOwner(`approve: ${req.summary.slice(0, 120)}`)) ? 'approved' : 'rejected';
|
|
272
|
+
}
|
|
273
|
+
dialog(req, timeoutS) {
|
|
274
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
275
|
+
const kind = req.action_class && req.action_class !== 'unknown' ? ` (${req.action_class})` : '';
|
|
276
|
+
const msg = `An AI agent wants to act on your phone${kind}.\n\nApp: ${req.app ?? 'unknown'}\nAction: ${req.summary}\nWhy approval: ${req.reason}\n\nDevice: ${req.device_id}`;
|
|
277
|
+
const script = `display dialog "${esc(msg)}" with title "Mobius approval" buttons {"Reject", "Approve"} default button "Reject" cancel button "Reject" with icon caution giving up after ${timeoutS}`;
|
|
278
|
+
return new Promise(resolve => {
|
|
279
|
+
execFile('osascript', ['-e', script], { timeout: (timeoutS + 5) * 1000 }, (err, stdout) => {
|
|
280
|
+
if (err)
|
|
281
|
+
return resolve('rejected'); // Reject button or failure: fail closed
|
|
282
|
+
if (/gave up:true/.test(stdout))
|
|
283
|
+
return resolve('expired');
|
|
284
|
+
resolve(/button returned:Approve/.test(stdout) ? 'approved' : 'rejected');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Emulators and simulators only: physical phones hold real accounts and money. */
|
|
290
|
+
export const isVirtualDevice = (id) => /^emulator-\d+$/.test(id) || /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(id);
|
|
291
|
+
/**
|
|
292
|
+
* Tests only: fixed answer from MOBIUS_TEST_APPROVAL. Never enabled implicitly.
|
|
293
|
+
* An auto-"approved" never reaches a physical device: an agent that edits its own MCP config can't turn it into a bypass.
|
|
294
|
+
*/
|
|
295
|
+
export class FixedApprover {
|
|
296
|
+
answer;
|
|
297
|
+
anyDevice;
|
|
298
|
+
requests = [];
|
|
299
|
+
constructor(answer, opts = {}) { this.answer = answer; this.anyDevice = !!opts.anyDevice; }
|
|
300
|
+
async request(req) {
|
|
301
|
+
this.requests.push(req);
|
|
302
|
+
const virtual = req.virtual_device ?? isVirtualDevice(req.device_id);
|
|
303
|
+
if (this.answer === 'approved' && !this.anyDevice && !virtual)
|
|
304
|
+
return 'rejected';
|
|
305
|
+
return this.answer;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Remote approver: POSTs the request to a webhook (your Slack bot, push service or backend) and waits for its answer.
|
|
310
|
+
* The decision lives off this machine, so an agent with a local shell can't click it. Fail closed on any error.
|
|
311
|
+
* Request body: ApprovalRequest + timeout_s. Response: {"decision":"approved"|"rejected"} (anything else = rejected).
|
|
312
|
+
*/
|
|
313
|
+
export class WebhookApprover {
|
|
314
|
+
url;
|
|
315
|
+
token;
|
|
316
|
+
constructor(url, token) { this.url = url; this.token = token; }
|
|
317
|
+
async request(req, timeoutS) {
|
|
318
|
+
try {
|
|
319
|
+
const r = await fetch(this.url, {
|
|
320
|
+
method: 'POST', signal: AbortSignal.timeout(timeoutS * 1000),
|
|
321
|
+
headers: { 'content-type': 'application/json', ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) },
|
|
322
|
+
body: JSON.stringify({ ...req, timeout_s: timeoutS }),
|
|
323
|
+
});
|
|
324
|
+
if (!r.ok)
|
|
325
|
+
return 'rejected';
|
|
326
|
+
const d = (await r.json()).decision;
|
|
327
|
+
return d === 'approved' ? 'approved' : d === 'expired' ? 'expired' : 'rejected';
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
return e.name === 'TimeoutError' ? 'expired' : 'rejected';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** No human channel available: fail closed. */
|
|
335
|
+
export class DenyApprover {
|
|
336
|
+
async request() { return 'rejected'; }
|
|
337
|
+
}
|
|
338
|
+
export function defaultApprover() {
|
|
339
|
+
const fixed = process.env.MOBIUS_TEST_APPROVAL;
|
|
340
|
+
if (fixed === 'approved' || fixed === 'rejected' || fixed === 'expired')
|
|
341
|
+
return new FixedApprover(fixed);
|
|
342
|
+
const hook = process.env.MOBIUS_APPROVAL_WEBHOOK;
|
|
343
|
+
if (hook)
|
|
344
|
+
return new WebhookApprover(hook, process.env.MOBIUS_APPROVAL_TOKEN);
|
|
345
|
+
return process.platform === 'darwin' ? new MacDialogApprover(loadPolicy().approval_auth) : new DenyApprover();
|
|
346
|
+
}
|
|
347
|
+
export const newApprovalId = () => `apr_${randomUUID().slice(0, 8)}`;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Mobius Agent app icon: a Möbius loop (a lemniscate whose stroke twists thin at the crossing) on an indigo→violet gradient.
|
|
2
|
+
|
|
3
|
+
python3 brand.py OUT_DIR → OUT_DIR/icon-1024.png and the iOS sizes (AppIcon60x60@2x/@3x, AppIcon76x76@2x, AppIcon83.5x83.5@2x)
|
|
4
|
+
"""
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
10
|
+
|
|
11
|
+
S = 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def gradient():
|
|
15
|
+
img = Image.new("RGB", (S, S))
|
|
16
|
+
top, bottom = (24, 20, 74), (109, 40, 217) # deep indigo → violet
|
|
17
|
+
px = img.load()
|
|
18
|
+
for y in range(S):
|
|
19
|
+
for x in range(S):
|
|
20
|
+
t = min(1.0, max(0.0, (0.65 * y + 0.35 * x) / S))
|
|
21
|
+
px[x, y] = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
|
|
22
|
+
return img
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def loop(draw, cx, cy, a, color, glow=False):
|
|
26
|
+
# Lemniscate of Bernoulli; stroke width varies with |sin| so it pinches at the crossing (the Möbius twist).
|
|
27
|
+
pts = []
|
|
28
|
+
for i in range(1400):
|
|
29
|
+
t = 2 * math.pi * i / 1400
|
|
30
|
+
d = 1 + math.sin(t) ** 2
|
|
31
|
+
x = cx + a * math.cos(t) / d
|
|
32
|
+
y = cy + a * math.sin(t) * math.cos(t) / d
|
|
33
|
+
w = (34 if glow else 22) + (70 if glow else 58) * abs(math.cos(t))
|
|
34
|
+
pts.append((x, y, w))
|
|
35
|
+
for x, y, w in pts:
|
|
36
|
+
r = w / 2
|
|
37
|
+
draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def icon():
|
|
41
|
+
base = gradient()
|
|
42
|
+
glow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
|
43
|
+
loop(ImageDraw.Draw(glow), S / 2, S / 2 + 10, 360, (167, 139, 250, 150), glow=True)
|
|
44
|
+
glow = glow.filter(ImageFilter.GaussianBlur(28))
|
|
45
|
+
base.paste(glow, (0, 0), glow)
|
|
46
|
+
mark = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
|
47
|
+
loop(ImageDraw.Draw(mark), S / 2, S / 2 + 10, 360, (255, 255, 255, 255))
|
|
48
|
+
base.paste(mark, (0, 0), mark)
|
|
49
|
+
# Small "agent" dot riding the loop.
|
|
50
|
+
d = ImageDraw.Draw(base)
|
|
51
|
+
d.ellipse((S / 2 + 300 - 34, S / 2 + 10 - 34, S / 2 + 300 + 34, S / 2 + 10 + 34), fill=(52, 211, 153))
|
|
52
|
+
return base
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
out = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
57
|
+
os.makedirs(out, exist_ok=True)
|
|
58
|
+
big = icon()
|
|
59
|
+
big.save(os.path.join(out, "icon-1024.png"))
|
|
60
|
+
for name, px in [("AppIcon60x60@2x.png", 120), ("AppIcon60x60@3x.png", 180), ("AppIcon76x76@2x~ipad.png", 152), ("AppIcon83.5x83.5@2x~ipad.png", 167)]:
|
|
61
|
+
big.resize((px, px), Image.LANCZOS).save(os.path.join(out, name))
|
|
62
|
+
print(out)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Brand the WebDriverAgent runner as "Mobius Agent" with the Mobius icon, then re-sign it with the same identity.
|
|
3
|
+
set -e
|
|
4
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
APP="$(ls -d "${MOBIUS_WDA_DIR:-$HOME/.mobius/wda}"/build/Build/Products/Debug-iphoneos/*-Runner.app | head -1)"
|
|
6
|
+
python3 "$HERE/brand.py" "$APP" >/dev/null && rm -f "$APP/icon-1024.png"
|
|
7
|
+
P="$APP/Info.plist"
|
|
8
|
+
/usr/libexec/PlistBuddy -c "Delete :CFBundleIcons" "$P" 2>/dev/null || true
|
|
9
|
+
/usr/libexec/PlistBuddy -c "Delete :CFBundleDisplayName" "$P" 2>/dev/null || true
|
|
10
|
+
/usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string Mobius Agent" \
|
|
11
|
+
-c "Add :CFBundleIcons dict" -c "Add :CFBundleIcons:CFBundlePrimaryIcon dict" \
|
|
12
|
+
-c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles array" \
|
|
13
|
+
-c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60" \
|
|
14
|
+
-c "Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName string AppIcon" "$P"
|
|
15
|
+
IDENTITY="$(codesign -dvv "$APP" 2>&1 | sed -n 's/^Authority=//p' | head -1)"
|
|
16
|
+
codesign -d --entitlements :- "$APP" > "$APP.entitlements.plist" 2>/dev/null
|
|
17
|
+
codesign -f -s "$IDENTITY" --entitlements "$APP.entitlements.plist" --generate-entitlement-der "$APP" 2>&1 | grep -v "replacing existing signature" || true
|
|
18
|
+
rm -f "$APP.entitlements.plist"
|
|
19
|
+
codesign --verify --deep --strict "$APP" && echo "branded and re-signed: $APP ($IDENTITY)"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# One-time iOS setup: builds and signs WebDriverAgent (Appium, BSD-3) with YOUR Apple developer team into ~/.mobius/wda.
|
|
3
|
+
# mobiusagent-driver then starts it on a USB-connected iPhone on demand.
|
|
4
|
+
#
|
|
5
|
+
# MOBIUS_TEAM_ID=XXXXXXXXXX ./setup.sh (team id: the OU of your "Apple Development" certificate)
|
|
6
|
+
#
|
|
7
|
+
# Phone: Developer Mode on, paired/trusted with this Mac, UI Automation enabled (Settings → Developer).
|
|
8
|
+
set -e
|
|
9
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
TEAM="${MOBIUS_TEAM_ID:?set MOBIUS_TEAM_ID to your Apple developer team id}"
|
|
11
|
+
DIR="${MOBIUS_WDA_DIR:-$HOME/.mobius/wda}"
|
|
12
|
+
WDA_VERSION="${WDA_VERSION:-v9.15.0}"
|
|
13
|
+
[ -d "$DIR/.git" ] || git clone --depth 1 --branch "$WDA_VERSION" https://github.com/appium/WebDriverAgent.git "$DIR"
|
|
14
|
+
cd "$DIR"
|
|
15
|
+
# Mobius fast touch (tap/swipe without WebDriverAgent's app lookup and settle wait). BSD-3 allows shipping a modified build.
|
|
16
|
+
PATCH="$HERE/wda-mobius-fast-touch.patch"
|
|
17
|
+
git apply --check "$PATCH" 2>/dev/null && git apply "$PATCH" && echo "applied Mobius fast-touch patch"
|
|
18
|
+
xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \
|
|
19
|
+
-destination 'generic/platform=iOS' DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic \
|
|
20
|
+
PRODUCT_BUNDLE_IDENTIFIER="${MOBIUS_WDA_BUNDLE:-dev.mobius.wda}" \
|
|
21
|
+
-allowProvisioningUpdates -allowProvisioningDeviceRegistration -derivedDataPath "$DIR/build" | grep -E "error:|\*\* " || true
|
|
22
|
+
"$HERE/brand.sh"
|
|
23
|
+
# Simulators: an unsigned simulator build (no team needed).
|
|
24
|
+
xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \
|
|
25
|
+
-destination 'generic/platform=iOS Simulator' -derivedDataPath "$DIR/build-sim" CODE_SIGNING_ALLOWED=NO | grep -E "error:|\*\* " || true
|
|
26
|
+
ls "$DIR"/build/Build/Products/*.xctestrun >/dev/null && echo "WebDriverAgent ready in $DIR"
|