Skip to content

feat: expose Limrun device session capabilities - #1485

Merged
thymikee merged 5 commits into
mainfrom
agent/limrun-runtime-parity
Jul 29, 2026
Merged

feat: expose Limrun device session capabilities#1485
thymikee merged 5 commits into
mainfrom
agent/limrun-runtime-parity

Conversation

@thymikee

@thymikee thymikee commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

  • expose a client-private Limrun device-session facade for reusable app, input, log, recording, install, and interactor capabilities
  • expose the existing Android ADB provider and reversible port forwarding plus a typed iOS simctl seam
  • preserve verified iOS remote-install behavior with bounded inventory retries, while keeping raw Limrun clients private
  • keep focused mirrored facade tests, including delayed iOS inventory consistency

Validation

  • pnpm check:affected --base origin/main --run
  • pnpm exec vitest run src/providers/limrun/device-session.test.ts src/__tests__/limrun-runtime.test.ts
  • pnpm exec vitest run --project unit-core --maxWorkers 1 scripts/__tests__/help-conformance-bench.test.ts src/screenshot-diff/__tests__/screenshot-diff.test.ts src/platforms/apple/core/__tests__/runner-client.test.ts
  • local agent-device-cloud package: pnpm --filter @agent-device-cloud/bridge check
  • local agent-device-cloud package: pnpm --filter @agent-device-cloud/bridge test -- test/limrun-session.test.ts
  • live Limrun Android E2E through agent-device-cloud: app inventory, Settings launch, snapshot, screenshot, app/keyboard state, key input, bounded logs, recording, port reverse, and individual reverse removal; session terminated after the run
  • live Limrun iOS E2E through the public facade: fresh lease, inventory changed from 1 to 2 after remotely installing dev.agentdevice.LimrunParityE2E, both facade inventory and runSimctl(["listapps", "booted"]) verified the install, recording produced 27,380 bytes, and no raw client was exposed; lease and temporary uploaded asset were cleaned up

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-29 15:34 UTC

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.87 MB 1.88 MB +3.3 kB
JS gzip 602.8 kB 604.0 kB +1.2 kB
npm tarball 720.5 kB 722.3 kB +1.8 kB
npm unpacked 2.52 MB 2.53 MB +7.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.1 ms 27.4 ms -0.6 ms
CLI --help 58.3 ms 57.7 ms -0.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js 0 B +2 B

@thymikee

Copy link
Copy Markdown
Member Author

Review at e3ac354: the facade architecture and production export route look sound, and exact-head CI is green, but this is not ship-ready yet.

  • P2 — test topology/size. This adds 217 lines to src/__tests__/limrun-runtime.test.ts, taking an already oversized 719-line aggregation to 936 lines. Tests are not exempt from the >500 LOC extract-before-add rule, and the new src/providers/limrun/device-session.ts concept should have a focused mirrored test module instead of making the runtime catch-all larger.
  • Required device evidence — iOS. The live validation covers Android only, while this PR also exposes iOS inventory/log/recording/simctl capabilities and changes the existing iOS install path to require inventory verification within the new retry window. The unit mock returns the installed app immediately, so it cannot prove provider eventual-consistency behavior. Add a real Limrun iOS run covering session acquisition, app inventory + remote install, runSimctl, and at least logs or recording, with cleanup noted in Validation.

No ready-for-human until the focused test extraction and iOS live evidence land.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed the ship-readiness requests in 876e9bf0:

  • extracted the facade coverage into the mirrored src/providers/limrun/device-session.test.ts; the runtime aggregation is back down from 936 to 735 lines
  • added an eventual-consistency test where iOS inventory remains stale twice before exposing the remotely installed app
  • completed a live iOS run through the public facade: fresh lease, inventory 1 -> 2, remote install of dev.agentdevice.LimrunParityE2E, successful runSimctl(["listapps", "booted"]) verification, a 27,380-byte recording, and confirmation that no raw client is exposed
  • released the lease and deleted the temporary uploaded asset in finally

I also checked the agent-device-cloud parity boundary. The public facade now covers the provider capabilities that are reusable there: inventory, interaction/viewport, key input, logs, recording, remote install, Android semantic ADB/keyboard/reverse removal, and iOS simctl. Tenant/run/lease authorization, Metro policy, artifact materialization, and the iOS runner protocol remain cloud-owned orchestration rather than Limrun provider API. iOS foreground-app/keyboard/clipboard queries and provider-unsound rotate/alert operations are intentionally not claimed as facade capabilities.

Local validation is in the updated PR body. The aggregate unit run reached 4,758/4,761 tests; the only failures were unrelated five-second load timeouts, and all three affected files then passed in isolation with one worker (97/97).

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 29, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review at 876e9bf0: clean and ready for human review.

Both prior blockers are resolved:

  • facade coverage now lives in focused mirrored src/providers/limrun/device-session.test.ts; the runtime aggregation is back down from 936 to 735 lines
  • the iOS post-install inventory retry has delayed-consistency coverage, and Validation records a real Limrun iOS run through the public facade: inventory 1 -> 2, remote install, runSimctl, a non-empty recording, raw-client non-exposure, and cleanup

The production export route remains sound, focused tests pass, and exact-head CI is green. No code findings remain.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep code-quality review at 876e9bf0, focused on structure, abstraction quality, and boundary cleanliness. (Would be a Request Changes, but GitHub doesn't allow that on one's own PR.) Behavior looks right and the validation story (live Android + iOS runs, mirrored tests) is genuinely strong — this is about the shape of the facade, not whether it works.

The central finding

LimrunDeviceSessionBase is over-unified, and that one modeling choice generates most of the incidental complexity in the new file. Four separate smells trace back to it:

  1. iOS getForegroundApp is a stub that always resolves undefined, conflating "unsupported" with "no foreground app" — inconsistent with this provider's own convention of throwing UNSUPPORTED_OPERATION.
  2. readLogs(appId: string | undefined, lineLimit) — Android ignores appId; iOS requires it and enforces that with a runtime throw for what could be a compile-time signature difference.
  3. Android installRemoteApp silently drops md5 and relaunch, and returns the caller's own appIdentifierHint echoed back as a "result", while the iOS counterpart returns a genuinely verified bundle id. Same method name, very different guarantees.
  4. removePortReverse is a pass-through wrapper over adb.reverse.remove (already exposed on the same object), with a silent ?. no-op guarding an invariant that always holds for Limrun sessions.

The fix is a restructuring, not a rewrite: the discriminated union already exists, and Android-only capabilities already live on the Android variant. Shrink the base to what is genuinely symmetric and move getForegroundApp, readLogs, and installRemoteApp onto the platform variants with honest per-platform signatures. The stub, the runtime throw, the ignored parameter, and the dropped options all disappear, and no call site gets harder — consumers must narrow on platform to reach adb/runSimctl anyway.

Secondary findings (inline)

  • Canonical-helper duplication introduced by this PR: isUserInstalledIosApp (verbatim copy of ios.ts:341) and tcpEndpoint (verbatim copy of android.ts:209), plus a third, subtly different unsupported() in the same directory. The isUserInstalledIosApp copy is the risky one — inventory filtering and install verification can silently diverge.
  • runSimctl's as LimrunIosCommandExecution cast couples a ~25-line hand-rolled mirror of the SDK's execution type to reality by assertion only. Make the mirror checked (typed assignment or an assignability assertion) so SDK drift fails the build.
  • listApps defaults to 'all' while the contract module the AppsFilter type comes from defines DEFAULT_APPS_FILTER = 'user-installed' and provides resolveAppsFilter() — the facade quietly disagrees with the canonical default.
  • stopRecording discards the client's return value — question inline about whether that's deliberate data loss.
  • Minor, no inline comment: Awaited<ReturnType<LimrunIosSession['client']['listApps']>> is spelled out four times in ios.ts — a named LimrunIosApp alias would help legibility.

What's good

  • The layering goal is right and achieved: raw Limrun clients stay private, the runtime's getDeviceSession mirrors getInteractor, and the installLimrunIosApp refactor to route through installLimrunIosRemoteApp with bounded inventory verification is a real improvement over trusting result.bundleId.
  • Tests live in a focused mirrored module, no file approaches the 1k-line boundary, and the eventual-consistency test is exactly the coverage that path needed.

I'd hold merge on the base-type restructuring and the helper duplication; the rest are smaller but worth doing while the file is fresh.


Generated by Claude Code

Comment on lines +63 to +78
type LimrunDeviceSessionBase = {
readonly platform: 'android' | 'ios';
readonly device: DeviceInfo;
readonly interactor: Interactor;
readonly viewport?: { width: number; height: number };
listApps(filter?: AppsFilter): Promise<LimrunInstalledApp[]>;
getForegroundApp(): Promise<LimrunForegroundApp | undefined>;
pressKey(key: string, modifiers?: string[]): Promise<void>;
readLogs(appId: string | undefined, lineLimit: number): Promise<string>;
startRecording(options?: { quality?: LimrunRecordingQuality }): Promise<void>;
stopRecording(options: { outPath: string }): Promise<void>;
installRemoteApp(
url: string,
options?: LimrunRemoteInstallOptions,
): Promise<LimrunRemoteInstallResult>;
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Structural: LimrunDeviceSessionBase is too wide, and it's the root cause of most of the special cases in this file.

You already have a discriminated union (LimrunAndroidDeviceSession | LimrunIosDeviceSession), and Android-only capabilities (keyboard, adb, removePortReverse) already live on the Android variant. But the base type forces false symmetry for everything else, and each forced-symmetric member generates a smell downstream:

  • getForegroundApp() — iOS has no implementation, so it's a stub that always resolves undefined (line 176), making "unsupported" indistinguishable from "no foreground app". Everywhere else in this provider, unsupported operations throw AppError('UNSUPPORTED_OPERATION').
  • readLogs(appId: string | undefined, lineLimit) — Android ignores appId entirely; iOS requires it and throws at runtime for what could be a compile-time contract (readLogs(lineLimit) on Android vs readLogs(appId, lineLimit) on iOS).
  • installRemoteApp(url, LimrunRemoteInstallOptions) — Android silently drops md5 and relaunch (see separate comment).

i think there's a code-judo move here that makes this much simpler: shrink the base to what is genuinely symmetric (platform, device, interactor, listApps, pressKey, recording), and move getForegroundApp, readLogs, and installRemoteApp onto the platform variants with honest per-platform signatures. That deletes the iOS stub, the runtime throw, the ignored parameter, and the silently-dropped options in one restructuring — consumers of the union already have to narrow on platform to use adb/runSimctl anyway, so nothing gets harder to call.


Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
Comment on lines +148 to +151
installRemoteApp: async (url, options) => {
await session.client.sendAsset(url);
return { appId: options?.appIdentifierHint };
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract problem: Android installRemoteApp silently ignores its options and returns unverified data.

Two issues here:

  1. LimrunRemoteInstallOptions promises md5 and relaunch on both platforms, but this implementation drops both on the floor. relaunch is implementable — installLimrunAndroidApp in android.ts already does the am force-stop dance for exactly this. Either implement it or narrow the Android option type so the contract doesn't lie.
  2. { appId: options?.appIdentifierHint } echoes the caller's own hint back as the result. On iOS, appId means "verified present in post-install inventory, with bounded retries"; on Android it means "whatever you told me". Same method name, wildly different guarantees — a consumer can't tell which one they got without checking platform. If Android can't verify, the result type for Android shouldn't pretend to carry an appId at all (which the per-platform split suggested on the base type would make natural).

Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
Comment on lines +152 to +154
removePortReverse: async (devicePort) => {
await session.adbProvider.reverse?.remove(tcpEndpoint(devicePort));
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This abstraction seems unnecessary — it's a pass-through over a capability the same object already exposes.

The facade exposes the full AndroidAdbProvider as session.adb, so a consumer can already write session.adb.reverse?.remove('tcp:8081') — with owner-aware tracking via the reverse manager. removePortReverse adds nothing except:

  • a silent no-op when reverse is absent (?.) — for Limrun sessions reverse is always assigned in createLimrunAndroidSession, so the optional chain papers over a non-invariant instead of asserting it;
  • a re-validation of the port via a duplicated tcpEndpoint (see comment below).

Suggest deleting this method and letting adb.reverse be the one canonical port-reverse surface, or — if you want removal to be a first-class facade capability — making it throw when reverse is missing rather than pretending success.


Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
Comment on lines +204 to +216
function isUserInstalledIosApp(app: { bundleId: string; installType: string }): boolean {
return (
!app.bundleId.startsWith('com.apple.') && !app.installType.toLowerCase().includes('system')
);
}

function tcpEndpoint(port: number): `tcp:${number}` {
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse port: ${port}`);
}
return `tcp:${port}`;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verbatim helper duplication introduced by this same PR.

  • isUserInstalledIosApp is character-for-character identical to ios.ts:341 (also added in this PR). If the "what counts as a user app" heuristic ever changes, listApps('user-installed') and install verification will silently disagree.
  • tcpEndpoint (including the AppError validation) is a copy of android.ts:209.
  • This file also adds a third unsupported() variant in the same directory (ios.ts and runtime.ts each have one), and this one returns the error while the siblings throw — subtle divergence in a copy-pasted name.

Export the canonical helpers from ios.ts/android.ts (or a shared module in this provider dir) and reuse them.


Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
...createRecordingOperations(session.client),
installRemoteApp: async (url, options) =>
await installLimrunIosRemoteApp(session, url, options),
runSimctl: (args) => session.client.simctl(args) as LimrunIosCommandExecution,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need a cast? LimrunIosCommandExecution is ~25 lines of hand-rolled event-emitter overloads mirroring the @limrun/api client's simctl return type, coupled to it only by this as. If the SDK's execution type drifts (event renamed, wait() result reshaped), this compiles fine and consumers break at runtime.

I get the motivation — not leaking @limrun/api types into the public d.ts — but then make the mirror checked: if session.client.simctl(args) is assignable to LimrunIosCommandExecution, a plain typed return needs no cast; if it isn't assignable without the cast, the exported type is already wrong today. Either drop the as, or add a compile-time assignability assertion (e.g. satisfies-style check against ReturnType<LimrunIosClient['simctl']>) next to the type so drift fails the build instead of the bridge.


Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
device: session.device,
interactor: createLimrunAndroidInteractor(session),
adb: session.adbProvider,
listApps: async (filter = 'all') => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boundary inconsistency: both listApps implementations default to 'all', but the contract module this AppsFilter type is imported from defines DEFAULT_APPS_FILTER = 'user-installed' and ships resolveAppsFilter() for exactly this. A bridge calling session.listApps() and the CLI apps command will disagree on what the default inventory means. Use resolveAppsFilter(filter) here (both platforms), or document why the facade deliberately diverges from the canonical default.


Generated by Claude Code

Comment thread src/providers/limrun/device-session.ts Outdated
Comment on lines +195 to +200
startRecording: async (options?: { quality?: LimrunRecordingQuality }) => {
await client.startRecording(options);
},
stopRecording: async ({ outPath }: { outPath: string }) => {
await client.stopRecording({ localPath: outPath });
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: the underlying client's stopRecording returns a value (Promise<string> per LimrunRecordingClient and the tests, which stub it with a URL), and the facade discards it and returns void. Is that reference (remote recording URL?) genuinely useless to bridges, or is the facade throwing away data a consumer would otherwise have to re-derive? If it's intentional, worth a one-line note on LimrunRecordingClient; if not, surface it in the result.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head code review is clean. The discriminated Limrun device-session facade exposes honest platform-specific capabilities without leaking raw clients; app filtering uses the canonical rule; iOS remote installation is verified against eventually consistent inventory; and focused tests plus live Android/iOS evidence exercise the public facade and cleanup. Completed static, integration, coverage, Fallow, and non-iOS smoke checks are green. The iOS smoke is still in progress, not failing, so the existing ready-for-human label remains appropriate under the coordinator policy.

@thymikee
thymikee merged commit 1cfd391 into main Jul 29, 2026
32 checks passed
@thymikee
thymikee deleted the agent/limrun-runtime-parity branch July 29, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant