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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ export default defineConfig({
],
},
},
{
files: ['src/commands/**/*.ts', 'src/cli/commands/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'node:child_process',
message:
'Use process helpers from @agent-device/host-kit/command instead of importing node:child_process directly.',
},
],
patterns: [
{
group: ['@agent-device/provider-*'],
message:
'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.',
},
],
},
],
},
},
{
files: [
'packages/host-kit/src/internal/exec.ts',
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/device-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export type DeviceLease = {

export type LeaseLifecycleContext = {
flags?: Readonly<Record<string, unknown>>;
initialApp?: string;
cwd?: string;
publicNetworkOnly?: boolean;
/** Request-bound cancellation (explicit cancel or client disconnect). */
signal?: AbortSignal;
/**
Expand Down Expand Up @@ -66,3 +68,19 @@ export type ProviderDeviceInventorySource = Readonly<{
signal: AbortSignal,
): Promise<ProviderDeviceInventoryOutcome>;
}>;

export type ProviderAppCatalogQuery = Readonly<{
provider: string;
platform: 'android' | 'ios';
publicNetworkOnly?: boolean;
}>;

export type ProviderAppCatalogHandler = (
query: ProviderAppCatalogQuery,
signal?: AbortSignal,
) => Promise<readonly string[]>;

export type ProviderAppCatalog = Readonly<{
supports(provider: string): boolean;
list: ProviderAppCatalogHandler;
}>;
3 changes: 3 additions & 0 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type {
DeviceLease,
LeaseLifecycleContext,
LeaseLifecycleProvider,
ProviderAppCatalog,
ProviderAppCatalogHandler,
ProviderAppCatalogQuery,
ProviderDeviceInventoryOutcome,
ProviderDeviceInventorySource,
} from '../device-provider.ts';
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/provider-device-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
DeviceInventoryProvider,
DeviceLease,
LeaseLifecycleProvider,
ProviderAppCatalogHandler,
} from './device-provider.ts';
import type { Interactor, RunnerContext } from './interactor-types.ts';

Expand Down Expand Up @@ -36,6 +37,7 @@ export type ProviderDeviceRuntime = {
leaseLifecycle: LeaseLifecycleProvider;
recoverExpiredLease?: ProviderExpiredLeaseRecovery;
cloudArtifacts?: CloudArtifactProvider;
appCatalog?: ProviderAppCatalogHandler;
deviceInventoryProvider: DeviceInventoryProvider;
ownsDevice(device: DeviceInfo): boolean;
getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined;
Expand Down
80 changes: 80 additions & 0 deletions packages/provider-limrun/src/app-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test, vi } from 'vitest';
import {
assertLimrunUploadedAppAccess,
listLimrunAppAssets,
resolveInstalledAppIdForAsset,
resolveLimrunAppAsset,
} from './app-catalog.ts';

describe('Limrun uploaded app catalog', () => {
test('rejects uploaded app access on the public daemon HTTP surface', () => {
expect(() => assertLimrunUploadedAppAccess(true)).toThrow(/public daemon HTTP surface/);
expect(() => assertLimrunUploadedAppAccess(false)).not.toThrow();
});

test('lists only uploaded assets compatible with the requested platform', async () => {
const list = vi.fn(async () => [
{ id: 'android-explicit', name: 'build.bin', os: 'android', md5: 'a' },
{ id: 'android-apk', name: 'com.example.app.apk', md5: 'b' },
{ id: 'ios-zip', name: 'Example.app.zip', md5: 'c' },
{ id: 'pending', name: 'pending.apk' },
{ id: 'unknown', name: 'notes.txt', md5: 'd' },
]);
const limrun = { assets: { list } } as never;

await expect(listLimrunAppAssets(limrun, 'android')).resolves.toEqual([
{ id: 'android-explicit', name: 'build.bin' },
{ id: 'android-apk', name: 'com.example.app.apk' },
]);
await expect(listLimrunAppAssets(limrun, 'ios')).resolves.toEqual([
{ id: 'ios-zip', name: 'Example.app.zip' },
]);
});

test('resolves an exact uploaded asset name and rejects platform mismatches', async () => {
const list = vi
.fn()
.mockResolvedValueOnce([
{ id: 'similar-1', name: 'Example.app.zip.backup.zip', md5: 'z' },
{ id: 'similar-2', name: 'Example.app.zip.previous.zip', md5: 'y' },
{ id: 'ios-app', name: 'Example.app.zip', md5: 'a' },
])
.mockResolvedValueOnce([{ id: 'android-app', name: 'Example.apk', md5: 'b' }]);
const limrun = { assets: { list } } as never;

await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.app.zip')).resolves.toEqual({
id: 'ios-app',
name: 'Example.app.zip',
});
await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.apk')).resolves.toBeUndefined();
expect(list).toHaveBeenNthCalledWith(
1,
{ limit: 1_000, nameFilter: 'Example.app.zip' },
{ signal: undefined },
);
});

test('matches an uploaded iOS asset when the instance also contains Expo Go', () => {
expect(
resolveInstalledAppIdForAsset('easagentdevice.app.zip', [
{ id: 'dev.expo.easagentdevice', name: 'Agent Device' },
{ id: 'host.exp.Exponent', name: 'Expo Go' },
]),
).toBe('dev.expo.easagentdevice');
expect(
resolveInstalledAppIdForAsset('unrelated-build.zip', [
{ id: 'com.example.first' },
{ id: 'com.example.second' },
]),
).toBeUndefined();
});

test('rejects colliding exact installed identities', () => {
expect(
resolveInstalledAppIdForAsset('example.app.zip', [
{ id: 'com.first', name: 'Example' },
{ id: 'com.second.example', name: 'Second' },
]),
).toBeUndefined();
});
});
110 changes: 110 additions & 0 deletions packages/provider-limrun/src/app-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type Limrun from '@limrun/api';
import type { Asset } from '@limrun/api/resources/assets';
import { AppError } from '@agent-device/kernel/errors';

const APP_CATALOG_LIMIT = 1_000;

export type LimrunAppAsset = Readonly<{
id: string;
name: string;
}>;

type InstalledAppIdentity = Readonly<{ id: string; name?: string }>;

export function assertLimrunUploadedAppAccess(publicNetworkOnly: boolean | undefined): void {
if (!publicNetworkOnly) return;
throw new AppError(
'UNAUTHORIZED',
'Limrun uploaded apps are unavailable on the public daemon HTTP surface.',
);
}

export async function listLimrunAppAssets(
limrun: Limrun,
platform: 'android' | 'ios',
signal?: AbortSignal,
): Promise<readonly LimrunAppAsset[]> {
signal?.throwIfAborted();
const assets = await limrun.assets.list({ limit: APP_CATALOG_LIMIT }, { signal });
signal?.throwIfAborted();
const apps: LimrunAppAsset[] = [];
for (const asset of assets) {
const app = toAvailableAppAsset(asset, platform);
if (app) apps.push(app);
}
return apps.sort((left, right) => left.name.localeCompare(right.name));
}

export async function resolveLimrunAppAsset(
limrun: Limrun,
platform: 'android' | 'ios',
name: string,
signal?: AbortSignal,
): Promise<LimrunAppAsset | undefined> {
signal?.throwIfAborted();
const assets = await limrun.assets.list(
{ limit: APP_CATALOG_LIMIT, nameFilter: name },
{ signal },
);
signal?.throwIfAborted();
const matches = assets
.filter((asset) => asset.name === name)
.map((asset) => toAvailableAppAsset(asset, platform))
.filter((asset): asset is LimrunAppAsset => asset !== undefined);
if (matches.length <= 1) return matches[0];
throw new AppError('COMMAND_FAILED', `Limrun returned multiple uploaded apps named ${name}.`, {
app: name,
platform,
assetIds: matches.map((asset) => asset.id),
});
}

export function resolveInstalledAppIdForAsset(
assetName: string,
apps: readonly InstalledAppIdentity[],
): string | undefined {
const assetKey = appIdentityKey(
assetName.replace(/\.(?:tar\.gz|tgz|tar|zip|ipa|apk)$/i, '').replace(/\.app$/i, ''),
);
if (assetKey.length < 5) return undefined;
const candidates = apps.map((app) => ({ app, keys: appIdentityValues(app) }));
const exact = candidates.filter(({ keys }) => keys.includes(assetKey));
return exact.length === 1 ? exact[0]?.app.id : undefined;
}

function toAvailableAppAsset(
asset: Asset,
requestedPlatform: 'android' | 'ios',
): LimrunAppAsset | undefined {
if (!asset.md5) return undefined;
const platform = resolveAssetPlatform(asset);
if (platform !== requestedPlatform) return undefined;
return { id: asset.id, name: asset.name };
}

function resolveAssetPlatform(asset: Asset): 'android' | 'ios' | undefined {
if (asset.os === 'android' || asset.os === 'ios') return asset.os;
const name = asset.name.toLowerCase();
if (name.endsWith('.apk')) return 'android';
if (
name.endsWith('.ipa') ||
name.endsWith('.zip') ||
name.endsWith('.tar') ||
name.endsWith('.tar.gz') ||
name.endsWith('.tgz')
) {
return 'ios';
}
return undefined;
}

function appIdentityValues(app: InstalledAppIdentity): string[] {
const terminalId = app.id.split(/[.:/]/).at(-1);
return [app.id, terminalId, app.name]
.filter((value): value is string => typeof value === 'string')
.map(appIdentityKey);
}

function appIdentityKey(value: string): string {
return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, '');
}
2 changes: 2 additions & 0 deletions packages/provider-limrun/src/app-log-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit<
runtimeInstance: string;
ownsDevice(device: DeviceInfo): boolean;
getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined;
resolveAppReference?(device: DeviceInfo, app: string): string;
openCurrent(device: DeviceInfo): Promise<LimrunAppLogReader | undefined>;
hasLiveSession(device: DeviceInfo): boolean;
reconnect(
Expand Down Expand Up @@ -280,6 +281,7 @@ function bindLimrunAppLogs(
device,
signal,
getInteractor: options.getInteractor,
resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app,
configurePortReverse: options.configurePortReverse,
}),
runtimeFacts.operations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test('Limrun verification reads the selected instance service without creating a
},
app: {
status: 'missing',
message: 'A new Limrun instance does not have your app yet.',
message: 'Run apps to choose an uploaded asset before allocation.',
},
});
assert.deepEqual(mockState.androidList.mock.calls, [[{ limit: 1 }]]);
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-limrun/src/connection-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function verifyLimrunConnection(
},
app: {
status: 'missing',
message: 'A new Limrun instance does not have your app yet.',
message: 'Run apps to choose an uploaded asset before allocation.',
},
};
}
Expand Down
17 changes: 14 additions & 3 deletions packages/provider-limrun/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte
localInteractors: { resolve: localInteractor },
},
getInteractor: () => ({ close: providerClose, open: providerOpen }) as unknown as Interactor,
resolveAppReference: (_device, app) => (app === 'Example.app.zip' ? 'com.example.app' : app),
}),
);

Expand All @@ -270,10 +271,13 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte
scope,
});

await expect(
binding.operations.resolveOpenTarget?.({ target: 'Example.app.zip', surface: 'app' }),
).resolves.toEqual({ appBundleId: 'com.example.app', appName: 'com.example.app' });
await binding.operations.openApplication?.({
target: 'com.example.app',
positionals: ['com.example.app'],
appBundleId: 'com.example.app',
target: 'Example.app.zip',
positionals: ['Example.app.zip'],
appBundleId: 'Example.app.zip',
surface: 'app',
hasExistingSession: true,
relaunch: true,
Expand All @@ -288,6 +292,13 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte
'com.example.app',
expect.objectContaining({ appBundleId: 'com.example.app' }),
);
await binding.operations.closeApplication?.({
positionals: ['Example.app.zip'],
appBundleId: 'Example.app.zip',
surface: 'app',
execution: {},
});
expect(providerClose).toHaveBeenLastCalledWith('com.example.app');
expect(localInteractor).not.toHaveBeenCalled();
});

Expand Down
Loading
Loading