@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 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 not built: run \`cargo build --release\` in drivers/mobiusagent-driver (expected ${driverPath()})`);
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
- const p = spawn(driverPath(), [], { stdio: ['pipe', 'pipe', 'pipe'] });
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('TIMEOUT', `mobiusagent-driver ${op} timed out`, { retryable: true }));
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
- throw new MobiusError('DEVICE_LOCKED', `Could not read the screen: ${e.message}`, { retryable: true, hint: 'Ask the user to wake and unlock the phone.' });
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' && (key === 'back' || key === 'recents')))
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
- throw new MobiusError('APP_NOT_INSTALLED', e.message, { hint: 'Call list_apps to see installed apps.' });
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
- // Long-running ops get their own budget: sessions (`seconds`), routes, installs, first OCR build.
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
- const b = n.bounds;
70
- 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) }
71
- : { x1: b.x, y1: b.y, x2: b.x + b.width, y2: b.y + b.height };
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
- if (this.history.length > 5)
156
- this.history.shift();
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
- export declare const center: (e: Element, screen?: readonly Element[]) => {
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
- /** Re-observe until two consecutive screens hash the same (UI settled), bounded. State-based, no fixed sleeps. */
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
- /** Where to touch an element: the center of its on-screen part (an element partly above/left of the screen has negative
18
- * bounds; its raw center can be off-screen). Given the screen, a point not covered by a smaller clickable element inside
19
- * it: tapping a big card's center must not hit the item drawn there (live run: Swiggy's restaurant card opened a burger). */
20
- export const center = (e, screen) => {
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.max(e.bounds.x + e.bounds.width, x1), y2 = Math.max(e.bounds.y + e.bounds.height, y1);
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 inside = (o) => o.bounds.x >= e.bounds.x && o.bounds.y >= e.bounds.y && o.bounds.x + o.bounds.width <= e.bounds.x + e.bounds.width
27
- && o.bounds.y + o.bounds.height <= e.bounds.y + e.bounds.height && o.bounds.width * o.bounds.height < e.bounds.width * e.bounds.height;
28
- const kids = screen.filter(o => o !== e && o.id !== e.id && (o.clickable || o.editable) && inside(o));
29
- if (!kids.length)
30
- return mid;
31
- const hit = (p) => kids.some(k => p.x >= k.bounds.x && p.x <= k.bounds.x + k.bounds.width && p.y >= k.bounds.y && p.y <= k.bounds.y + k.bounds.height);
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 (!hit(p))
52
+ if (!blocked(p))
36
53
  return p;
37
54
  }
38
- return mid; // fully covered: the children are the target in practice
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
- if (guard.ctx.mutating && (guard.targetId || guard.point))
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 ? center(target, latest?.elements) : null);
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: confirm the approved target is still the one on screen.
140
- if (guard.targetId) {
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
- /** Re-observe until two consecutive screens hash the same (UI settled), bounded. State-based, no fixed sleeps. */
163
- async settle(deadline) {
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 { ...center(el, this.store.latest()?.elements), el };
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 message = await withTimeout(exec(target), opts.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS, tool);
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
- let obs = await this.settle(Math.min(deadline, Date.now() + 2_500));
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
- process.stderr.write(JSON.stringify({ level: 'error', msg: 'audit write failed', err: String(e) }) + '\n');
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
  });
@@ -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
- const sameToken = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
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');