From 9a16be131286b7535a3b44b245d21cf71d82c140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 16:45:29 +0200 Subject: [PATCH] refactor(runtime): use the owning host environment at process boundaries (#3753) Co-Authored-By: Claude Opus 5.5 --- .../platform-runtime-runtime-hints.test.ts | 3 ++ .../provider-device-runtimes.test.ts | 54 +++++++++++++------ .../test-utils/builtin-plugin-inventory.ts | 2 +- src/builtin-provider-plugins.ts | 20 ++++--- src/daemon-composition.ts | 2 +- ...atform-runtime-android-adb-binding.test.ts | 4 +- src/platform-runtime-android-adb-host.test.ts | 4 +- src/platform-runtime-android-adb-host.ts | 11 ++-- ...tform-runtime-app-log-android-transport.ts | 6 ++- src/platform-runtime-app-log-process.test.ts | 44 ++++++++++----- src/platform-runtime-app-log-process.ts | 8 ++- src/platform-runtime-operation-host.ts | 3 +- src/platform-runtime-perf-host.test.ts | 8 ++- src/provider-device-runtimes.ts | 8 +-- src/provider-limrun-runtime.ts | 6 ++- src/sdk/android-adb.test.ts | 28 ++++++++++ src/sdk/android-adb.ts | 21 +++++++- src/sdk/limrun-runtime-dependencies.test.ts | 12 ++--- src/sdk/limrun-runtime-dependencies.ts | 17 ++++-- 19 files changed, 194 insertions(+), 67 deletions(-) diff --git a/src/__tests__/platform-runtime-runtime-hints.test.ts b/src/__tests__/platform-runtime-runtime-hints.test.ts index 8ba78c701f..3a3277b7eb 100644 --- a/src/__tests__/platform-runtime-runtime-hints.test.ts +++ b/src/__tests__/platform-runtime-runtime-hints.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; @@ -418,3 +419,5 @@ test('clearRuntimeHintValues deletes iOS simulator React Native defaults', async ); }); }); + +bindTestCommandHost(); diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index e1e64724e2..0b15653879 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -13,7 +13,10 @@ import { test('daemon composition registers installed provider runtimes and their lazy platform modules', async () => { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', registrationSource('example')); - const composition = await createDaemonProviderRuntimeComposition(env); + const composition = await createDaemonProviderRuntimeComposition({ + env: env, + cwd: process.cwd(), + }); const plugin = composition.runtimes.find((runtime) => runtime.provider === 'example'); assert.ok(plugin); assert.equal( @@ -26,7 +29,10 @@ test('daemon composition registers installed provider runtimes and their lazy pl test('built-in providers register ahead of the store and an unconfigured Limrun still claims its id', async () => { const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', registrationSource('example')); - const composition = await createDaemonProviderRuntimeComposition(env); + const composition = await createDaemonProviderRuntimeComposition({ + env: env, + cwd: process.cwd(), + }); assert.deepEqual( composition.runtimes.map((runtime) => runtime.provider), @@ -47,10 +53,13 @@ test('daemon composition records Limrun as failed for a partial instance set and const { home, env } = pluginHome(); selectPlugin(home, 'example', 'example', registrationSource('example')); const composition = await createDaemonProviderRuntimeComposition({ - ...env, - LIMRUN_API_KEY: 'lim_test_key', - LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', - LIM_ANDROID_INSTANCE_TOKEN: 'android-instance-token', + env: { + ...env, + LIMRUN_API_KEY: 'lim_test_key', + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-instance-token', + }, + cwd: process.cwd(), }); assert.deepEqual( @@ -68,7 +77,10 @@ test('a plugin claiming a built-in provider ID is recorded as failed and the bui for (const provider of ['limrun', 'browserstack', 'aws-device-farm']) { const { home, env } = pluginHome(); selectPlugin(home, 'example', provider, 'throw new Error("must not evaluate");'); - const composition = await createDaemonProviderRuntimeComposition(env); + const composition = await createDaemonProviderRuntimeComposition({ + env: env, + cwd: process.cwd(), + }); const plugin = composition.inventory.find(({ package: name }) => name === 'example'); assert.equal(plugin?.state, 'failed'); assert.equal(plugin?.error?.reason, 'duplicate_provider'); @@ -85,8 +97,11 @@ test('Limrun stays unregistered when only the removed API key alias is configure const { env } = pluginHome(); const { runtimes, platformModules, inventory, shutdown } = await createDaemonProviderRuntimeComposition({ - ...env, - LIM_API_KEY: 'lim_test_key', + env: { + ...env, + LIM_API_KEY: 'lim_test_key', + }, + cwd: process.cwd(), }); assert.equal( @@ -104,8 +119,11 @@ test('Limrun stays unregistered when only the removed API key alias is configure test('Limrun registers when a Limrun API key is configured', async () => { const { env } = pluginHome(); const { runtimes, platformModules, shutdown } = await createDaemonProviderRuntimeComposition({ - ...env, - LIMRUN_API_KEY: 'lim_test_key', + env: { + ...env, + LIMRUN_API_KEY: 'lim_test_key', + }, + cwd: process.cwd(), }); const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); @@ -118,9 +136,12 @@ test('Limrun registers when a Limrun API key is configured', async () => { test('Limrun registers for an existing instance without an API key', async () => { const { env } = pluginHome(); const { runtimes, platformModules, shutdown } = await createDaemonProviderRuntimeComposition({ - ...env, - LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', - LIM_IOS_INSTANCE_TOKEN: 'ios-instance-token', + env: { + ...env, + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-instance-token', + }, + cwd: process.cwd(), }); const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); @@ -167,7 +188,10 @@ test('composition shutdown is the only route the daemon uses: it shuts every run });`, { selectionOptions: { log } }, ); - const composition = await createDaemonProviderRuntimeComposition(env); + const composition = await createDaemonProviderRuntimeComposition({ + env: env, + cwd: process.cwd(), + }); assert.ok(composition.runtimes.some((runtime) => runtime.provider === 'cleaning')); await composition.shutdown(); diff --git a/src/__tests__/test-utils/builtin-plugin-inventory.ts b/src/__tests__/test-utils/builtin-plugin-inventory.ts index eac4a34b3d..3065189bd0 100644 --- a/src/__tests__/test-utils/builtin-plugin-inventory.ts +++ b/src/__tests__/test-utils/builtin-plugin-inventory.ts @@ -8,7 +8,7 @@ import { builtinProviderCandidates } from '../../builtin-provider-plugins.ts'; */ export async function builtinPluginInventory(): Promise { const resolved = await resolveProviderPlugins( - builtinProviderCandidates({ LIMRUN_API_KEY: 'configured' }), + builtinProviderCandidates({ env: { LIMRUN_API_KEY: 'configured' }, cwd: process.cwd() }), ); return resolved.inventory(); } diff --git a/src/builtin-provider-plugins.ts b/src/builtin-provider-plugins.ts index 1d09697d39..7698c35348 100644 --- a/src/builtin-provider-plugins.ts +++ b/src/builtin-provider-plugins.ts @@ -5,7 +5,7 @@ import { type CloudWebDriverProviderDeclaration, } from '@agent-device/provider-webdriver/providers'; import { LIMRUN_ID } from './builtin-provider-ids.ts'; -import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { limrunCredentialDeclaration, readLimrunCredentials, @@ -17,14 +17,14 @@ import { * load only when a runtime is set up; a candidate that needs configuration claims its id without * registering until the environment holds it. */ -export function builtinProviderCandidates(env: EnvMap): PluginCandidate[] { +export function builtinProviderCandidates(host: HostEnvironment): PluginCandidate[] { return [ ...CLOUD_WEBDRIVER_PROVIDER_DECLARATIONS.map((declaration): PluginCandidate => ({ source: 'builtin', provider: declaration.provider, load: () => cloudWebDriverPlugin(declaration), })), - limrunCandidate(env), + limrunCandidate(host), ]; } @@ -64,11 +64,11 @@ function cloudWebDriverPlugin(declaration: CloudWebDriverProviderDeclaration): A }; } -function limrunCandidate(env: EnvMap): PluginCandidate { +function limrunCandidate(host: HostEnvironment): PluginCandidate { const base = { source: 'builtin', provider: LIMRUN_ID } as const; let credentials: LimrunCredentials | undefined; try { - credentials = readLimrunCredentials(env); + credentials = readLimrunCredentials(host.env); } catch (error) { return { ...base, load: () => Promise.reject(error) }; } @@ -85,9 +85,13 @@ function limrunCandidate(env: EnvMap): PluginCandidate { import('@agent-device/provider-limrun'), import('./sdk/limrun-runtime-dependencies.ts'), ]); - return runtime.createLimrunRuntime(limrun, dependencies.createLimrunRuntimeDependencies(), { - includePlatformModule: true, - }); + return runtime.createLimrunRuntime( + limrun, + dependencies.createLimrunRuntimeDependencies(host), + { + includePlatformModule: true, + }, + ); }, }), }; diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 7fb7f90d27..9001658e62 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -93,7 +93,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( resolveSessionDir, }); const hostDiagnostics = createHostDiagnostics(host); - const providerComposition = await createDaemonProviderRuntimeComposition(env); + const providerComposition = await createDaemonProviderRuntimeComposition(host); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index 8a872b8b9c..7e57cb1772 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -26,7 +26,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { }); import { createPlatformRuntimeGateway } from './platform-runtime.ts'; -import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; const sessionArtifacts = { sessionsDir: '/sessions', @@ -102,3 +102,5 @@ test('the composed gateway reads Android app state from inside the platform pack await gateway.shutdown(); }); + +bindTestCommandHost(); diff --git a/src/platform-runtime-android-adb-host.test.ts b/src/platform-runtime-android-adb-host.test.ts index 84968eb53c..6841823601 100644 --- a/src/platform-runtime-android-adb-host.test.ts +++ b/src/platform-runtime-android-adb-host.test.ts @@ -11,7 +11,7 @@ import { parseAndroidAdbArgv, runAndroidHostAdb, } from '@agent-device/platform-android/mechanics'; -import { ANDROID_EMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, ANDROID_EMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; import './platform-runtime-android-adb-host.ts'; const UNGUARDED_DEVICE_SHELL = { @@ -324,3 +324,5 @@ test.skipIf(process.platform === 'win32')( assert.equal(fs.existsSync(markerPath), true); }, ); + +bindTestCommandHost(); diff --git a/src/platform-runtime-android-adb-host.ts b/src/platform-runtime-android-adb-host.ts index 17b94f28f2..55e32ba9f7 100644 --- a/src/platform-runtime-android-adb-host.ts +++ b/src/platform-runtime-android-adb-host.ts @@ -4,6 +4,7 @@ import { assertDeviceShellArgv } from '@agent-device/kernel/device-shell'; import { createHash, randomUUID } from 'node:crypto'; import { coerceExecResult, + commandHostEnvironment, execFailureDetails, runCmd, runCmdBackground, @@ -27,8 +28,6 @@ import { import os from 'node:os'; import path from 'node:path'; -const environment = process.env; - /** * The adb process boundary: a `shell`/`exec-out` command reaches the device's `sh` verbatim, so the * command must have been minted by the device-shell funnel. Checked on the command, not the lowered @@ -39,7 +38,9 @@ function requireQuotedDeviceShellCommand(command: readonly string[]): void { } bindAndroidAdbHost({ - environment, + get environment() { + return commandHostEnvironment().env; + }, files: { access: async (candidate) => await access(candidate), ensureDirectory: async (directory) => { @@ -93,7 +94,7 @@ bindAndroidAdbHost({ }, execAdb: async (invocation, options) => { requireQuotedDeviceShellCommand(invocation.command); - const lowered = lowerAndroidAdbInvocation(invocation, options, environment); + const lowered = lowerAndroidAdbInvocation(invocation, options, commandHostEnvironment().env); return await runCmd('adb', lowered.args, { ...lowered.options, // adb's fork-server is a grandchild: without its own process group a deadline can only @@ -103,7 +104,7 @@ bindAndroidAdbHost({ }, spawnAdb: (invocation, options) => { requireQuotedDeviceShellCommand(invocation.command); - const lowered = lowerAndroidAdbInvocation(invocation, options, environment); + const lowered = lowerAndroidAdbInvocation(invocation, options, commandHostEnvironment().env); const background = runCmdBackground('adb', lowered.args, { ...lowered.options, allowFailure: true, diff --git a/src/platform-runtime-app-log-android-transport.ts b/src/platform-runtime-app-log-android-transport.ts index 163ab5b703..6522415b58 100644 --- a/src/platform-runtime-app-log-android-transport.ts +++ b/src/platform-runtime-app-log-android-transport.ts @@ -3,6 +3,7 @@ import type { AppLogProcessTransport, } from '@agent-device/contracts/app-log-runtime'; import type { HostCommandResult } from '@agent-device/contracts/platform-runtime-host'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidAdbProcess } from '@agent-device/platform-android/mechanics'; import { @@ -15,20 +16,21 @@ export async function resolveAndroidAppLogProcessTransport( sessionsDir: string, device: DeviceInfo, local: AppLogProcessTransport, + env: EnvMap, ): Promise { const { resolveScopedAndroidAdbBackgroundTransport } = await loadAndroidMechanics(); const transport = resolveScopedAndroidAdbBackgroundTransport(device); if (transport.mode === 'local') return local; if (!transport.spawn) return Object.freeze({ mode: 'transport-composed' }); const spawn = transport.spawn; - const processes = createManagedAppLogProcesses(sessionsDir, { + const processes = createManagedAppLogProcesses(sessionsDir, env, { launch: async (command, signal) => { signal?.throwIfAborted(); const adb = providerAdbCommand(command, device.id); const child = spawn([...adb.args], { allowFailure: adb.options?.allowFailure, cwd: adb.options?.cwd, - env: adb.options?.env ? { ...process.env, ...adb.options.env } : undefined, + env: { ...env, ...adb.options?.env }, captureOutput: false, signal, }); diff --git a/src/platform-runtime-app-log-process.test.ts b/src/platform-runtime-app-log-process.test.ts index 66ff00862b..937937d31c 100644 --- a/src/platform-runtime-app-log-process.test.ts +++ b/src/platform-runtime-app-log-process.test.ts @@ -41,9 +41,21 @@ afterEach(() => { }); describe('managed app-log process host', () => { + test('starts an app-log command with its construction host even without an env overlay', async () => { + const fixture = processFixture(); + await createManagedAppLogProcesses(fixture.sessionsDir, { ONLY_HOST_VALUE: 'host' }).start( + fixture.request, + ); + expect(exec.run).toHaveBeenCalledWith( + 'adb', + ['logcat', '--pid', '123'], + expect.objectContaining({ env: { ONLY_HOST_VALUE: 'host' } }), + ); + }); + test('publishes a complete marker atomically and preserves invalid evidence', async () => { const fixture = processFixture(); - const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const processes = createManagedAppLogProcesses(fixture.sessionsDir, process.env); const running = await processes.start(fixture.request); expect(await processes.readMarker(fixture.markerPath)).toEqual({ status: 'decoded', @@ -64,7 +76,7 @@ describe('managed app-log process host', () => { test('authorizes cleanup by exact process identity instead of command vocabulary', async () => { processIdentity.command = '/opt/acme/bin/device-output --stream session-1'; const fixture = processFixture({ settled: true }); - const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const processes = createManagedAppLogProcesses(fixture.sessionsDir, process.env); const running = await processes.start(fixture.request); @@ -80,7 +92,7 @@ describe('managed app-log process host', () => { processIdentity.startTime = null; const fixture = processFixture({ settled: true }); await expect( - createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request), + createManagedAppLogProcesses(fixture.sessionsDir, process.env).start(fixture.request), ).rejects.toThrow('complete ownership marker'); expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL'); expect(fs.existsSync(fixture.markerPath)).toBe(false); @@ -95,7 +107,7 @@ describe('managed app-log process host', () => { kill: vi.fn(() => true), }); await expect( - createManagedAppLogProcesses(fixture.sessionsDir, { + createManagedAppLogProcesses(fixture.sessionsDir, process.env, { launch: async () => ({ child, wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), @@ -116,7 +128,7 @@ describe('managed app-log process host', () => { }); await expect( - createManagedAppLogProcesses(fixture.sessionsDir, { launch }).start( + createManagedAppLogProcesses(fixture.sessionsDir, process.env, { launch }).start( fixture.request, controller.signal, ), @@ -128,7 +140,7 @@ describe('managed app-log process host', () => { const fixture = processFixture({ settled: true }); fs.writeFileSync(fixture.markerPath, '{retained'); await expect( - createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request), + createManagedAppLogProcesses(fixture.sessionsDir, process.env).start(fixture.request), ).rejects.toThrow('must be recovered before start'); expect(exec.run).not.toHaveBeenCalled(); expect(fs.readFileSync(fixture.markerPath, 'utf8')).toBe('{retained'); @@ -136,7 +148,7 @@ describe('managed app-log process host', () => { test('treats PID command reuse as ownership lost and never terminates it', async () => { const fixture = processFixture(); - const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const processes = createManagedAppLogProcesses(fixture.sessionsDir, process.env); const running = await processes.start(fixture.request); processIdentity.command = 'unrelated process'; expect(await processes.inspect(running.marker!)).toBe('ownership-lost'); @@ -147,7 +159,9 @@ describe('managed app-log process host', () => { test('handles output rejection immediately and fails the managed wait', async () => { const fixture = processFixture({ rejectOutput: true }); - const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + const running = await createManagedAppLogProcesses(fixture.sessionsDir, process.env).start( + fixture.request, + ); fixture.child.stdout.write('line\n'); await vi.waitFor(() => expect(fixture.child.kill).toHaveBeenCalledWith('SIGKILL')); fixture.resolveWait(); @@ -156,7 +170,9 @@ describe('managed app-log process host', () => { test('terminates the owned child exactly once across terminate and async disposal', async () => { const fixture = processFixture(); - const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + const running = await createManagedAppLogProcesses(fixture.sessionsDir, process.env).start( + fixture.request, + ); const terminating = running.terminate(); fixture.resolveWait(); await terminating; @@ -167,7 +183,9 @@ describe('managed app-log process host', () => { test('retains a marker that is corrupted before the managed child exits', async () => { const fixture = processFixture(); - const running = await createManagedAppLogProcesses(fixture.sessionsDir).start(fixture.request); + const running = await createManagedAppLogProcesses(fixture.sessionsDir, process.env).start( + fixture.request, + ); fs.writeFileSync(fixture.markerPath, '{corrupt'); fixture.resolveWait(); await running.wait; @@ -177,7 +195,7 @@ describe('managed app-log process host', () => { test('rejects marker paths outside the sessions root', async () => { const fixture = processFixture({ settled: true }); await expect( - createManagedAppLogProcesses(fixture.sessionsDir).start({ + createManagedAppLogProcesses(fixture.sessionsDir, process.env).start({ ...fixture.request, markerPath: path.join(path.dirname(fixture.sessionsDir), 'app-log.pid'), }), @@ -191,7 +209,7 @@ describe('managed app-log process host', () => { fs.mkdirSync(outside); fs.symlinkSync(outside, linked); await expect( - createManagedAppLogProcesses(fixture.sessionsDir).start({ + createManagedAppLogProcesses(fixture.sessionsDir, process.env).start({ ...fixture.request, markerPath: path.join(linked, 'app-log.pid'), }), @@ -209,7 +227,7 @@ describe('managed app-log process host', () => { }; fs.writeFileSync(outsidePath, `${JSON.stringify(outsideMarker)}\n`); fs.symlinkSync(outsidePath, fixture.markerPath); - const processes = createManagedAppLogProcesses(fixture.sessionsDir); + const processes = createManagedAppLogProcesses(fixture.sessionsDir, process.env); await expect(processes.readMarker(fixture.markerPath)).resolves.toMatchObject({ status: 'invalid', diff --git a/src/platform-runtime-app-log-process.ts b/src/platform-runtime-app-log-process.ts index f279ee811c..4a7f1516a4 100644 --- a/src/platform-runtime-app-log-process.ts +++ b/src/platform-runtime-app-log-process.ts @@ -12,6 +12,7 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import { decodeAppLogProcessMarker } from '@agent-device/capture-kit'; import type { DeviceIdentity } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import fs from 'node:fs'; import path from 'node:path'; import { openVerifiedFileForRead } from '@agent-device/host-kit/file'; @@ -47,10 +48,12 @@ export type ManagedAppLogCommandLauncher = ( export function createManagedAppLogProcesses( sessionsDir: string, + env: EnvMap, options: Readonly<{ launch?: ManagedAppLogCommandLauncher }> = {}, ) { const root = path.resolve(sessionsDir); - const launch = options.launch ?? launchLocalAppLogCommand; + const launch = + options.launch ?? ((command, signal) => launchLocalAppLogCommand(command, env, signal)); return Object.freeze({ start: async (request: AppLogBackgroundProcessRequest, signal?: AbortSignal) => await startAppLogProcess(root, request, launch, signal), @@ -167,13 +170,14 @@ async function startAppLogProcess( function launchLocalAppLogCommand( command: AppLogProcessCommand, + env: EnvMap, signal?: AbortSignal, ): ManagedAppLogCommand { const request = localHostCommand(command); return runCmdBackground(request.executable, [...request.args], { allowFailure: request.allowFailure, cwd: request.cwd, - env: request.env ? { ...process.env, ...request.env } : undefined, + env: { ...env, ...request.env }, captureOutput: false, signal, }); diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index f46a6ebe39..1537603191 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -65,7 +65,7 @@ export function createPlatformRuntimeHost(options: { }): PlatformRuntimeHost { const host = options.host; const env = host.env; - const processes = createManagedAppLogProcesses(options.sessionsDir); + const processes = createManagedAppLogProcesses(options.sessionsDir, env); const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start }); const appleTools = createAppleToolHost(); const commands = Object.freeze({ @@ -116,6 +116,7 @@ export function createPlatformRuntimeHost(options: { options.sessionsDir, device, localProcessTransport, + env, ); }, }), diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index 68b0cd072e..c307d38d04 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -1,5 +1,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + createLiveTestHostEnvironment, + mkdtempForTestSync, +} from '@agent-device/test-kit'; import { expect, test, vi } from 'vitest'; import { createPerfRuntimeHost } from './platform-runtime-perf-host.ts'; import { createPlatformRuntimeGateway } from './platform-runtime.ts'; @@ -74,3 +78,5 @@ test('the composed Android perf operation reports an unavailable ADB memory samp await gateway.shutdown(); } }); + +bindTestCommandHost(); diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index dcad5a81f4..4b2e8e1077 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { PluginCleanupOutcome, @@ -7,8 +8,6 @@ import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gat import { builtinProviderCandidates } from './builtin-provider-plugins.ts'; import { PROVIDER_PLUGIN_POLICY } from './provider-plugin-policy.ts'; -export type DefaultProviderDeviceRuntimeEnv = NodeJS.ProcessEnv; - export type ProviderRuntimeComposition = Readonly<{ runtimes: readonly ProviderDeviceRuntime[]; platformModules: readonly PlatformRuntimeProviderRegistration[]; @@ -23,8 +22,9 @@ export type ProviderRuntimeComposition = Readonly<{ * that cannot register is listed in the inventory; the others still register. */ export async function createDaemonProviderRuntimeComposition( - env: DefaultProviderDeviceRuntimeEnv = process.env, + host: HostEnvironment, ): Promise { + const { env } = host; // This daemon's sources: the built-in providers, then the plugin store. A host that embeds the // daemon picks its own candidates; the registry takes whatever candidates it is given. const [{ resolveProviderPlugins, startProviderRegistry }, { storePluginCandidates }] = @@ -33,7 +33,7 @@ export async function createDaemonProviderRuntimeComposition( import('@agent-device/plugins/store'), ]); const resolved = await resolveProviderPlugins([ - ...builtinProviderCandidates(env), + ...builtinProviderCandidates(host), ...(await storePluginCandidates(env)), ]); const registry = await startProviderRegistry(resolved, env, PROVIDER_PLUGIN_POLICY); diff --git a/src/provider-limrun-runtime.ts b/src/provider-limrun-runtime.ts index e3bfccda9f..0aba2ca761 100644 --- a/src/provider-limrun-runtime.ts +++ b/src/provider-limrun-runtime.ts @@ -1,3 +1,4 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { DeviceInventoryProvider, LeaseLifecycleProvider, @@ -24,7 +25,10 @@ export class LimrunRuntime implements ProviderDeviceRuntime { readonly provider = LIMRUN_PROVIDER; constructor(options: LimrunRuntimeOptions) { - this.implementation = createLimrunRuntime(options, createLimrunRuntimeDependencies()); + this.implementation = createLimrunRuntime( + options, + createLimrunRuntimeDependencies(captureHostEnvironment(process)), + ); } get leaseLifecycle(): LeaseLifecycleProvider { diff --git a/src/sdk/android-adb.test.ts b/src/sdk/android-adb.test.ts index 5126e1aa5e..7472c2fcee 100644 --- a/src/sdk/android-adb.test.ts +++ b/src/sdk/android-adb.test.ts @@ -37,3 +37,31 @@ test('getAndroidAppStateWithAdb falls back to activity focus and settles empty', ['shell', 'dumpsys', 'activity'], ]); }); + +test.skipIf(process.platform === 'win32')( + 'standalone public Android shell helpers establish their process host before dispatch', + async () => { + const { runAndroidShell, runAndroidExecOut } = await import('./android-adb.ts'); + const { ANDROID_EMULATOR, mkdtempForTestSync } = await import('@agent-device/test-kit'); + const { writeFileSync, chmodSync } = await import('node:fs'); + const { join } = await import('node:path'); + const bin = mkdtempForTestSync('agent-device-sdk-adb-'); + const adbPath = join(bin, 'adb'); + writeFileSync( + adbPath, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));`, + ); + chmodSync(adbPath, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = bin; + try { + const shell = await runAndroidShell(ANDROID_EMULATOR, ['id']); + const execOut = await runAndroidExecOut(ANDROID_EMULATOR, ['id']); + assert.deepEqual(JSON.parse(shell.stdout), ['-s', ANDROID_EMULATOR.id, 'shell', 'id']); + assert.deepEqual(JSON.parse(execOut.stdout), ['-s', ANDROID_EMULATOR.id, 'exec-out', 'id']); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }, +); diff --git a/src/sdk/android-adb.ts b/src/sdk/android-adb.ts index 009bbc556e..a8bccc07f9 100644 --- a/src/sdk/android-adb.ts +++ b/src/sdk/android-adb.ts @@ -8,7 +8,26 @@ export { } from '@agent-device/platform-android/mechanics'; export { listAndroidAppsWithAdb } from '@agent-device/platform-android/mechanics'; export { runAdbExecOut, runAdbShell } from '@agent-device/platform-android/mechanics'; -export { runAndroidExecOut, runAndroidShell } 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), + ); +} + +export async function runAndroidExecOut(...args: Parameters) { + const host = captureHostEnvironment(process); + return await withCommandHost({ host, requestEnv: undefined }, () => + runAndroidExecOutOnHost(...args), + ); +} export type { ShellWord } from '@agent-device/kernel/device-shell'; import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index 27df5b34eb..c6510102fa 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -63,7 +63,7 @@ test('Limrun construction defers operation and opposite-platform helper modules' await runtime.shutdown(); const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const device = { platform: 'android' as const, id: 'limrun:android:lease-a', @@ -113,7 +113,7 @@ test('Limrun construction defers operation and opposite-platform helper modules' test('Limrun appstate forwards an in-flight abort through the provider ADB executor', async () => { const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const device = { platform: 'android' as const, id: 'limrun:android:lease-abort', @@ -151,7 +151,7 @@ test('host.runAdb carries addressing apart from payload and routes through the h serializeAndroidAdbInvocation, withAndroidHostAdbTransport, } = await import('@agent-device/platform-android/mechanics'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const seen: Array<{ args: string[]; payload: readonly string[]; @@ -194,7 +194,7 @@ test('adbError names the failed command with the platform serializer', async () const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); const { androidAdbInvocation, androidAdbSerialTarget } = await import('@agent-device/platform-android/mechanics'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const failure = await dependencies.android.adbError( 'Limrun Android ADB command failed', @@ -220,7 +220,7 @@ test('the adb invocation adapters address through the platform builders', async androidAdbSerialTarget, serializeAndroidAdbInvocation, } = await import('@agent-device/platform-android/mechanics'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const device = dependencies.android.deviceAdbInvocation('127.0.0.1:62001', [ 'shell', @@ -249,7 +249,7 @@ test('the adb invocation adapters address through the platform builders', async test('Limrun iOS settings evaluate the Apple simctl plan module on the first setting', async () => { const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); - const dependencies = createLimrunRuntimeDependencies(); + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); const runSimctl = vi.fn(async (_args: string[]) => ({ stdout: '', stderr: '' })); const grantPhotos = { runSimctl, diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index f6aa5be5cd..bb0589b0a9 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,3 +1,5 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; import { AppError } from '@agent-device/kernel/errors'; import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; import '../platform-runtime-android-adb-host.ts'; @@ -10,14 +12,18 @@ import { androidAdbSerialTarget, runAndroidHostAdb, } from '@agent-device/platform-android/mechanics'; -import { execFailureDetails, runCmd } from '@agent-device/host-kit/command'; +import { execFailureDetails, runCmd, withCommandHost } from '@agent-device/host-kit/command'; import { readVersion } from '@agent-device/host-kit/version'; -export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { +export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRuntimeDependencies { return { clientVersion: readVersion(), android: { - createInteractor: (device, adb) => createAndroidInteractor(device, adb), + createInteractor: (device, adb) => + withMethodScope( + createAndroidInteractor(device, adb), + async (task) => await withCommandHost({ host, requestEnv: undefined }, task), + ), createPortReverse: async (adb, options) => { const { createAndroidPortReverseManager } = await import('@agent-device/platform-android/mechanics'); @@ -87,7 +93,10 @@ export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }, }, host: { - runAdb: async (invocation, options) => await runAndroidHostAdb(invocation, options), + runAdb: async (invocation, options) => + await withCommandHost({ host, requestEnv: undefined }, () => + runAndroidHostAdb(invocation, options), + ), downloadFile: async (options) => { const { downloadLimrunFile } = await import('./limrun-download-file.ts'); await downloadLimrunFile(options);