Skip to content
Closed
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
51 changes: 51 additions & 0 deletions packages/host-kit/src/internal/exec-types.ts
Original file line number Diff line number Diff line change
@@ -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<ExecDetachedExit>;
};

export type CommandExecutorOverride = (
cmd: string,
args: readonly string[],
options: ExecOptions,
) => Promise<ExecResult> | undefined;

export type CommandHostScope = Readonly<{
host: HostEnvironment;
requestEnv: EnvMap | undefined;
}>;
73 changes: 19 additions & 54 deletions packages/host-kit/src/internal/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<ExecDetachedExit>;
};

/**
* Background runs have no `timeoutMs`: the callers are long-lived sessions (the
* Android snapshot helper, the keep-hot xcodebuild runner, app-log capture), and
Expand All @@ -89,12 +66,6 @@ export type ExecBackgroundOptions = Omit<ExecOptions, 'timeoutMs'> & {
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<ExecResult> | undefined;

const commandExecutorOverrideScope = new AsyncLocalStorage<CommandExecutorOverride | undefined>();

export async function withCommandExecutorOverride<T>(
Expand All @@ -110,27 +81,21 @@ export async function withoutCommandExecutorOverride<T>(fn: () => Promise<T>): P
return await commandExecutorOverrideScope.run(undefined, fn);
}

type CommandHostScope = Readonly<{
host: HostEnvironment;
requestEnv: EnvMap | undefined;
}>;

const commandHostScope = new AsyncLocalStorage<CommandHostScope>();

/**
* Runs `fn` as the host that owns the commands it spawns. A command without its own `env` starts
* 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<T>(
scope: CommandHostScope,
fn: () => Promise<T>,
): Promise<T> {
export function withCommandHost<T>(scope: CommandHostScope, fn: () => Promise<T>): Promise<T>;
export function withCommandHost<T>(scope: CommandHostScope, fn: () => T): T;
export function withCommandHost<T>(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);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions packages/platform-android/src/adb-port-reverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ export type AndroidExecPortReverseOptions = Readonly<{
const ANDROID_PORT_REVERSE_REBIND_REFUSED_REASON = 'android_port_reverse_rebind_refused';

const managedAndroidPortReverseProviders = new WeakSet<AndroidPortReverseProvider>();
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,
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,5 @@ export {
type AndroidBlockingDialogObservation,
type AndroidWindowDumpReader,
} from './window-state.ts';

export { scopeAndroidPortReverseManager } from './adb-port-reverse.ts';
17 changes: 15 additions & 2 deletions scripts/layering/process-globals-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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);
}
});
6 changes: 2 additions & 4 deletions scripts/layering/process-globals-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 3 additions & 4 deletions src/provider-limrun-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import { withSdkCommandHost } from './sdk/command-host.ts';
import type {
DeviceInventoryProvider,
LeaseLifecycleProvider,
Expand All @@ -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)),
);
}

Expand Down
25 changes: 25 additions & 0 deletions src/sdk/android-adb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);
});
Loading
Loading