@anuraghq/mobius 0.1.0 → 0.1.2
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/README.md +3 -2
- package/bin/mobiusagent-driver-darwin-arm64 +0 -0
- package/dist/cli.js +2 -1
- package/dist/execution/drivers/mobiusagent.d.ts +2 -0
- package/dist/execution/drivers/mobiusagent.js +31 -17
- package/dist/execution/observe.d.ts +2 -0
- package/dist/execution/observe.js +27 -9
- package/dist/execution/session.d.ts +32 -2
- package/dist/execution/session.js +104 -47
- package/dist/interface/http.js +2 -1
- package/dist/interface/mcp.js +42 -13
- package/dist/interface/mobius-tools.d.ts +6 -0
- package/dist/interface/mobius-tools.js +147 -61
- package/dist/observability/audit.d.ts +1 -0
- package/dist/observability/audit.js +14 -1
- package/dist/policy/policy.d.ts +4 -0
- package/dist/policy/policy.js +11 -3
- package/ios-agent/setup.sh +10 -4
- package/package.json +35 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Mobius
|
|
2
2
|
|
|
3
|
-
**Let AI agents use a real phone, safely.**
|
|
3
|
+
**Let AI agents use a real phone, safely.** · Website: https://anu-code07.github.io/mobius/
|
|
4
4
|
|
|
5
5
|
Mobius is an open-source [MCP](https://modelcontextprotocol.io) server that lets Claude Code, Cursor or any MCP client operate Android and iOS devices. The agent sees a clean, masked view of the screen, every action is checked, and anything that spends money or sends something needs a human's approval.
|
|
6
6
|
|
|
@@ -279,7 +279,8 @@ Everything an operator needs to let agents loose on phones, in the open-source e
|
|
|
279
279
|
| **No test-mode backdoor** | `MOBIUS_TEST_APPROVAL=approved` only ever approves on emulators and simulators; on a physical phone it rejects. |
|
|
280
280
|
| **Tamper-evident audit** | Each entry records the agent (MCP client), session, decision, rule id, action class and amount, hash-chained to the previous one. `mobius audit verify` detects any edit; `mobius audit export` gives JSONL for your SIEM. |
|
|
281
281
|
| **Kill switch + quotas** | `mobius stop` halts every agent's state-changing actions instantly (`mobius resume` to undo). `MOBIUS_MAX_ACTIONS` (default 1000) and `MOBIUS_MAX_SESSION_MIN` cap a session. |
|
|
282
|
-
| **Secrets vault** | `type` with `secret_ref: "env:NAME"` or `"keychain:service/account"`: the host fetches the secret and types it; the model never sees it and it's never logged. |
|
|
282
|
+
| **Secrets vault** | `type` with `secret_ref: "env:NAME"` or `"keychain:service/account"`: the host fetches the secret and types it; the model never sees it and it's never logged. Only refs listed in `MOBIUS_SECRETS` (comma-separated, e.g. `env:SHOP_PASS,keychain:shop/riya`) can be used. |
|
|
283
|
+
| **Contained file output** | Screenshots, recordings, pulled files and reports are written only under `MOBIUS_OUTPUT_DIR` (default `~/.mobius/outputs`); an agent can't write anywhere else on your machine. |
|
|
283
284
|
| **Attack corpus + Break Mobius** | [`attacks.test.ts`](https://github.com/Anu-Code07/mobius/blob/main/packages/host/test/attacks.test.ts) (45 policy cases) and the **[Break Mobius benchmark](https://github.com/Anu-Code07/mobius/blob/main/docs/benchmark/README.md)**: 44 end-to-end attacks scored on what actually happened on the device (all 44 defended) plus 9 must-not-annoy controls. Both run on every `npm test` as a release gate. `npm run bench` prints the scorecard. |
|
|
284
285
|
|
|
285
286
|
| **REST API** | `mobius serve` exposes the same engine over HTTP for agents that don't speak MCP: `POST /v1/actions`, `GET /v1/tools`, `GET /v1/audit[/export\|/verify]`. Same policy, approvals and audit as MCP. Bearer token, localhost by default. |
|
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,8 @@ import { driverBuilt, driverPath, iosAgentReady, MobiusAgentConnector, MobiusAge
|
|
|
13
13
|
/** Every online device through the Mobius driver. iOS devices also need the Mobius Agent runner set up once. */
|
|
14
14
|
const connectors = (driver) => async () => {
|
|
15
15
|
if (!driverBuilt())
|
|
16
|
-
throw new Error(`Mobius driver
|
|
16
|
+
throw new Error(`No Mobius driver for ${process.platform}-${process.arch} in this install (looked for ${driverPath()}). ` +
|
|
17
|
+
'Build it from https://github.com/Anu-Code07/mobius (`cargo build --release` in drivers/mobiusagent-driver) and point MOBIUS_DRIVER_BIN at the binary.');
|
|
17
18
|
const ios = iosAgentReady();
|
|
18
19
|
return (await driver.devices()).filter(d => d.state === 'online' && (d.platform === 'android' || ios)).map(d => new MobiusAgentConnector(driver, d));
|
|
19
20
|
};
|
|
@@ -24,6 +24,8 @@ export declare class MobiusAgentDriver {
|
|
|
24
24
|
devices(): Promise<AgentDevice[]>;
|
|
25
25
|
close(): void;
|
|
26
26
|
}
|
|
27
|
+
/** How long a driver op may take: sessions (`seconds`), routes, installs, waits and the first OCR build get their own budget. */
|
|
28
|
+
export declare function extraTimeoutMs(op: string, args?: Record<string, unknown>): number;
|
|
27
29
|
export declare class MobiusAgentConnector implements Connector {
|
|
28
30
|
readonly platform: 'android' | 'ios';
|
|
29
31
|
readonly deviceId: string;
|
|
@@ -32,7 +32,15 @@ export class MobiusAgentDriver {
|
|
|
32
32
|
next = 1;
|
|
33
33
|
waiting = new Map();
|
|
34
34
|
start() {
|
|
35
|
-
|
|
35
|
+
// stderr is not read: a full pipe would stall the driver.
|
|
36
|
+
const p = spawn(driverPath(), [], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
37
|
+
// Every call in flight when the driver dies (killed after a timeout, crashed) may or may not have reached the device.
|
|
38
|
+
const failAll = (e) => { for (const w of this.waiting.values())
|
|
39
|
+
w.fail(e); this.waiting.clear(); if (this.proc === p)
|
|
40
|
+
this.proc = null; };
|
|
41
|
+
// A binary that can't start (missing, not executable) must not crash the server.
|
|
42
|
+
p.on('error', e => failAll(new MobiusError('INTERNAL', `mobiusagent-driver could not run: ${e.message}`)));
|
|
43
|
+
p.stdin.on('error', () => { });
|
|
36
44
|
createInterface({ input: p.stdout }).on('line', line => {
|
|
37
45
|
let r;
|
|
38
46
|
try {
|
|
@@ -47,12 +55,7 @@ export class MobiusAgentDriver {
|
|
|
47
55
|
this.waiting.delete(r.id);
|
|
48
56
|
r.ok ? w.ok(r.data) : w.fail(new MobiusError('INTERNAL', String(r.error)));
|
|
49
57
|
});
|
|
50
|
-
p.on('exit', () => {
|
|
51
|
-
for (const w of this.waiting.values())
|
|
52
|
-
w.fail(new MobiusError('INTERNAL', 'mobiusagent-driver exited', { retryable: true }));
|
|
53
|
-
this.waiting.clear();
|
|
54
|
-
this.proc = null;
|
|
55
|
-
});
|
|
58
|
+
p.on('exit', () => failAll(new MobiusError('OUTCOME_UNKNOWN', 'mobiusagent-driver exited during the call; it may or may not have happened', { retryable: true, hint: 'Call observe before retrying.' })));
|
|
56
59
|
this.proc = p;
|
|
57
60
|
return p;
|
|
58
61
|
}
|
|
@@ -65,7 +68,7 @@ export class MobiusAgentDriver {
|
|
|
65
68
|
this.waiting.delete(id);
|
|
66
69
|
// ponytail: a wedged adb call blocks the one driver process; restart it rather than queue behind it.
|
|
67
70
|
this.proc?.kill();
|
|
68
|
-
fail(new MobiusError('
|
|
71
|
+
fail(new MobiusError('OUTCOME_UNKNOWN', `mobiusagent-driver ${op} did not answer within ${timeoutMs} ms; it may or may not have happened`, { retryable: true, hint: 'Call observe before retrying.' }));
|
|
69
72
|
}, timeoutMs);
|
|
70
73
|
this.waiting.set(id, { ok: v => { clearTimeout(t); ok(v); }, fail: e => { clearTimeout(t); fail(e); } });
|
|
71
74
|
p.stdin.write(JSON.stringify({ id, op, ...args }) + '\n');
|
|
@@ -74,7 +77,14 @@ export class MobiusAgentDriver {
|
|
|
74
77
|
async devices() { return (await this.call('devices')).devices; }
|
|
75
78
|
close() { this.proc?.kill(); this.proc = null; }
|
|
76
79
|
}
|
|
77
|
-
const KEYS = { back: 'back', home: 'home', recents: 'recents', enter: 'enter' };
|
|
80
|
+
const KEYS = { back: 'back', home: 'home', recents: 'recents', enter: 'enter', notifications: 'notifications' };
|
|
81
|
+
/** How long a driver op may take: sessions (`seconds`), routes, installs, waits and the first OCR build get their own budget. */
|
|
82
|
+
export function extraTimeoutMs(op, args = {}) {
|
|
83
|
+
const secs = Number(args.seconds ?? 0);
|
|
84
|
+
return op === 'gps_route' ? 3_700_000 : op === 'install' ? 300_000
|
|
85
|
+
: ['perf_report', 'watch_crashes'].includes(op) ? (secs + 120) * 1000 : ['ocr', 'bug_report', 'replay'].includes(op) ? 180_000
|
|
86
|
+
: op === 'wait_idle' ? Number(args.timeout_ms ?? 5000) + 30_000 : 60_000;
|
|
87
|
+
}
|
|
78
88
|
export class MobiusAgentConnector {
|
|
79
89
|
platform;
|
|
80
90
|
deviceId;
|
|
@@ -106,7 +116,10 @@ export class MobiusAgentConnector {
|
|
|
106
116
|
}
|
|
107
117
|
async dump() {
|
|
108
118
|
const r = await this.drv.call('dump', this.d).catch(e => {
|
|
109
|
-
|
|
119
|
+
const msg = e.message;
|
|
120
|
+
if (/\block(ed)?\b|unlock|passcode/i.test(msg))
|
|
121
|
+
throw new MobiusError('DEVICE_LOCKED', `Could not read the screen: ${msg}`, { retryable: true, hint: 'Ask the user to wake and unlock the phone.' });
|
|
122
|
+
throw new MobiusError('DEVICE_OFFLINE', `Could not read the screen: ${msg}`, { retryable: true, hint: this.platform === 'ios' ? 'Check the device is connected and WebDriverAgent is running (mobius setup-ios).' : 'Check the device is connected (adb devices).' });
|
|
110
123
|
});
|
|
111
124
|
this.size = { width: r.width, height: r.height };
|
|
112
125
|
const root = { cls: 'hierarchy', pkg: '', text: '', desc: '', resourceId: '', clickable: false, longClickable: false,
|
|
@@ -144,22 +157,23 @@ export class MobiusAgentConnector {
|
|
|
144
157
|
}
|
|
145
158
|
async key(key) {
|
|
146
159
|
const k = KEYS[key];
|
|
147
|
-
if (!k || (this.platform === 'ios' &&
|
|
160
|
+
if (!k || (this.platform === 'ios' && key !== 'home' && key !== 'enter'))
|
|
148
161
|
throw new MobiusError('UNSUPPORTED_CAPABILITY', `Key "${key}" is not available on this device`);
|
|
149
162
|
await this.drv.call('key', { ...this.d, key: k });
|
|
150
163
|
}
|
|
151
164
|
async openApp(appId) {
|
|
152
165
|
await this.drv.call('launch', { ...this.d, app: appId }).catch(e => {
|
|
153
|
-
|
|
166
|
+
const msg = e.message;
|
|
167
|
+
// iOS refuses launches on a locked phone (FBSOpenApplicationErrorDomain code 7, "Locked"): not a missing app.
|
|
168
|
+
if (/\block(ed)?\b|FBSOpenApplicationErrorDomain Code=7\b|unlock/i.test(msg)) {
|
|
169
|
+
throw new MobiusError('DEVICE_LOCKED', `The phone is locked, so ${appId} could not be opened`, { retryable: true, hint: 'Ask the user to unlock the phone, then retry.' });
|
|
170
|
+
}
|
|
171
|
+
throw new MobiusError('APP_NOT_INSTALLED', msg, { hint: 'Call list_apps to see installed apps.' });
|
|
154
172
|
});
|
|
155
173
|
}
|
|
156
174
|
async openUrl(url) { await this.drv.call('url', { ...this.d, url }); }
|
|
157
175
|
async listApps() { return (await this.drv.call('apps', this.d)).apps; }
|
|
158
176
|
async extra(op, args = {}) {
|
|
159
|
-
|
|
160
|
-
const secs = Number(args.seconds ?? 0);
|
|
161
|
-
const timeout = op === 'gps_route' ? 3_700_000 : op === 'install' ? 300_000
|
|
162
|
-
: ['perf_report', 'watch_crashes'].includes(op) ? (secs + 120) * 1000 : ['ocr', 'bug_report', 'replay'].includes(op) ? 180_000 : 60_000;
|
|
163
|
-
return this.drv.call(op, { ...this.d, ...args }, timeout);
|
|
177
|
+
return this.drv.call(op, { ...this.d, ...args }, extraTimeoutMs(op, args));
|
|
164
178
|
}
|
|
165
179
|
}
|
|
@@ -27,6 +27,8 @@ export declare function normalize(screen: RawScreen, obsId: string): Normalized
|
|
|
27
27
|
* an earlier Mobius process still resolves against the live screen (same identity rules, same ambiguity checks). */
|
|
28
28
|
export declare class ObservationStore {
|
|
29
29
|
private seq;
|
|
30
|
+
/** The observation the agent was last shown: never evicted, so its refs resolve (by identity) or fail as stale. */
|
|
31
|
+
shown: string | null;
|
|
30
32
|
private history;
|
|
31
33
|
private saved;
|
|
32
34
|
private readonly file;
|
|
@@ -34,9 +34,11 @@ function roleOf(n) {
|
|
|
34
34
|
const REDACTED = '[redacted]';
|
|
35
35
|
const CARD_LIKE = /\b(?:\d[ -]?){12,18}\d\b/g;
|
|
36
36
|
// Digits may be spaced or dashed ("4 8 2 9 1 3", "482-913") to dodge a plain \d{6} match.
|
|
37
|
-
const OTP_LIKE = /\b(otp|one[- ]?time|verification code|security code|passcode|pin|cvv|cvc)\b([^\d]{0,25})\d(?:[ -]?\d){2,7}\b/gi;
|
|
37
|
+
const OTP_LIKE = /\b(otp|one[- ]?time|verification code|security code|passcode|code|pin|cvv|cvc)\b([^\d]{0,25})\d(?:[ -]?\d){2,7}\b/gi;
|
|
38
|
+
// The other common order: "482913 is your OTP", "4829 is the code for login". Never an amount ("₹1499 · Enter PIN").
|
|
39
|
+
const OTP_FIRST = /(?<!(?:₹|rs\.?|inr|\$|€|£)\s?[\d,]*)\b\d(?:[ -]?\d){3,7}\b(?=[^\d]{0,30}\b(?:otp|one[- ]?time|verification code|security code|passcode|code|pin)\b)/gi;
|
|
38
40
|
export function scrub(s) {
|
|
39
|
-
return s.replace(CARD_LIKE, REDACTED).replace(OTP_LIKE, (_m, kw, mid) => `${kw}${mid}${REDACTED}`);
|
|
41
|
+
return s.replace(CARD_LIKE, REDACTED).replace(OTP_LIKE, (_m, kw, mid) => `${kw}${mid}${REDACTED}`).replace(OTP_FIRST, REDACTED);
|
|
40
42
|
}
|
|
41
43
|
/** A field asking for a secret, whatever the driver reported (an app may not set the password flag on a PIN box). */
|
|
42
44
|
const SECRET_FIELD = /\b(password|passcode|passwd|pin|mpin|upi pin|otp|cvv|cvc|secret)\b|one[- ]?time/i;
|
|
@@ -64,13 +66,25 @@ export const MAX_ELEMENTS = 400;
|
|
|
64
66
|
export function normalize(screen, obsId) {
|
|
65
67
|
const picked = [];
|
|
66
68
|
let kb = null;
|
|
69
|
+
// The IME window is often full-screen and transparent above the keys (Android 15 edge-to-edge), so it must not
|
|
70
|
+
// count as covering the app. No keyboard is taller than 60% of the screen: descend through such wrappers and take
|
|
71
|
+
// the first keyboard-sized node (the key panel, suggestion strip included) whole.
|
|
72
|
+
const keys = (n) => {
|
|
73
|
+
if (!visible(n))
|
|
74
|
+
return;
|
|
75
|
+
if (n.bounds.height > screen.height * 0.6) {
|
|
76
|
+
n.children.forEach(keys);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const b = n.bounds;
|
|
80
|
+
kb = kb ? { x1: Math.min(kb.x1, b.x), y1: Math.min(kb.y1, b.y), x2: Math.max(kb.x2, b.x + b.width), y2: Math.max(kb.y2, b.y + b.height) }
|
|
81
|
+
: { x1: b.x, y1: b.y, x2: b.x + b.width, y2: b.y + b.height };
|
|
82
|
+
};
|
|
67
83
|
const walk = (n, insideClickable) => {
|
|
68
84
|
if (isKeyboard(n) && visible(n)) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return; // keys are noise for the agent; the area is reported as `keyboard`
|
|
73
|
-
}
|
|
85
|
+
keys(n);
|
|
86
|
+
return;
|
|
87
|
+
} // keys are noise for the agent; the area is reported as `keyboard`
|
|
74
88
|
if (!visible(n) && n.cls !== 'hierarchy') {
|
|
75
89
|
// Zero-size containers (common on iOS) can still hold visible children.
|
|
76
90
|
for (const c of n.children)
|
|
@@ -135,6 +149,8 @@ const identityHash = (f) => createHash('sha256').update(JSON.stringify([f.role,
|
|
|
135
149
|
* an earlier Mobius process still resolves against the live screen (same identity rules, same ambiguity checks). */
|
|
136
150
|
export class ObservationStore {
|
|
137
151
|
seq = 0;
|
|
152
|
+
/** The observation the agent was last shown: never evicted, so its refs resolve (by identity) or fail as stale. */
|
|
153
|
+
shown = null;
|
|
138
154
|
history = [];
|
|
139
155
|
saved = [];
|
|
140
156
|
file;
|
|
@@ -152,8 +168,10 @@ export class ObservationStore {
|
|
|
152
168
|
nextId() { return `o${++this.seq}`; }
|
|
153
169
|
add(obs, fps) {
|
|
154
170
|
this.history.push({ obs, fps });
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
// ponytail: settle/verify polls add an observation every ~100 ms, so a small window evicts the ids the agent
|
|
172
|
+
// just got from observe after one slow action. 64 covers a full verify timeout; the on-disk copy stays at 5.
|
|
173
|
+
if (this.history.length > 64)
|
|
174
|
+
this.history.splice(this.history.findIndex(h => h.obs.observation_id !== this.shown), 1);
|
|
157
175
|
if (!this.file)
|
|
158
176
|
return;
|
|
159
177
|
this.saved.push([...fps].map(([id, f]) => ({ id, hash: identityHash(f), dup: f.dup_index, rid: !!f.resource_id })));
|
|
@@ -23,7 +23,16 @@ export declare const LAUNCH_EXPECT_TIMEOUT_MS = 20000;
|
|
|
23
23
|
/** Where to touch an element: the center of its on-screen part (an element partly above/left of the screen has negative
|
|
24
24
|
* bounds; its raw center can be off-screen). Given the screen, a point not covered by a smaller clickable element inside
|
|
25
25
|
* it: tapping a big card's center must not hit the item drawn there (live run: Swiggy's restaurant card opened a burger). */
|
|
26
|
-
|
|
26
|
+
type Box = Element['bounds'];
|
|
27
|
+
/**
|
|
28
|
+
* Where to tap an element: inside its on-screen part, off its own clickable children (a card's inner button),
|
|
29
|
+
* and off anything drawn over it (a floating toolbar, tab bar or sheet: a later element that isn't its ancestor).
|
|
30
|
+
* Throws when no point of it is visibly tappable, so a covered or off-screen target is never tapped "successfully".
|
|
31
|
+
*/
|
|
32
|
+
export declare const center: (e: Element, screen?: readonly Element[], size?: {
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
}, keyboard?: Box | null) => {
|
|
27
36
|
x: number;
|
|
28
37
|
y: number;
|
|
29
38
|
};
|
|
@@ -32,21 +41,41 @@ export declare function expectationMet(obs: Observation, ex: Expect): boolean;
|
|
|
32
41
|
export declare class DeviceSession {
|
|
33
42
|
readonly connector: Connector;
|
|
34
43
|
readonly store: ObservationStore;
|
|
44
|
+
/** The observation last shown to the agent (observe / list / snapshot): short refs like @e5 point into it, not into internal polls. */
|
|
45
|
+
get shown(): string | null;
|
|
46
|
+
set shown(id: string | null);
|
|
35
47
|
private readonly audit;
|
|
36
48
|
private readonly policy;
|
|
37
49
|
private readonly approver;
|
|
38
50
|
private queue;
|
|
51
|
+
/** Device work that outlived its action's timeout: the next action waits for it (no tap while typing still runs). */
|
|
52
|
+
private inflight;
|
|
53
|
+
/** Set when an audit write failed: every later action is refused (no unaudited actions). */
|
|
54
|
+
private auditBroken;
|
|
39
55
|
private recent;
|
|
40
56
|
private readonly plane;
|
|
41
57
|
private verdict;
|
|
42
58
|
constructor(connector: Connector, audit: AuditLog, policy: Policy, approver: Approver, plane?: PlaneContext, persistIds?: boolean);
|
|
43
59
|
isBlockedApp(app: string | null): boolean;
|
|
60
|
+
/** Who is asking may act at all: a rejected, revoked or expired session can't read the screen either. */
|
|
61
|
+
checkAgent(): void;
|
|
62
|
+
/** Kill switch, audit health and the agent's session, for every gated action; the quota for state changes. */
|
|
63
|
+
private checkPlane;
|
|
64
|
+
/** Where the finger lands for an element on the latest screen (on screen, off overlays and the keyboard). */
|
|
65
|
+
pointOf(el: Element): {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
};
|
|
44
69
|
/** Policy + approval gate. Returns the (re-resolved) target to act on. Throws MobiusError to stop. */
|
|
45
70
|
private gate;
|
|
46
71
|
/** Serialize work per device: one action at a time, no interleaved taps. */
|
|
47
72
|
private exclusive;
|
|
48
73
|
observe(): Promise<Observation>;
|
|
49
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Re-observe until two consecutive screens hash the same (UI settled), bounded. State-based, no fixed sleeps.
|
|
76
|
+
* Two identical frames of the *pre-action* screen only count after a grace window: a tap that launches an
|
|
77
|
+
* activity takes a beat before the window changes, and settling early reports the old screen as the result.
|
|
78
|
+
*/
|
|
50
79
|
private settle;
|
|
51
80
|
/** Every element containing the point on the latest screen (overlays, the button below them, its labels). */
|
|
52
81
|
elementsAt(x: number, y: number): Element[];
|
|
@@ -99,3 +128,4 @@ export declare class DeviceSession {
|
|
|
99
128
|
check?: (obs: Observation) => string | null;
|
|
100
129
|
}): Promise<ActionResult>;
|
|
101
130
|
}
|
|
131
|
+
export {};
|
|
@@ -10,32 +10,53 @@ export const DEFAULT_EXPECT_TIMEOUT_MS = 6_000;
|
|
|
10
10
|
export const LAUNCH_EXPECT_TIMEOUT_MS = 20_000;
|
|
11
11
|
/** Pause between re-reads while waiting for the screen. */
|
|
12
12
|
const POLL_MS = 100;
|
|
13
|
+
/** How long an unchanged pre-action screen is waited on before settle accepts it (see settle). */
|
|
14
|
+
const SETTLE_GRACE_MS = 600;
|
|
13
15
|
/** Text read from pixels, as an element the policy can judge like any label. */
|
|
14
16
|
const drawnText = (text, bounds) => ({ id: 'drawn', role: 'text', text, label: null, resource_id: null,
|
|
15
17
|
clickable: false, long_clickable: false, enabled: true, focused: false, scrollable: false, editable: false, checked: null, selected: false,
|
|
16
18
|
sensitive: false, bounds, dup_index: 0 });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const has = (b, p) => p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
|
|
20
|
+
const holds = (o, i) => o.x <= i.x && o.y <= i.y && o.x + o.width >= i.x + i.width && o.y + o.height >= i.y + i.height;
|
|
21
|
+
/**
|
|
22
|
+
* Where to tap an element: inside its on-screen part, off its own clickable children (a card's inner button),
|
|
23
|
+
* and off anything drawn over it (a floating toolbar, tab bar or sheet: a later element that isn't its ancestor).
|
|
24
|
+
* Throws when no point of it is visibly tappable, so a covered or off-screen target is never tapped "successfully".
|
|
25
|
+
*/
|
|
26
|
+
export const center = (e, screen, size, keyboard) => {
|
|
27
|
+
const W = size?.width || Infinity, H = size?.height || Infinity;
|
|
21
28
|
const x1 = Math.max(e.bounds.x, 0), y1 = Math.max(e.bounds.y, 0);
|
|
22
|
-
const x2 = Math.
|
|
29
|
+
const x2 = Math.min(e.bounds.x + e.bounds.width, W), y2 = Math.min(e.bounds.y + e.bounds.height, H);
|
|
30
|
+
if (x2 <= x1 || y2 <= y1) {
|
|
31
|
+
if (size)
|
|
32
|
+
throw new MobiusError('ELEMENT_NOT_FOUND', `${e.id} is off screen`, { hint: 'Scroll it into view, observe, then tap.' });
|
|
33
|
+
return { x: Math.max(x1, Math.min(x2, (x1 + x2) / 2)), y: Math.max(y1, Math.min(y2, (y1 + y2) / 2)) };
|
|
34
|
+
}
|
|
23
35
|
const mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
24
36
|
if (!screen)
|
|
25
37
|
return mid;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const kids = screen.filter(o => o !== e && o.id !== e.id && (o.clickable || o.editable) &&
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
38
|
+
const idx = screen.findIndex(o => o === e || o.id === e.id);
|
|
39
|
+
const area = (b) => b.width * b.height;
|
|
40
|
+
const kids = screen.filter(o => o !== e && o.id !== e.id && (o.clickable || o.editable) && holds(e.bounds, o.bounds) && area(o.bounds) < area(e.bounds));
|
|
41
|
+
// Drawn on top: later in tree order, overlapping, neither its ancestor nor its descendant, and not a decoration
|
|
42
|
+
// that passes touches through (plain text/images inside the overlap still block a tap on what's below).
|
|
43
|
+
const over = idx < 0 ? [] : screen.slice(idx + 1).filter(o => !holds(o.bounds, e.bounds) && !holds(e.bounds, o.bounds)
|
|
44
|
+
&& (o.clickable || o.editable || o.scrollable || /toolbar|tab ?bar|navigation bar|sheet/i.test(`${o.label ?? ''} ${o.text ?? ''}`)));
|
|
45
|
+
// The soft keyboard is drawn over the app too (its keys aren't in `screen`): a tap there types a letter.
|
|
46
|
+
const covers = keyboard ? [...over.map(o => o.bounds), keyboard] : over.map(o => o.bounds);
|
|
47
|
+
const covered = (p) => covers.some(b => has(b, p));
|
|
48
|
+
const blocked = (p) => kids.some(k => has(k.bounds, p)) || covered(p);
|
|
32
49
|
const at = (fx, fy) => ({ x: x1 + (x2 - x1) * fx, y: y1 + (y2 - y1) * fy });
|
|
33
50
|
for (const [fx, fy] of [[0.5, 0.5], [0.5, 0.12], [0.12, 0.5], [0.88, 0.5], [0.5, 0.88], [0.12, 0.12], [0.88, 0.12], [0.12, 0.88], [0.88, 0.88], [0.3, 0.12], [0.7, 0.12]]) {
|
|
34
51
|
const p = at(fx, fy);
|
|
35
|
-
if (!
|
|
52
|
+
if (!blocked(p))
|
|
36
53
|
return p;
|
|
37
54
|
}
|
|
38
|
-
|
|
55
|
+
if (covered(mid)) {
|
|
56
|
+
const by = [...over.filter(o => has(o.bounds, mid)).map(o => o.label || o.text || o.role), ...(keyboard && has(keyboard, mid) ? ['the keyboard'] : [])];
|
|
57
|
+
throw new MobiusError('ELEMENT_NOT_FOUND', `${e.id} is covered by ${by.slice(0, 2).join(', ')}`, { hint: by.includes('the keyboard') ? 'Hide the keyboard (mobius_hide_keyboard), observe, then tap.' : 'Scroll it into view, observe, then tap.' });
|
|
58
|
+
}
|
|
59
|
+
return mid; // covered only by its own children: the children are the target in practice
|
|
39
60
|
};
|
|
40
61
|
function withTimeout(p, ms, what) {
|
|
41
62
|
let timer;
|
|
@@ -62,10 +83,17 @@ export function expectationMet(obs, ex) {
|
|
|
62
83
|
export class DeviceSession {
|
|
63
84
|
connector;
|
|
64
85
|
store;
|
|
86
|
+
/** The observation last shown to the agent (observe / list / snapshot): short refs like @e5 point into it, not into internal polls. */
|
|
87
|
+
get shown() { return this.store.shown; }
|
|
88
|
+
set shown(id) { this.store.shown = id; }
|
|
65
89
|
audit;
|
|
66
90
|
policy;
|
|
67
91
|
approver;
|
|
68
92
|
queue = Promise.resolve();
|
|
93
|
+
/** Device work that outlived its action's timeout: the next action waits for it (no tap while typing still runs). */
|
|
94
|
+
inflight = Promise.resolve();
|
|
95
|
+
/** Set when an audit write failed: every later action is refused (no unaudited actions). */
|
|
96
|
+
auditBroken = null;
|
|
69
97
|
recent = [];
|
|
70
98
|
plane;
|
|
71
99
|
verdict = null;
|
|
@@ -78,15 +106,51 @@ export class DeviceSession {
|
|
|
78
106
|
this.approver = approver;
|
|
79
107
|
}
|
|
80
108
|
isBlockedApp(app) { return this.policy.appBlocked(app); }
|
|
109
|
+
/** Who is asking may act at all: a rejected, revoked or expired session can't read the screen either. */
|
|
110
|
+
checkAgent() {
|
|
111
|
+
const pl = this.plane;
|
|
112
|
+
const ses = pl.session();
|
|
113
|
+
if (ses && 'invalid' in ses)
|
|
114
|
+
throw new MobiusError('POLICY_BLOCKED', `Agent session token rejected: ${ses.invalid}`, { hint: 'Ask the operator for a fresh session token.' });
|
|
115
|
+
if (!ses && pl.require_session)
|
|
116
|
+
throw new MobiusError('POLICY_BLOCKED', 'This Mobius requires a signed agent session', { hint: 'Ask the operator for a session token (mobius session issue).' });
|
|
117
|
+
if (ses && isRevoked(ses.jti))
|
|
118
|
+
throw new MobiusError('POLICY_BLOCKED', 'Agent session revoked by the operator', { hint: 'Stop and tell the user.' });
|
|
119
|
+
if (ses && ses.exp <= Date.now() / 1000)
|
|
120
|
+
throw new MobiusError('POLICY_BLOCKED', 'Agent session expired', { hint: 'Ask the operator for a fresh session token.' });
|
|
121
|
+
if (ses?.policy_version && ses.policy_version !== this.policy.config.version) {
|
|
122
|
+
throw new MobiusError('POLICY_BLOCKED', `Session was issued for policy ${ses.policy_version}, but Mobius runs ${this.policy.config.version}`, { hint: 'The operator must re-issue the session for the current policy.' });
|
|
123
|
+
}
|
|
124
|
+
if (pl.max_minutes && Date.now() - pl.started > pl.max_minutes * 60_000)
|
|
125
|
+
throw new MobiusError('POLICY_BLOCKED', `Session time limit reached (${pl.max_minutes} min)`, { hint: 'Stop and report progress to the user.' });
|
|
126
|
+
}
|
|
127
|
+
/** Kill switch, audit health and the agent's session, for every gated action; the quota for state changes. */
|
|
128
|
+
checkPlane(mutating) {
|
|
129
|
+
if (existsSync(killSwitchPath()))
|
|
130
|
+
throw new MobiusError('POLICY_BLOCKED', 'Kill switch engaged: all agent actions are stopped', { hint: 'A human ran `mobius stop`. Stop and tell the user.' });
|
|
131
|
+
if (this.auditBroken)
|
|
132
|
+
throw new MobiusError('POLICY_BLOCKED', `Audit log unavailable (${this.auditBroken}), so actions are refused`, { hint: 'Tell the user: the audit database needs attention.' });
|
|
133
|
+
this.checkAgent();
|
|
134
|
+
const pl = this.plane;
|
|
135
|
+
if (mutating && pl.actions >= pl.max_actions)
|
|
136
|
+
throw new MobiusError('POLICY_BLOCKED', `Action quota reached (${pl.max_actions} per session)`, { hint: 'Stop and report progress to the user.' });
|
|
137
|
+
}
|
|
138
|
+
/** Where the finger lands for an element on the latest screen (on screen, off overlays and the keyboard). */
|
|
139
|
+
pointOf(el) {
|
|
140
|
+
const l = this.store.latest();
|
|
141
|
+
return center(el, l?.elements, l?.screen, l?.keyboard);
|
|
142
|
+
}
|
|
81
143
|
/** Policy + approval gate. Returns the (re-resolved) target to act on. Throws MobiusError to stop. */
|
|
82
144
|
async gate(tool, guard) {
|
|
83
145
|
// Time of check = time of use: the app may have changed the screen since the agent read it ("Add to cart" becomes
|
|
84
146
|
// "Pay ₹999"). Re-read now and decide on what the finger will actually hit. A changed target is refused as stale.
|
|
85
|
-
|
|
147
|
+
// Reads re-read too: a read is judged on the app in front now (a banking app may have opened since the last look).
|
|
148
|
+
this.checkPlane(guard.ctx.mutating);
|
|
149
|
+
if (!guard.ctx.mutating || guard.targetId || guard.point)
|
|
86
150
|
await this.observe();
|
|
87
151
|
const latest = this.store.latest();
|
|
88
152
|
let target = guard.targetId ? this.store.resolve(guard.targetId) : guard.point ? this.elementAt(guard.point.x, guard.point.y) : null;
|
|
89
|
-
const p = guard.point ?? (target ?
|
|
153
|
+
const p = guard.point ?? (target ? this.pointOf(target) : null);
|
|
90
154
|
const under = p ? this.elementsAt(p.x, p.y) : [];
|
|
91
155
|
// Pixels, not just labels: on a screen showing an amount due, read the text actually drawn where the finger lands
|
|
92
156
|
// (OCR), so a Pay button can't hide behind an accessibility label that says "Close".
|
|
@@ -102,27 +166,6 @@ export class DeviceSession {
|
|
|
102
166
|
under.push(drawnText(String(t.text), { x, y, width: w, height: h }));
|
|
103
167
|
}
|
|
104
168
|
}
|
|
105
|
-
if (guard.ctx.mutating) {
|
|
106
|
-
if (existsSync(killSwitchPath()))
|
|
107
|
-
throw new MobiusError('POLICY_BLOCKED', 'Kill switch engaged: all agent actions are stopped', { hint: 'A human ran `mobius stop`. Stop and tell the user.' });
|
|
108
|
-
const pl = this.plane;
|
|
109
|
-
if (pl.actions >= pl.max_actions)
|
|
110
|
-
throw new MobiusError('POLICY_BLOCKED', `Action quota reached (${pl.max_actions} per session)`, { hint: 'Stop and report progress to the user.' });
|
|
111
|
-
const ses = pl.session();
|
|
112
|
-
if (ses && 'invalid' in ses)
|
|
113
|
-
throw new MobiusError('POLICY_BLOCKED', `Agent session token rejected: ${ses.invalid}`, { hint: 'Ask the operator for a fresh session token.' });
|
|
114
|
-
if (!ses && pl.require_session)
|
|
115
|
-
throw new MobiusError('POLICY_BLOCKED', 'This Mobius requires a signed agent session', { hint: 'Ask the operator for a session token (mobius session issue).' });
|
|
116
|
-
if (ses && isRevoked(ses.jti))
|
|
117
|
-
throw new MobiusError('POLICY_BLOCKED', 'Agent session revoked by the operator', { hint: 'Stop and tell the user.' });
|
|
118
|
-
if (ses && ses.exp <= Date.now() / 1000)
|
|
119
|
-
throw new MobiusError('POLICY_BLOCKED', 'Agent session expired', { hint: 'Ask the operator for a fresh session token.' });
|
|
120
|
-
if (ses?.policy_version && ses.policy_version !== this.policy.config.version) {
|
|
121
|
-
throw new MobiusError('POLICY_BLOCKED', `Session was issued for policy ${ses.policy_version}, but Mobius runs ${this.policy.config.version}`, { hint: 'The operator must re-issue the session for the current policy.' });
|
|
122
|
-
}
|
|
123
|
-
if (pl.max_minutes && Date.now() - pl.started > pl.max_minutes * 60_000)
|
|
124
|
-
throw new MobiusError('POLICY_BLOCKED', `Session time limit reached (${pl.max_minutes} min)`, { hint: 'Stop and report progress to the user.' });
|
|
125
|
-
}
|
|
126
169
|
const v = this.policy.evaluate({ ...guard.ctx, tool, app: latest?.foreground_app ?? null, target, screen: latest?.elements, under });
|
|
127
170
|
this.verdict = v;
|
|
128
171
|
const { decision, reason, action_class } = v;
|
|
@@ -136,10 +179,12 @@ export class DeviceSession {
|
|
|
136
179
|
throw new MobiusError('APPROVAL_REJECTED', `User rejected: ${req.summary}`, { hint: 'Do not retry. Ask the user what they want instead.' });
|
|
137
180
|
if (outcome === 'expired')
|
|
138
181
|
throw new MobiusError('APPROVAL_EXPIRED', `No decision within ${this.policy.config.approval_timeout_s}s: ${req.summary}`, { retryable: true });
|
|
139
|
-
// Time passed while the human decided:
|
|
140
|
-
|
|
182
|
+
// Time passed while the human decided (up to minutes): the operator may have stopped Mobius or revoked the agent,
|
|
183
|
+
// and the screen may have moved. Re-check, and confirm what the finger will hit is still what was approved.
|
|
184
|
+
this.checkPlane(guard.ctx.mutating);
|
|
185
|
+
if (guard.targetId || guard.point) {
|
|
141
186
|
await this.observe();
|
|
142
|
-
target = this.store.resolve(guard.targetId);
|
|
187
|
+
target = guard.targetId ? this.store.resolve(guard.targetId) : this.elementAt(guard.point.x, guard.point.y);
|
|
143
188
|
if (guard.describe(target) !== req.summary) {
|
|
144
189
|
throw new MobiusError('STALE_ELEMENT', 'Screen changed after approval; the approved target is no longer the same', { retryable: true, hint: 'Observe again; a new approval will be requested.' });
|
|
145
190
|
}
|
|
@@ -149,7 +194,7 @@ export class DeviceSession {
|
|
|
149
194
|
/** Serialize work per device: one action at a time, no interleaved taps. */
|
|
150
195
|
exclusive(fn) {
|
|
151
196
|
const next = this.queue.then(fn, fn);
|
|
152
|
-
this.queue = next.catch(() => undefined);
|
|
197
|
+
this.queue = next.catch(() => undefined).then(() => this.inflight);
|
|
153
198
|
return next;
|
|
154
199
|
}
|
|
155
200
|
async observe() {
|
|
@@ -159,13 +204,18 @@ export class DeviceSession {
|
|
|
159
204
|
this.store.add(obs, fps);
|
|
160
205
|
return obs;
|
|
161
206
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Re-observe until two consecutive screens hash the same (UI settled), bounded. State-based, no fixed sleeps.
|
|
209
|
+
* Two identical frames of the *pre-action* screen only count after a grace window: a tap that launches an
|
|
210
|
+
* activity takes a beat before the window changes, and settling early reports the old screen as the result.
|
|
211
|
+
*/
|
|
212
|
+
async settle(deadline, preHash = null) {
|
|
213
|
+
const graceEnd = Date.now() + SETTLE_GRACE_MS;
|
|
164
214
|
let prev = await this.observe();
|
|
165
215
|
while (Date.now() < deadline) {
|
|
166
216
|
await new Promise(r => setTimeout(r, POLL_MS));
|
|
167
217
|
const next = await this.observe();
|
|
168
|
-
if (next.screen_hash === prev.screen_hash)
|
|
218
|
+
if (next.screen_hash === prev.screen_hash && (next.screen_hash !== preHash || Date.now() >= graceEnd))
|
|
169
219
|
return next;
|
|
170
220
|
prev = next;
|
|
171
221
|
}
|
|
@@ -199,7 +249,7 @@ export class DeviceSession {
|
|
|
199
249
|
const el = this.store.resolve(element_id);
|
|
200
250
|
if (!el.enabled)
|
|
201
251
|
throw new MobiusError('ELEMENT_NOT_FOUND', `${element_id} is disabled`, { hint: 'Wait for it to enable or pick another element.' });
|
|
202
|
-
return { ...
|
|
252
|
+
return { ...this.pointOf(el), el };
|
|
203
253
|
}
|
|
204
254
|
if (x !== undefined && y !== undefined) {
|
|
205
255
|
const s = this.store.latest()?.screen;
|
|
@@ -232,10 +282,14 @@ export class DeviceSession {
|
|
|
232
282
|
if (opts.guard?.ctx.mutating)
|
|
233
283
|
this.plane.actions++;
|
|
234
284
|
preHash = this.store.latest()?.screen_hash ?? preHash;
|
|
235
|
-
const
|
|
285
|
+
const work = exec(target);
|
|
286
|
+
this.inflight = work.catch(() => undefined);
|
|
287
|
+
const message = await withTimeout(work, opts.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS, tool);
|
|
236
288
|
const verifying = !!opts.expect || !!opts.check;
|
|
237
289
|
const deadline = Date.now() + (verifying ? opts.verifyMs ?? DEFAULT_EXPECT_TIMEOUT_MS : 2_500);
|
|
238
|
-
|
|
290
|
+
// A read changes nothing: no need to wait for the screen to settle (that costs the settle grace window).
|
|
291
|
+
const read = opts.guard && !opts.guard.ctx.mutating && !verifying;
|
|
292
|
+
let obs = read ? this.store.latest() ?? await this.observe() : await this.settle(Math.min(deadline, Date.now() + 2_500), preHash);
|
|
239
293
|
let verified = null;
|
|
240
294
|
let why = '';
|
|
241
295
|
const evaluate = (o) => {
|
|
@@ -296,7 +350,10 @@ export class DeviceSession {
|
|
|
296
350
|
});
|
|
297
351
|
}
|
|
298
352
|
catch (e) {
|
|
299
|
-
|
|
353
|
+
// Fail closed: this action already ran, so report it, but nothing more happens without an audit trail.
|
|
354
|
+
this.auditBroken = String(e).slice(0, 200);
|
|
355
|
+
process.stderr.write(JSON.stringify({ level: 'error', msg: 'audit write failed; refusing further actions', err: String(e) }) + '\n');
|
|
356
|
+
result = { ...result, message: `${result.message} (warning: the audit write failed, so Mobius refuses further actions)` };
|
|
300
357
|
}
|
|
301
358
|
return result;
|
|
302
359
|
});
|
package/dist/interface/http.js
CHANGED
|
@@ -36,7 +36,8 @@ const send = (res, code, body, type = 'application/json') => {
|
|
|
36
36
|
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
|
|
37
37
|
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
38
38
|
};
|
|
39
|
-
|
|
39
|
+
// Compare bytes, not characters: equal-length strings with different UTF-8 sizes would make timingSafeEqual throw.
|
|
40
|
+
const sameToken = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
|
|
40
41
|
export function createHttpServer(engine, audit, token, sessionKey) {
|
|
41
42
|
if (!token || token.length < 16)
|
|
42
43
|
throw new Error('MOBIUS_API_TOKEN must be at least 16 characters');
|