From 24d0703372f4a04adeb44f9d7332cb7eca023f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 20:06:33 +0200 Subject: [PATCH 1/2] fix(sdk): scope public Android and Limrun command hosts Co-Authored-By: Claude Opus 5.5 --- packages/host-kit/src/internal/exec-types.ts | 51 +++++ packages/host-kit/src/internal/exec.ts | 73 ++----- .../platform-android/src/adb-port-reverse.ts | 18 ++ packages/platform-android/src/mechanics.ts | 2 + src/provider-limrun-runtime.ts | 7 +- src/sdk/android-adb.test.ts | 25 +++ src/sdk/android-adb.ts | 124 +++++++----- src/sdk/artifacts.test.ts | 58 ++++++ src/sdk/artifacts.ts | 7 +- src/sdk/command-host.test.ts | 23 +++ src/sdk/command-host.ts | 11 ++ src/sdk/limrun-runtime-dependencies.test.ts | 33 ++++ src/sdk/limrun-runtime-dependencies.ts | 181 +++++++++++------- src/sdk/metro.ts | 4 +- src/sdk/remote-config.ts | 12 +- 15 files changed, 445 insertions(+), 184 deletions(-) create mode 100644 packages/host-kit/src/internal/exec-types.ts create mode 100644 src/sdk/artifacts.test.ts create mode 100644 src/sdk/command-host.test.ts create mode 100644 src/sdk/command-host.ts diff --git a/packages/host-kit/src/internal/exec-types.ts b/packages/host-kit/src/internal/exec-types.ts new file mode 100644 index 0000000000..38e95bfea9 --- /dev/null +++ b/packages/host-kit/src/internal/exec-types.ts @@ -0,0 +1,51 @@ +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; + +export type ExecResult = { + stdout: string; + stderr: string; + exitCode: number; + stdoutBuffer?: Buffer; +}; + +export type ExecOptions = { + cwd?: string; + env?: NodeJS.ProcessEnv; + allowFailure?: boolean; + binaryStdout?: boolean; + stdin?: string | Buffer; + timeoutMs?: number; + detached?: boolean; + signal?: AbortSignal; + /** Max stdout/stderr bytes for synchronous runs (default Node ~1MB). */ + maxBuffer?: number; + /** + * How a deadline or a cancelled request stops the child. Without this it is SIGKILL at + * once. A child that has to undo something before it dies — the macOS helper releasing a + * mouse button it is holding — is sent `signal` first, and SIGKILL only once `graceMs` + * pass without it exiting. + */ + kill?: { readonly signal: NodeJS.Signals; readonly graceMs: number }; +}; + +export type ExecDetachedExit = { + pid: number; + exitCode?: number; + signal?: NodeJS.Signals; + error?: string; +}; + +export type ExecDetachedProcess = { + pid: number; + exited: Promise; +}; + +export type CommandExecutorOverride = ( + cmd: string, + args: readonly string[], + options: ExecOptions, +) => Promise | undefined; + +export type CommandHostScope = Readonly<{ + host: HostEnvironment; + requestEnv: EnvMap | undefined; +}>; diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 71785d2c98..4e2f63292b 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -13,32 +13,21 @@ import { type HostEnvironment, } from '@agent-device/kernel/source-value'; -export type ExecResult = { - stdout: string; - stderr: string; - exitCode: number; - stdoutBuffer?: Buffer; -}; - -export type ExecOptions = { - cwd?: string; - env?: NodeJS.ProcessEnv; - allowFailure?: boolean; - binaryStdout?: boolean; - stdin?: string | Buffer; - timeoutMs?: number; - detached?: boolean; - signal?: AbortSignal; - /** Max stdout/stderr bytes for synchronous runs (default Node ~1MB). */ - maxBuffer?: number; - /** - * How a deadline or a cancelled request stops the child. Without this it is SIGKILL at - * once. A child that has to undo something before it dies — the macOS helper releasing a - * mouse button it is holding — is sent `signal` first, and SIGKILL only once `graceMs` - * pass without it exiting. - */ - kill?: { readonly signal: NodeJS.Signals; readonly graceMs: number }; -}; +import type { + CommandExecutorOverride, + CommandHostScope, + ExecResult, + ExecOptions, + ExecDetachedExit, + ExecDetachedProcess, +} from './exec-types.ts'; +export type { + CommandExecutorOverride, + ExecResult, + ExecOptions, + ExecDetachedExit, + ExecDetachedProcess, +} from './exec-types.ts'; export type ExecStreamOptions = ExecOptions & { /** Set false when chunk callbacks own output and only the exit code is needed. */ @@ -57,18 +46,6 @@ type ExecDetachedOptions = ExecOptions & { stdio?: StdioOptions; }; -export type ExecDetachedExit = { - pid: number; - exitCode?: number; - signal?: NodeJS.Signals; - error?: string; -}; - -export type ExecDetachedProcess = { - pid: number; - exited: Promise; -}; - /** * Background runs have no `timeoutMs`: the callers are long-lived sessions (the * Android snapshot helper, the keep-hot xcodebuild runner, app-log capture), and @@ -89,12 +66,6 @@ export type ExecBackgroundOptions = Omit & { const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/; const WINDOWS_PATH_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd']; const EXEC_DIAGNOSTIC_ARG_LIMIT = 6; -export type CommandExecutorOverride = ( - cmd: string, - args: readonly string[], - options: ExecOptions, -) => Promise | undefined; - const commandExecutorOverrideScope = new AsyncLocalStorage(); export async function withCommandExecutorOverride( @@ -110,11 +81,6 @@ export async function withoutCommandExecutorOverride(fn: () => Promise): P return await commandExecutorOverrideScope.run(undefined, fn); } -type CommandHostScope = Readonly<{ - host: HostEnvironment; - requestEnv: EnvMap | undefined; -}>; - const commandHostScope = new AsyncLocalStorage(); /** @@ -122,15 +88,14 @@ const commandHostScope = new AsyncLocalStorage(); * from `host.env`, then takes `requestEnv` on top, so neither the process's environment nor a * concurrent request's values reach it. Outside a scope a command without `env` inherits the process's. */ -export async function withCommandHost( - scope: CommandHostScope, - fn: () => Promise, -): Promise { +export function withCommandHost(scope: CommandHostScope, fn: () => Promise): Promise; +export function withCommandHost(scope: CommandHostScope, fn: () => T): T; +export function withCommandHost(scope: CommandHostScope, fn: () => T): T { // A nested scope of the same host that names no request env keeps the enclosing request's. const enclosing = commandHostScope.getStore(); const requestEnv = scope.requestEnv ?? (enclosing?.host === scope.host ? enclosing.requestEnv : undefined); - return await commandHostScope.run({ host: scope.host, requestEnv }, fn); + return commandHostScope.run({ host: scope.host, requestEnv }, fn); } /** diff --git a/packages/platform-android/src/adb-port-reverse.ts b/packages/platform-android/src/adb-port-reverse.ts index 23b9e26ef4..6015a90218 100644 --- a/packages/platform-android/src/adb-port-reverse.ts +++ b/packages/platform-android/src/adb-port-reverse.ts @@ -28,6 +28,24 @@ export type AndroidExecPortReverseOptions = Readonly<{ const ANDROID_PORT_REVERSE_REBIND_REFUSED_REASON = 'android_port_reverse_rebind_refused'; const managedAndroidPortReverseProviders = new WeakSet(); +const scopedAndroidPortReverseProviders = new WeakMap< + AndroidPortReverseProvider, + AndroidPortReverseProvider +>(); + +/** Keeps scoped managers canonical when another provider reuses them as its reverse capability. */ +export function scopeAndroidPortReverseManager( + manager: AndroidPortReverseProvider, + scope: (manager: AndroidPortReverseProvider) => AndroidPortReverseProvider, +): AndroidPortReverseProvider { + const existing = scopedAndroidPortReverseProviders.get(manager); + if (existing) return existing; + const scoped = scope(manager); + managedAndroidPortReverseProviders.add(scoped); + scopedAndroidPortReverseProviders.set(manager, scoped); + scopedAndroidPortReverseProviders.set(scoped, scoped); + return scoped; +} export function createAndroidPortReverseManager( provider: AndroidAdbProvider | AndroidAdbExecutor, diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index a175cb35e8..8dd6941516 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -364,3 +364,5 @@ export { type AndroidBlockingDialogObservation, type AndroidWindowDumpReader, } from './window-state.ts'; + +export { scopeAndroidPortReverseManager } from './adb-port-reverse.ts'; diff --git a/src/provider-limrun-runtime.ts b/src/provider-limrun-runtime.ts index 0aba2ca761..92a11d9ea2 100644 --- a/src/provider-limrun-runtime.ts +++ b/src/provider-limrun-runtime.ts @@ -1,4 +1,4 @@ -import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withSdkCommandHost } from './sdk/command-host.ts'; import type { DeviceInventoryProvider, LeaseLifecycleProvider, @@ -25,9 +25,8 @@ export class LimrunRuntime implements ProviderDeviceRuntime { readonly provider = LIMRUN_PROVIDER; constructor(options: LimrunRuntimeOptions) { - this.implementation = createLimrunRuntime( - options, - createLimrunRuntimeDependencies(captureHostEnvironment(process)), + this.implementation = withSdkCommandHost((host) => + createLimrunRuntime(options, createLimrunRuntimeDependencies(host)), ); } diff --git a/src/sdk/android-adb.test.ts b/src/sdk/android-adb.test.ts index 7472c2fcee..0576135231 100644 --- a/src/sdk/android-adb.test.ts +++ b/src/sdk/android-adb.test.ts @@ -65,3 +65,28 @@ test.skipIf(process.platform === 'win32')( } }, ); + +test('scoped SDK reverse managers remain canonical when reused by another provider', async () => { + const { createAndroidPortReverseManager } = await import('./android-adb.ts'); + const { commandHostEnvironment } = await import('@agent-device/host-kit/command'); + const calls: (readonly string[])[] = []; + const provider: import('@agent-device/platform-android/mechanics').AndroidAdbProvider = { + exec: async (args) => { + assert.ok(commandHostEnvironment().env); + calls.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + const first = createAndroidPortReverseManager(provider); + const second = createAndroidPortReverseManager({ ...provider, reverse: first }); + assert.equal(second, first); + const mapping = { local: 'tcp:8081', remote: 'tcp:8081' } as const; + await second.ensure(mapping); + await first.remove(mapping.local); + await second.ensure(mapping); + assert.deepEqual(calls, [ + ['reverse', 'tcp:8081', 'tcp:8081'], + ['reverse', '--remove', 'tcp:8081'], + ['reverse', 'tcp:8081', 'tcp:8081'], + ]); +}); diff --git a/src/sdk/android-adb.ts b/src/sdk/android-adb.ts index a8bccc07f9..1d0cd63b5f 100644 --- a/src/sdk/android-adb.ts +++ b/src/sdk/android-adb.ts @@ -1,56 +1,88 @@ +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; import '../platform-runtime-android-adb-host.ts'; -export { - createAndroidPortReverseManager, - type AndroidAdbExecutor, - type AndroidAdbExecutorOptions, - type AndroidAdbProvider, - type AndroidPortReverseEndpoint, -} from '@agent-device/platform-android/mechanics'; -export { listAndroidAppsWithAdb } from '@agent-device/platform-android/mechanics'; -export { runAdbExecOut, runAdbShell } from '@agent-device/platform-android/mechanics'; -import { - runAndroidExecOut as runAndroidExecOutOnHost, - runAndroidShell as runAndroidShellOnHost, -} from '@agent-device/platform-android/mechanics'; -import { captureHostEnvironment } from '@agent-device/host-kit/process'; -import { withCommandHost } from '@agent-device/host-kit/command'; - -export async function runAndroidShell(...args: Parameters) { - const host = captureHostEnvironment(process); - return await withCommandHost({ host, requestEnv: undefined }, () => - runAndroidShellOnHost(...args), - ); -} +import * as mechanics from '@agent-device/platform-android/mechanics'; +import { withSdkCommandHost } from './command-host.ts'; +import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; -export async function runAndroidExecOut(...args: Parameters) { - const host = captureHostEnvironment(process); - return await withCommandHost({ host, requestEnv: undefined }, () => - runAndroidExecOutOnHost(...args), - ); -} +export type { + AndroidAdbExecutor, + AndroidAdbExecutorOptions, + AndroidAdbProvider, + AndroidPortReverseEndpoint, + AndroidKeyboardDismissResult, + AndroidKeyboardState, +} from '@agent-device/platform-android/mechanics'; export type { ShellWord } from '@agent-device/kernel/device-shell'; -import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; -import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; +export const runAndroidShell: typeof mechanics.runAndroidShell = (...args) => + withSdkCommandHost(() => mechanics.runAndroidShell(...args)); + +export const runAndroidExecOut: typeof mechanics.runAndroidExecOut = (...args) => + withSdkCommandHost(() => mechanics.runAndroidExecOut(...args)); + +export const runAdbShell: typeof mechanics.runAdbShell = (...args) => + withSdkCommandHost(() => mechanics.runAdbShell(...args)); + +export const runAdbExecOut: typeof mechanics.runAdbExecOut = (...args) => + withSdkCommandHost(() => mechanics.runAdbExecOut(...args)); + +export const listAndroidAppsWithAdb: typeof mechanics.listAndroidAppsWithAdb = (...args) => + withSdkCommandHost(() => mechanics.listAndroidAppsWithAdb(...args)); + +export const forceStopAndroidAppWithAdb: typeof mechanics.forceStopAndroidAppWithAdb = (...args) => + withSdkCommandHost(() => mechanics.forceStopAndroidAppWithAdb(...args)); + +export const openAndroidAppWithAdb: typeof mechanics.openAndroidAppWithAdb = (...args) => + withSdkCommandHost(() => mechanics.openAndroidAppWithAdb(...args)); + +export const captureAndroidLogcatWithAdb: typeof mechanics.captureAndroidLogcatWithAdb = ( + ...args +) => withSdkCommandHost(() => mechanics.captureAndroidLogcatWithAdb(...args)); + +export const dismissAndroidKeyboardWithAdb: typeof mechanics.dismissAndroidKeyboardWithAdb = ( + ...args +) => withSdkCommandHost(() => mechanics.dismissAndroidKeyboardWithAdb(...args)); + +export const getAndroidKeyboardStatusWithAdb: typeof mechanics.getAndroidKeyboardStatusWithAdb = ( + ...args +) => withSdkCommandHost(() => mechanics.getAndroidKeyboardStatusWithAdb(...args)); + +export const readAndroidClipboardWithAdb: typeof mechanics.readAndroidClipboardWithAdb = ( + ...args +) => withSdkCommandHost(() => mechanics.readAndroidClipboardWithAdb(...args)); + +export const writeAndroidClipboardWithAdb: typeof mechanics.writeAndroidClipboardWithAdb = ( + ...args +) => withSdkCommandHost(() => mechanics.writeAndroidClipboardWithAdb(...args)); + +export function createAndroidPortReverseManager( + provider: mechanics.AndroidAdbProvider | AndroidAdbExecutor, +): ReturnType; +export function createAndroidPortReverseManager( + ...args: Parameters +): ReturnType; +export function createAndroidPortReverseManager( + provider: mechanics.AndroidAdbProvider | AndroidAdbExecutor, + options?: Parameters[1], +): ReturnType { + return withSdkCommandHost((host) => { + const manager = + options && typeof provider === 'function' + ? mechanics.createAndroidPortReverseManager(provider, options) + : mechanics.createAndroidPortReverseManager(provider); + return mechanics.scopeAndroidPortReverseManager(manager, (target) => + withMethodScope(target, (task) => withSdkCommandHost(task, host)), + ); + }); +} export async function getAndroidAppStateWithAdb( adb: AndroidAdbExecutor, signal?: AbortSignal, ): Promise { - const { getAndroidAppStateWithAdb: read } = await import('../platform-runtime.ts'); - return await read(adb, signal); + return await withSdkCommandHost(async () => { + const { getAndroidAppStateWithAdb: read } = await import('../platform-runtime.ts'); + return await read(adb, signal); + }); } - -export { - forceStopAndroidAppWithAdb, - openAndroidAppWithAdb, -} from '@agent-device/platform-android/mechanics'; -export { captureAndroidLogcatWithAdb } from '@agent-device/platform-android/mechanics'; -export { - dismissAndroidKeyboardWithAdb, - getAndroidKeyboardStatusWithAdb, - readAndroidClipboardWithAdb, - type AndroidKeyboardDismissResult, - type AndroidKeyboardState, - writeAndroidClipboardWithAdb, -} from '@agent-device/platform-android/mechanics'; diff --git a/src/sdk/artifacts.test.ts b/src/sdk/artifacts.test.ts new file mode 100644 index 0000000000..62a93bd8da --- /dev/null +++ b/src/sdk/artifacts.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function malformedArchiveWithAapt() { + const root = await mkdtempForTest('sdk-artifacts-aapt-'); + const buildTools = path.join(root, 'build-tools', '36.0.0'); + await mkdir(buildTools, { recursive: true }); + await writeFile( + path.join(buildTools, 'aapt'), + `#!${process.execPath}\nprocess.stdout.write("package: name='com.example.aapt' versionCode='1'\\n");\n`, + { mode: 0o755 }, + ); + await writeFile(path.join(root, 'AndroidManifest.xml'), 'unparseable'); + const archive = path.join(root, 'app.apk'); + execFileSync('zip', ['-q', archive, 'AndroidManifest.xml'], { cwd: root }); + vi.stubEnv('ANDROID_SDK_ROOT', root); + return { archive, root }; +} + +test.skipIf(process.platform === 'win32')( + 'standalone archive resolution falls back to aapt when the manifest cannot name a package', + async () => { + const { archive } = await malformedArchiveWithAapt(); + const { resolveAndroidArchivePackageName } = await import('./artifacts.ts'); + + assert.equal(await resolveAndroidArchivePackageName(archive), 'com.example.aapt'); + }, +); + +test.skipIf(process.platform === 'win32')( + 'an initial unscoped SDK call does not poison aapt lookup for later scoped mechanics calls', + async () => { + const { archive, root } = await malformedArchiveWithAapt(); + const { resolveAndroidArchivePackageName } = await import('./artifacts.ts'); + await resolveAndroidArchivePackageName(archive); + const { withCommandHost } = await import('@agent-device/host-kit/command'); + const { resolveAndroidArchivePackageName: resolveOnHost } = + await import('@agent-device/platform-android/mechanics'); + + const resolved = await withCommandHost( + { + host: { env: { ...process.env, ANDROID_SDK_ROOT: root }, cwd: root }, + requestEnv: undefined, + }, + () => resolveOnHost(archive), + ); + assert.equal(resolved, 'com.example.aapt'); + }, +); diff --git a/src/sdk/artifacts.ts b/src/sdk/artifacts.ts index 9748b6d3d4..ad6f77b811 100644 --- a/src/sdk/artifacts.ts +++ b/src/sdk/artifacts.ts @@ -1,3 +1,4 @@ +import { withSdkCommandHost } from './command-host.ts'; import { loadAndroidMechanics } from '../platform-runtime-android-mechanics.ts'; type ResolveAndroidArchivePackageName = @@ -6,6 +7,8 @@ type ResolveAndroidArchivePackageName = export async function resolveAndroidArchivePackageName( ...args: Parameters ): Promise>> { - const { resolveAndroidArchivePackageName: resolve } = await loadAndroidMechanics(); - return await resolve(...args); + return await withSdkCommandHost(async () => { + const { resolveAndroidArchivePackageName: resolve } = await loadAndroidMechanics(); + return await resolve(...args); + }); } diff --git a/src/sdk/command-host.test.ts b/src/sdk/command-host.test.ts new file mode 100644 index 0000000000..523e87620c --- /dev/null +++ b/src/sdk/command-host.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import { withSdkCommandHost } from './command-host.ts'; + +test('SDK scopes keep synchronous return values and exceptions synchronous', () => { + const host = { env: { SDK_HOST: 'owned' }, cwd: '/sdk' }; + assert.equal( + withSdkCommandHost(() => commandHostEnvironment().env.SDK_HOST, host), + 'owned', + ); + const failure = new Error('construction failed'); + assert.throws( + () => + withSdkCommandHost(() => { + throw failure; + }, host), + (error) => error === failure, + ); + assert.throws(() => commandHostEnvironment(), { + details: { reason: 'host_environment_unscoped' }, + }); +}); diff --git a/src/sdk/command-host.ts b/src/sdk/command-host.ts new file mode 100644 index 0000000000..6682ee8db6 --- /dev/null +++ b/src/sdk/command-host.ts @@ -0,0 +1,11 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** Released SDK defaults capture the process once per entry call until #3759 supplies a host API. */ +export function withSdkCommandHost( + task: (host: HostEnvironment) => T, + host = captureHostEnvironment(process), +): T { + return withCommandHost({ host, requestEnv: undefined }, () => task(host)); +} diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index c6510102fa..005f957f6e 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -273,3 +273,36 @@ test('Limrun iOS settings evaluate the Apple simctl plan module on the first set [['privacy', 'booted', 'grant', 'photos-add', 'com.example.app']], ]); }); + +test('deferred Limrun reverse operations and archive commands retain their construction host', async () => { + const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); + const { commandHostEnvironment, withCommandExecutorOverride } = + await import('@agent-device/host-kit/command'); + const host = { env: { SDK_HOST: 'limrun' }, cwd: '/limrun' }; + const dependencies = createLimrunRuntimeDependencies(host); + const adb = async () => { + assert.equal(commandHostEnvironment(), host); + return { exitCode: 0, stdout: '', stderr: '' }; + }; + const reverse = await dependencies.android.createPortReverse(adb, { noRebind: false }); + await reverse.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'sdk' }); + assert.deepEqual(await reverse.list?.(), []); + await reverse.removeAllOwned('sdk'); + + let archived = false; + await withCommandExecutorOverride( + async (command) => { + assert.equal(command, 'zip'); + assert.equal(commandHostEnvironment(), host); + archived = true; + return { exitCode: 0, stdout: '', stderr: '' }; + }, + () => + dependencies.host.archiveDirectory({ + sourceDirectory: '/app', + entryName: 'Example.app', + archivePath: '/app.zip', + }), + ); + assert.equal(archived, true); +}); diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index bb0589b0a9..e69a729964 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,3 +1,4 @@ +import { withSdkCommandHost } from './command-host.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { withMethodScope } from '@agent-device/kernel/scoped-provider'; import { AppError } from '@agent-device/kernel/errors'; @@ -11,8 +12,9 @@ import { androidAdbInvocation, androidAdbSerialTarget, runAndroidHostAdb, + scopeAndroidPortReverseManager, } from '@agent-device/platform-android/mechanics'; -import { execFailureDetails, runCmd, withCommandHost } from '@agent-device/host-kit/command'; +import { execFailureDetails, runCmd } from '@agent-device/host-kit/command'; import { readVersion } from '@agent-device/host-kit/version'; export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRuntimeDependencies { @@ -20,113 +22,150 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu clientVersion: readVersion(), android: { createInteractor: (device, adb) => - withMethodScope( - createAndroidInteractor(device, adb), - async (task) => await withCommandHost({ host, requestEnv: undefined }, task), + withSdkCommandHost( + () => + withMethodScope( + createAndroidInteractor(device, adb), + async (task) => await withSdkCommandHost(task, host), + ), + host, ), createPortReverse: async (adb, options) => { - const { createAndroidPortReverseManager } = - await import('@agent-device/platform-android/mechanics'); - return createAndroidPortReverseManager(adb, options); + return await withSdkCommandHost(async () => { + const { createAndroidPortReverseManager } = + await import('@agent-device/platform-android/mechanics'); + return scopeAndroidPortReverseManager( + createAndroidPortReverseManager(adb, options), + (target) => withMethodScope(target, (task) => withSdkCommandHost(task, host)), + ); + }, host); }, inferAppName: async (packageName) => { - const { inferAndroidAppName } = await import('@agent-device/platform-android/mechanics'); - return inferAndroidAppName(packageName); + return await withSdkCommandHost(async () => { + const { inferAndroidAppName } = await import('@agent-device/platform-android/mechanics'); + return inferAndroidAppName(packageName); + }, host); }, listApps: async (adb, filter) => { - const { listAndroidAppsWithAdb } = await import('@agent-device/platform-android/mechanics'); - return ( - await listAndroidAppsWithAdb(adb, { - filter, - target: 'mobile', - }) - ).map((app) => ({ id: app.package, name: app.name })); + return await withSdkCommandHost(async () => { + const { listAndroidAppsWithAdb } = + await import('@agent-device/platform-android/mechanics'); + return ( + await listAndroidAppsWithAdb(adb, { + filter, + target: 'mobile', + }) + ).map((app) => ({ id: app.package, name: app.name })); + }, host); }, getForegroundApp: async (_device, adb, signal) => { - const { getAndroidAppStateWithAdb } = await import('../platform-runtime.ts'); - const app = await getAndroidAppStateWithAdb( - async (args, options) => await adb(args, { ...options, signal }), - signal, - ); - return app.package ? { appId: app.package, activity: app.activity } : undefined; + return await withSdkCommandHost(async () => { + const { getAndroidAppStateWithAdb } = await import('../platform-runtime.ts'); + const app = await getAndroidAppStateWithAdb( + async (args, options) => await adb(args, { ...options, signal }), + signal, + ); + return app.package ? { appId: app.package, activity: app.activity } : undefined; + }, host); }, getKeyboardState: async (adb) => { - const { getAndroidKeyboardStatusWithAdb } = - await import('@agent-device/platform-android/mechanics'); - return await getAndroidKeyboardStatusWithAdb(adb); + return await withSdkCommandHost(async () => { + const { getAndroidKeyboardStatusWithAdb } = + await import('@agent-device/platform-android/mechanics'); + return await getAndroidKeyboardStatusWithAdb(adb); + }, host); }, dismissKeyboard: async (adb) => { - const { dismissAndroidKeyboardWithAdb } = - await import('@agent-device/platform-android/mechanics'); - return await dismissAndroidKeyboardWithAdb(adb); + return await withSdkCommandHost(async () => { + const { dismissAndroidKeyboardWithAdb } = + await import('@agent-device/platform-android/mechanics'); + return await dismissAndroidKeyboardWithAdb(adb); + }, host); }, readLogs: async (adb, lineLimit) => { - const { captureAndroidLogcatWithAdb } = - await import('@agent-device/platform-android/mechanics'); - return await captureAndroidLogcatWithAdb(adb, { - lines: lineLimit, - timeoutMs: 5_000, - }); + return await withSdkCommandHost(async () => { + const { captureAndroidLogcatWithAdb } = + await import('@agent-device/platform-android/mechanics'); + return await captureAndroidLogcatWithAdb(adb, { + lines: lineLimit, + timeoutMs: 5_000, + }); + }, host); }, forceStopApp: async (adb, packageName, signal) => { - const { runAdbShell } = await import('@agent-device/platform-android/mechanics'); - await runAdbShell( - async (args, options) => await adb(args, { ...options, signal }), - ['am', 'force-stop', packageName], - { allowFailure: true }, - ); + return await withSdkCommandHost(async () => { + const { runAdbShell } = await import('@agent-device/platform-android/mechanics'); + await runAdbShell( + async (args, options) => await adb(args, { ...options, signal }), + ['am', 'force-stop', packageName], + { allowFailure: true }, + ); + }, host); }, deviceAdbInvocation: (serial, command) => androidAdbInvocation(androidAdbSerialTarget(serial), command), hostAdbInvocation: (command) => androidAdbInvocation(androidAdbHostTarget(), command), adbError: async (message, result, invocation) => { - // Error construction is async so the platform helper remains lazy until an ADB failure. - const { androidAdbResultError, serializeAndroidAdbInvocation } = - await import('@agent-device/platform-android/mechanics'); - return androidAdbResultError( - message, - result, - invocation - ? { command: `adb ${serializeAndroidAdbInvocation(invocation).join(' ')}` } - : undefined, - ); + return await withSdkCommandHost(async () => { + // Error construction is async so the platform helper remains lazy until an ADB failure. + const { androidAdbResultError, serializeAndroidAdbInvocation } = + await import('@agent-device/platform-android/mechanics'); + return androidAdbResultError( + message, + result, + invocation + ? { command: `adb ${serializeAndroidAdbInvocation(invocation).join(' ')}` } + : undefined, + ); + }, host); }, }, host: { runAdb: async (invocation, options) => - await withCommandHost({ host, requestEnv: undefined }, () => - runAndroidHostAdb(invocation, options), - ), + await withSdkCommandHost(() => runAndroidHostAdb(invocation, options), host), downloadFile: async (options) => { - const { downloadLimrunFile } = await import('./limrun-download-file.ts'); - await downloadLimrunFile(options); + return await withSdkCommandHost(async () => { + const { downloadLimrunFile } = await import('./limrun-download-file.ts'); + await downloadLimrunFile(options); + }, host); }, archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { - const args = ['-qr', archivePath, entryName]; - const result = await runCmd('zip', args, { - cwd: sourceDirectory, - timeoutMs: 120_000, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { - command: ['zip', ...args].join(' '), - ...execFailureDetails(result), + return await withSdkCommandHost(async () => { + const args = ['-qr', archivePath, entryName]; + const result = await runCmd('zip', args, { + cwd: sourceDirectory, + timeoutMs: 120_000, }); - } + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { + command: ['zip', ...args].join(' '), + ...execFailureDetails(result), + }); + } + }, host); }, }, ios: { applySimctlSetting: async (request) => { - const { applySimctlSetting } = await import('@agent-device/platform-apple/simctl-settings'); - return await applySimctlSetting(request); + return await withSdkCommandHost(async () => { + const { applySimctlSetting } = + await import('@agent-device/platform-apple/simctl-settings'); + return await applySimctlSetting(request); + }, host); }, resolveAppAlias: async (app) => { - const { resolveIosAppAlias } = await import('@agent-device/platform-apple/app-resolution'); - return resolveIosAppAlias(app); + return await withSdkCommandHost(async () => { + const { resolveIosAppAlias } = + await import('@agent-device/platform-apple/app-resolution'); + return resolveIosAppAlias(app); + }, host); }, readBundleAppName: async (appPath) => { - const { readIosBundleInfo } = await import('@agent-device/platform-apple/install-artifact'); - return (await readIosBundleInfo(appPath)).appName; + return await withSdkCommandHost(async () => { + const { readIosBundleInfo } = + await import('@agent-device/platform-apple/install-artifact'); + return (await readIosBundleInfo(appPath)).appName; + }, host); }, }, }; diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index 24806147b0..38e2a33004 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,5 +1,5 @@ import * as devServer from '@agent-device/react-native'; -import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withSdkCommandHost } from './command-host.ts'; export { normalizeBaseUrl, @@ -16,5 +16,5 @@ export { export async function prepareMetroRuntime( input: Parameters[0] = {}, ): ReturnType { - return await devServer.prepareMetroRuntime(input, captureHostEnvironment(process)); + return await withSdkCommandHost((host) => devServer.prepareMetroRuntime(input, host)); } diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index f5c1286957..cb2de4cf09 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1,4 +1,4 @@ -import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withSdkCommandHost } from './command-host.ts'; import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; import type { RemoteConfigProfileOptions, @@ -11,8 +11,10 @@ export type { RemoteConfigProfile } from '@agent-device/remote/remote-config-sch export function resolveRemoteConfigProfile( options: RemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { - return resolveRemoteConfigProfileOnHost({ - ...options, - env: options.env ?? captureHostEnvironment(process).env, - }); + return withSdkCommandHost((host) => + resolveRemoteConfigProfileOnHost({ + ...options, + env: options.env ?? host.env, + }), + ); } From a68a59017ff247931fac66049273ca655a98e0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 20:06:33 +0200 Subject: [PATCH 2/2] chore(gates): declare the shared SDK process capture boundary Co-Authored-By: Claude Opus 5.5 --- scripts/layering/process-globals-policy.test.ts | 17 +++++++++++++++-- scripts/layering/process-globals-policy.ts | 6 ++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/layering/process-globals-policy.test.ts b/scripts/layering/process-globals-policy.test.ts index 58dce092f9..64d8ca55b5 100644 --- a/scripts/layering/process-globals-policy.test.ts +++ b/scripts/layering/process-globals-policy.test.ts @@ -57,7 +57,7 @@ test('entry declarations permit process reads but do not permit ambient command- 'packages/capture-kit/src/png-worker.ts', 'packages/maestro/src/daemon-port/run-script-http-child.ts', 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', - 'src/sdk/android-adb.ts', + 'src/sdk/command-host.ts', 'src/io.ts', ]) { assert.deepEqual(check('process.env; process.on("SIGINT", stop);', file), [], file); @@ -120,7 +120,7 @@ test('bare process values are captures in calls, aliases, spreads, returns and c "import * as p from 'node:process'; captureHostEnvironment(p.default);", ]) { assert.equal(check(source).length, 1, source); - assert.deepEqual(check(source, 'src/sdk/android-adb.ts'), [], source); + assert.deepEqual(check(source, 'src/sdk/command-host.ts'), [], source); } }); @@ -161,3 +161,16 @@ test('local process bindings do not hide captures in other lexical scopes', () = [], ); }); + +test('SDK consumers cannot reintroduce per-module process captures', () => { + for (const file of [ + 'src/sdk/android-adb.ts', + 'src/sdk/artifacts.ts', + 'src/sdk/limrun-runtime-dependencies.ts', + 'src/sdk/remote-config.ts', + 'src/sdk/metro.ts', + 'src/provider-limrun-runtime.ts', + ]) { + assert.equal(check('captureHostEnvironment(process);', file).length, 1, file); + } +}); diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts index 8f6ae88177..41fbe8989f 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -17,10 +17,8 @@ const PROCESS_ENTRIES = new Set([ 'packages/maestro/src/daemon-port/run-script-http-child.ts', // Released SDK defaults; #3759 removes these captures after agent-device/host ships (#3757). 'src/io.ts', - 'src/sdk/remote-config.ts', - 'src/sdk/android-adb.ts', - 'src/sdk/metro.ts', - 'src/provider-limrun-runtime.ts', + // SDK entry defaults until #3759; reserve agent-device/host's entry when #3757 ships. + 'src/sdk/command-host.ts', 'src/mcp/command-tools.ts', 'src/daemon-client/daemon-client.ts', 'src/daemon-client/daemon-client-ensure.ts',