diff --git a/docs/agents/code-ownership.md b/docs/agents/code-ownership.md index 205fd48fa7..3565645af1 100644 --- a/docs/agents/code-ownership.md +++ b/docs/agents/code-ownership.md @@ -1,12 +1,9 @@ # Code ownership -Read this for shared contracts, package boundaries, request routing, or diagnostics. Before you edit a module with many callers, run `pnpm depgraph affected ` to identify callers and guarantees. ## Declarations -Read the owner. Do not copy its policy into consumers. - | Question | Owner | | --- | --- | | Command names | `packages/command-registry/src/catalog.ts` | @@ -15,8 +12,11 @@ Read the owner. Do not copy its policy into consumers. | Interaction guarantees | `packages/contracts/src/interaction-guarantees.ts` | | Runtime-fact admission | `packages/daemon/src/core/runtime-admission.ts`, `src/platform-runtime-gateway.ts` | | Input fields and audiences | `src/commands/common-input-fields.ts`, `src/commands/input-audience.ts` | +| Released SDK default hosts | `src/sdk/host.ts` | -Shared selectors belong in `@agent-device/selectors`. +For SDK entries `android-adb`, `artifacts`, `limrun`, `metro`, and `remote-config`, use `captureSdkHost` for defaults. +For command-host readers, use `withSdkHost`. +Keep provider callbacks on their supplied host. Request cancellation and progress belong in `@agent-device/host-kit/request`. Per-call abort guards belong beside transports in `src/daemon-client/daemon-client-transport.ts`. Contracts shared across layers belong in `packages/contracts/src`. @@ -32,16 +32,9 @@ Before you add behavior, split files over 1,000 lines, except generated or fixtu Split a source module and its tests together. Do not extend legacy interaction or platform index test aggregations. Keep tests beside their named module. Put repository artifact guards in `test/repo-guards/`. -Shared helpers belong in `@agent-device/test-kit`, a devDependency. -Production code never imports test-kit. -Test-kit never depends on: - -- Platforms. -- Providers. -- The daemon. -- Root. - -Do not export test support from production packages. +Shared test support belongs in `@agent-device/test-kit`, a devDependency. +Production code must not import test-kit. +Test-kit must not depend on platforms, providers, the daemon, or root. ## Diagnostics diff --git a/scripts/layering/process-globals-policy.test.ts b/scripts/layering/process-globals-policy.test.ts index 58dce092f9..5441ab405f 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/host.ts', 'src/io.ts', ]) { assert.deepEqual(check('process.env; process.on("SIGINT", stop);', file), [], file); @@ -120,7 +120,19 @@ 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/host.ts'), [], source); + } +}); + +test('SDK adapters capture their default host only through the shared entry', () => { + for (const file of [ + 'src/sdk/android-adb.ts', + 'src/sdk/artifacts.ts', + 'src/sdk/metro.ts', + 'src/sdk/remote-config.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..036168bed5 100644 --- a/scripts/layering/process-globals-policy.ts +++ b/scripts/layering/process-globals-policy.ts @@ -17,10 +17,7 @@ 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', + 'src/sdk/host.ts', 'src/mcp/command-tools.ts', 'src/daemon-client/daemon-client.ts', 'src/daemon-client/daemon-client-ensure.ts', diff --git a/src/provider-limrun-runtime.ts b/src/provider-limrun-runtime.ts index 0aba2ca761..793e28d213 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 { captureSdkHost } from './sdk/host.ts'; import type { DeviceInventoryProvider, LeaseLifecycleProvider, @@ -27,7 +27,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { constructor(options: LimrunRuntimeOptions) { this.implementation = createLimrunRuntime( options, - createLimrunRuntimeDependencies(captureHostEnvironment(process)), + createLimrunRuntimeDependencies(captureSdkHost()), ); } diff --git a/src/sdk/android-adb.ts b/src/sdk/android-adb.ts index a8bccc07f9..46c187b437 100644 --- a/src/sdk/android-adb.ts +++ b/src/sdk/android-adb.ts @@ -12,21 +12,14 @@ 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'; +import { withSdkHost } from './host.ts'; export async function runAndroidShell(...args: Parameters) { - const host = captureHostEnvironment(process); - return await withCommandHost({ host, requestEnv: undefined }, () => - runAndroidShellOnHost(...args), - ); + return await withSdkHost(() => runAndroidShellOnHost(...args)); } export async function runAndroidExecOut(...args: Parameters) { - const host = captureHostEnvironment(process); - return await withCommandHost({ host, requestEnv: undefined }, () => - runAndroidExecOutOnHost(...args), - ); + return await withSdkHost(() => runAndroidExecOutOnHost(...args)); } export type { ShellWord } from '@agent-device/kernel/device-shell'; diff --git a/src/sdk/artifacts.test.ts b/src/sdk/artifacts.test.ts new file mode 100644 index 0000000000..493eaa06c0 --- /dev/null +++ b/src/sdk/artifacts.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { resolveAndroidArchivePackageName } from './artifacts.ts'; + +test.skipIf(process.platform === 'win32')( + 'standalone artifact resolution uses aapt after an unparseable manifest without poisoning later scoped calls', + async () => { + const root = mkdtempForTestSync('agent-device-sdk-artifacts-'); + const sdk = path.join(root, 'sdk'); + const tools = path.join(sdk, 'build-tools', '99.0.0'); + mkdirSync(tools, { recursive: true }); + const manifest = path.join(root, 'AndroidManifest.xml'); + writeFileSync(manifest, ''); + const archive = path.join(root, 'fallback.apk'); + execFileSync('zip', ['-q', archive, 'AndroidManifest.xml'], { cwd: root }); + const calls = path.join(root, 'aapt-calls.jsonl'); + const aapt = path.join(tools, 'aapt'); + writeFileSync( + aapt, + `#!${process.execPath}\n` + + `require('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify(process.argv.slice(2))+'\\n');\n` + + `process.stdout.write("package: name='com.example.fallback' versionCode='1'\\n");\n`, + ); + chmodSync(aapt, 0o755); + const previous = { + ANDROID_HOME: process.env.ANDROID_HOME, + ANDROID_SDK_ROOT: process.env.ANDROID_SDK_ROOT, + }; + process.env.ANDROID_HOME = sdk; + process.env.ANDROID_SDK_ROOT = sdk; + try { + const standalone = await resolveAndroidArchivePackageName(archive); + const scoped = await withCommandHost( + { host: { env: { ...process.env }, cwd: root }, requestEnv: undefined }, + () => resolveAndroidArchivePackageName(archive), + ); + assert.deepEqual([standalone, scoped], ['com.example.fallback', 'com.example.fallback']); + assert.deepEqual( + readFileSync(calls, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + [ + ['dump', 'badging', archive], + ['dump', 'badging', archive], + ], + ); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }, +); diff --git a/src/sdk/artifacts.ts b/src/sdk/artifacts.ts index 9748b6d3d4..379bfd87bc 100644 --- a/src/sdk/artifacts.ts +++ b/src/sdk/artifacts.ts @@ -1,4 +1,5 @@ import { loadAndroidMechanics } from '../platform-runtime-android-mechanics.ts'; +import { withSdkHost } from './host.ts'; type ResolveAndroidArchivePackageName = typeof import('@agent-device/platform-android/mechanics').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 withSdkHost(async () => { + const { resolveAndroidArchivePackageName: resolve } = await loadAndroidMechanics(); + return await resolve(...args); + }); } diff --git a/src/sdk/host.ts b/src/sdk/host.ts new file mode 100644 index 0000000000..e949579b38 --- /dev/null +++ b/src/sdk/host.ts @@ -0,0 +1,12 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import { withCommandHost } from '@agent-device/host-kit/command'; + +/** Released SDK defaults until the explicit host entry ships (#3759). */ +export function captureSdkHost(): HostEnvironment { + return captureHostEnvironment(process); +} + +export async function withSdkHost(task: () => Promise): Promise { + return await withCommandHost({ host: captureSdkHost(), requestEnv: undefined }, task); +} diff --git a/src/sdk/limrun-host-scope.test.ts b/src/sdk/limrun-host-scope.test.ts new file mode 100644 index 0000000000..604664513d --- /dev/null +++ b/src/sdk/limrun-host-scope.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { createLimrunRuntimeDependencies } from './limrun-runtime-dependencies.ts'; + +test.skipIf(process.platform === 'win32')( + 'Limrun archive callbacks retain their owning host outside and across concurrent foreign scopes', + async () => { + const root = mkdtempForTestSync('agent-device-limrun-host-'); + const bin = path.join(root, 'bin'); + mkdirSync(bin); + const zip = path.join(bin, 'zip'); + writeFileSync( + zip, + `#!${process.execPath}\n` + + `require('node:fs').writeFileSync(process.argv[3], JSON.stringify({args:process.argv.slice(2),host:process.env.LIMRUN_OWNER}));\n`, + ); + chmodSync(zip, 0o755); + const owner = (name: string) => + createLimrunRuntimeDependencies({ + env: { ...process.env, PATH: bin, LIMRUN_OWNER: name }, + cwd: root, + }); + const first = owner('first'); + const second = owner('second'); + const archive = (name: string) => ({ + sourceDirectory: root, + entryName: 'Example.app', + archivePath: path.join(root, `${name}.zip`), + }); + const previous = { PATH: process.env.PATH, LIMRUN_OWNER: process.env.LIMRUN_OWNER }; + process.env.PATH = bin; + process.env.LIMRUN_OWNER = 'ambient'; + try { + const standalone = archive('standalone'); + await first.host.archiveDirectory(standalone); + const left = archive('left'), + right = archive('right'); + await withCommandHost( + { + host: { env: { ...process.env, PATH: bin, LIMRUN_OWNER: 'foreign' }, cwd: root }, + requestEnv: { LIMRUN_OWNER: 'foreign-request' }, + }, + () => Promise.all([first.host.archiveDirectory(left), second.host.archiveDirectory(right)]), + ); + for (const [request, host] of [ + [standalone, 'first'], + [left, 'first'], + [right, 'second'], + ] as const) { + assert.deepEqual(JSON.parse(readFileSync(request.archivePath, 'utf8')), { + args: ['-qr', request.archivePath, 'Example.app'], + host, + }); + } + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }, +); + +test.skipIf(process.platform === 'win32')( + 'Limrun bundle inference resolves plist tooling from its supplied host without an outer scope', + async () => { + const root = mkdtempForTestSync('agent-device-limrun-plist-'); + const plutil = path.join(root, 'plutil'); + writeFileSync( + plutil, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify({CFBundleDisplayName:'Scoped App'}));\n`, + ); + chmodSync(plutil, 0o755); + const dependencies = createLimrunRuntimeDependencies({ + env: { ...process.env, PATH: root }, + cwd: root, + }); + assert.equal( + await dependencies.ios.readBundleAppName(path.join(root, 'Example.app')), + 'Scoped App', + ); + }, +); diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index bb0589b0a9..9ebff17d29 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -16,14 +16,13 @@ import { execFailureDetails, runCmd, withCommandHost } from '@agent-device/host- import { readVersion } from '@agent-device/host-kit/version'; export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRuntimeDependencies { + const runOnHost = (task: () => Promise) => + withCommandHost({ host, requestEnv: undefined }, task); return { clientVersion: readVersion(), android: { createInteractor: (device, adb) => - withMethodScope( - createAndroidInteractor(device, adb), - async (task) => await withCommandHost({ host, requestEnv: undefined }, task), - ), + withMethodScope(createAndroidInteractor(device, adb), runOnHost), createPortReverse: async (adb, options) => { const { createAndroidPortReverseManager } = await import('@agent-device/platform-android/mechanics'); @@ -92,42 +91,48 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu ); }, }, - host: { - 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); - }, - 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), + host: withMethodScope( + { + runAdb: async (invocation, options) => await runAndroidHostAdb(invocation, options), + downloadFile: async (options) => { + const { downloadLimrunFile } = await import('./limrun-download-file.ts'); + await downloadLimrunFile(options); + }, + archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { + const args = ['-qr', archivePath, entryName]; + const result = await runCmd('zip', args, { + cwd: sourceDirectory, + timeoutMs: 120_000, }); - } - }, - }, - ios: { - applySimctlSetting: async (request) => { - const { applySimctlSetting } = await import('@agent-device/platform-apple/simctl-settings'); - return await applySimctlSetting(request); - }, - resolveAppAlias: async (app) => { - const { resolveIosAppAlias } = await import('@agent-device/platform-apple/app-resolution'); - return resolveIosAppAlias(app); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { + command: ['zip', ...args].join(' '), + ...execFailureDetails(result), + }); + } + }, }, - readBundleAppName: async (appPath) => { - const { readIosBundleInfo } = await import('@agent-device/platform-apple/install-artifact'); - return (await readIosBundleInfo(appPath)).appName; + runOnHost, + ), + ios: withMethodScope( + { + applySimctlSetting: async (request) => { + const { applySimctlSetting } = + await import('@agent-device/platform-apple/simctl-settings'); + return await applySimctlSetting(request); + }, + resolveAppAlias: async (app) => { + const { resolveIosAppAlias } = + await import('@agent-device/platform-apple/app-resolution'); + return resolveIosAppAlias(app); + }, + readBundleAppName: async (appPath) => { + const { readIosBundleInfo } = + await import('@agent-device/platform-apple/install-artifact'); + return (await readIosBundleInfo(appPath)).appName; + }, }, - }, + runOnHost, + ), }; } diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index 24806147b0..ac0689b1a8 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 { captureSdkHost } from './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 devServer.prepareMetroRuntime(input, captureSdkHost()); } diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index f5c1286957..75aa99ac9e 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 { captureSdkHost } from './host.ts'; import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; import type { RemoteConfigProfileOptions, @@ -13,6 +13,6 @@ export function resolveRemoteConfigProfile( ): ResolvedRemoteConfigProfile { return resolveRemoteConfigProfileOnHost({ ...options, - env: options.env ?? captureHostEnvironment(process).env, + env: options.env ?? captureSdkHost().env, }); }