Skip to content
Open
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
15 changes: 13 additions & 2 deletions packages/contracts/src/platform-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device';
import type { HostEnvironment } from '@agent-device/kernel/source-value';
import type { PlatformGatedProviderResolverKey } from './platform-providers.ts';
import type { Interactor, RunnerContext } from './interactor-types.ts';

Expand Down Expand Up @@ -31,8 +32,18 @@ export type PlatformPlugin = {
readonly platforms: readonly Platform[];
/** The multi-platform family selector, when the plugin owns more than one leaf (`apple`). */
readonly familySelector?: PlatformSelector;
/** Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm. */
createInteractor(device: DeviceInfo, runner: RunnerContext): Promise<Interactor>;
/**
* Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm.
* `host` is the environment the calling runtime captured at its entry point. Plugins are
* module-level and shared by every runtime in a process, so the host arrives per call — never
* bound at plugin construction — and is the same object the runtime's deps and request-bound
* facets carry.
*/
createInteractor(
device: DeviceInfo,
runner: RunnerContext,
host: HostEnvironment,
): Promise<Interactor>;
/**
* The request-scope provider facet (issue #974). `platformGatedResolvers`
* declares which PLATFORM-GATED request provider resolvers apply to this family's
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/platform-runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { DeviceInfo, Platform } from '@agent-device/kernel/device';
import type { JsonObject, JsonValue } from './json.ts';
import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts';

// The composition root names the runtime host through this contract rather than the kernel
// substrate directly (R13): one host object is the whole configuration surface a runtime reads.
export type { HostEnvironment } from '@agent-device/kernel/source-value';

export type HostCommandRequest = Readonly<{
executable: string;
args: readonly string[];
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts';
import type { AppInventoryRuntimeOperations } from './app-inventory-runtime.ts';
import type { HostEnvironment } from '@agent-device/kernel/source-value';
import type {
AndroidAppDeploymentExecutor,
AppDeploymentRuntimeOperations,
Expand Down Expand Up @@ -830,6 +831,11 @@ export const keyboardRuntimePlanUses = Object.freeze([
export type PlatformRuntimeHost = AppLogRuntimeHost &
NetworkRuntimeHost &
Readonly<{
/**
* The host this runtime's entry point captured. Owners read configuration from it; it is the
* same object the runtime's deps carry, never a re-capture.
*/
hostEnvironment: HostEnvironment;
/** Focused native ports; deployment semantics remain in the owning family packages. */
appleDeployment: AppleAppDeploymentExecutor;
androidDeployment: AndroidAppDeploymentExecutor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { unavailableDeviceRuntimeGateway } from './unavailable-device-runtime-ga
* that cares about one ask overrides just that member — per case through the `overrides`
* argument, or by mocking this module's exported spy — and asserts the daemon's branching on
* the answer. What the composed members actually do (surface, open-target, readiness, and boot
* rules over the platform packages) is owned by the composition lane, which builds the real
* port through `src/__tests__/daemon-composition/platform-services-fixture.ts`. Members are
* rules over the platform packages) is owned by the composition lane, which builds the real port
* in `src/daemon-composition.ts`. Members are
* module-level stable references so two fixture instances deep-compare equal.
*/
export function daemonPlatformServicesFixture(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{
// runner underneath is the suite's own mock.
const runnerOptions = { signal: requestSignal };
const appleInteractor = isApplePlatform(device.platform)
? await applePlugin.createInteractor(device, runnerOptions)
? await applePlugin.createInteractor(device, runnerOptions, {
env: process.env,
cwd: process.cwd(),
})
: undefined;
const alertOptions = (input: AlertRuntimeInput) => ({
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
Expand Down
1 change: 1 addition & 0 deletions packages/host-kit/src/host-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
hostFileStat,
hostFileStatSync,
hostHomeDirectory,
hostHomeDirectoryFromEnv,
hostTemporaryDirectory,
makeHostTemporaryDirectory,
readHostBinaryFile,
Expand Down
12 changes: 12 additions & 0 deletions packages/host-kit/src/internal/host-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ export function hostHomeDirectory(): string {
return os.homedir();
}

/**
* The home directory a named host names for itself: its own HOME (or USERPROFILE on Windows).
* Returns undefined when the host env names no home; callers must fail closed or skip the
* home-bound lookup, never fall back to `os.homedir()`, because the process home is an ambient
* read that #3753 forbids. Derive per-runtime home config through this (#3794).
*/
export function hostHomeDirectoryFromEnv(
env: Record<string, string | undefined>,
): string | undefined {
return env['HOME'] ?? env['USERPROFILE'];
}

export function hostTemporaryDirectory(): string {
return os.tmpdir();
}
Expand Down
102 changes: 80 additions & 22 deletions packages/platform-apple/src/__tests__/alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ afterEach(() => {
test('a read spends the family default, not the caller window', async () => {
mockRunner.mockResolvedValue({ title: 'Camera Access' });

await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 });
await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 }, process.env);

assert.equal(mockRunner.mock.calls.length, 1);
assert.deepEqual(mockRunner.mock.calls[0]?.[1], {
Expand All @@ -62,7 +62,12 @@ test('a wait polls until one attempt answers, and the first attempt gets the who
return { title: 'Camera Access' };
});

const result = await awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 });
const result = await awaitAppleAlert(
IOS_SIMULATOR,
runnerOptions,
{ timeoutMs: 5_000 },
process.env,
);

assert.deepEqual(result, { title: 'Camera Access' });
assert.equal(calls, 2);
Expand All @@ -76,7 +81,12 @@ test('a wait that never sees an alert reports the timeout rather than the last a
vi.useFakeTimers();
mockRunner.mockRejectedValue(runnerAbsence());

const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 900 }).then(
const outcome = awaitAppleAlert(
IOS_SIMULATOR,
runnerOptions,
{ timeoutMs: 900 },
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -93,7 +103,13 @@ test('an accept retries only while the backend says the alert is not there yet',
throw new AppError('COMMAND_FAILED', 'runner crashed');
});

const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then(
const outcome = actOnAppleAlert(
IOS_SIMULATOR,
runnerOptions,
'accept',
undefined,
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -107,7 +123,13 @@ test('an exhausted accept carries the scoped-snapshot fallback the agent needs n
vi.useFakeTimers();
mockRunner.mockRejectedValue(runnerAbsence());

const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then(
const outcome = actOnAppleAlert(
IOS_SIMULATOR,
runnerOptions,
'accept',
undefined,
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -130,7 +152,12 @@ test('a wait propagates a non-absence failure instead of spending it as poll bud
throw new AppError('COMMAND_FAILED', 'runner transport closed: alert not found');
});

const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }).then(
const outcome = awaitAppleAlert(
IOS_SIMULATOR,
runnerOptions,
{ timeoutMs: 5_000 },
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -148,7 +175,13 @@ test('an action does not retry a failure that only reads like an absence', async
throw new AppError('COMMAND_FAILED', 'no alert service on this runner');
});

const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'dismiss').then(
const outcome = actOnAppleAlert(
IOS_SIMULATOR,
runnerOptions,
'dismiss',
undefined,
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -168,7 +201,13 @@ test('the macOS helper states absence as a typed reason, and the family retries
throw helperAbsence();
});

const outcome = actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'accept').then(
const outcome = actOnAppleAlert(
MACOS_DEVICE,
runnerOptions,
'accept',
undefined,
process.env,
).then(
() => undefined,
(error: unknown) => error,
);
Expand All @@ -183,24 +222,37 @@ test('the macOS helper states absence as a typed reason, and the family retries
test('the macOS host reads through its helper, and a frontmost-app session names no bundle', async () => {
mockHelper.mockResolvedValue({ title: 'Allow access' });

await readAppleAlert(MACOS_DEVICE, runnerOptions, {
surface: 'frontmost-app',
appBundleId: 'com.example.app',
});
await readAppleAlert(
MACOS_DEVICE,
runnerOptions,
{
surface: 'frontmost-app',
appBundleId: 'com.example.app',
},
process.env,
);

assert.equal(mockRunner.mock.calls.length, 0);
assert.deepEqual(mockHelper.mock.calls[0], ['get', { surface: 'frontmost-app' }]);
assert.deepEqual(mockHelper.mock.calls[0], [
'get',
{ surface: 'frontmost-app', env: process.env },
]);
});

// The narrowing is the macOS helper's alone. A non-macOS Apple leaf keeps the session bundle
// whatever the surface says, exactly as the retired route passed it to the XCTest runner.
test('the XCTest runner keeps the session bundle even on a frontmost-app surface', async () => {
mockRunner.mockResolvedValue({ title: 'Camera Access' });

await readAppleAlert(IOS_SIMULATOR, runnerOptions, {
surface: 'frontmost-app',
appBundleId: 'com.example.app',
});
await readAppleAlert(
IOS_SIMULATOR,
runnerOptions,
{
surface: 'frontmost-app',
appBundleId: 'com.example.app',
},
process.env,
);

assert.deepEqual(mockRunner.mock.calls[0]?.[1], {
command: 'alert',
Expand All @@ -213,13 +265,19 @@ test('the XCTest runner keeps the session bundle even on a frontmost-app surface
test('a macOS app session forwards the bundle its surface is scoped to', async () => {
mockHelper.mockResolvedValue({ action: 'dismiss' });

await actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'dismiss', {
surface: 'app',
appBundleId: 'com.example.app',
});
await actOnAppleAlert(
MACOS_DEVICE,
runnerOptions,
'dismiss',
{
surface: 'app',
appBundleId: 'com.example.app',
},
process.env,
);

assert.deepEqual(mockHelper.mock.calls[0], [
'dismiss',
{ bundleId: 'com.example.app', surface: 'app' },
{ bundleId: 'com.example.app', surface: 'app', env: process.env },
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,17 @@ const SCOPED_SITES: Record<string, ScopedDrive> = {
],
'macos.screenshot-runner.fullscreen': [
MACOS_DEVICE,
(outPath) => captureScreenshotViaRunner(MACOS_DEVICE, outPath, APP, true),
(outPath) =>
captureScreenshotViaRunner(MACOS_DEVICE, outPath, APP, true, undefined, process.env),
],
'ios-device.physical-device-screenshot.coredevice-file': [
IOS_DEVICE,
(outPath) => captureScreenshotViaRunner(IOS_DEVICE, outPath, APP, true),
(outPath) => captureScreenshotViaRunner(IOS_DEVICE, outPath, APP, true, undefined, process.env),
],
'ios-device.physical-device-screenshot.xctest-inline': [
XCTEST_DEVICE,
(outPath) => captureScreenshotViaRunner(XCTEST_DEVICE, outPath, APP),
(outPath) =>
captureScreenshotViaRunner(XCTEST_DEVICE, outPath, APP, undefined, undefined, process.env),
],
'ios-device.physical-device-control.activate': [
XCTEST_DEVICE,
Expand All @@ -223,14 +225,20 @@ test('every Apple request site builds exactly its golden runner request', async
for (const [name, [device, drive]] of Object.entries(INTERACTOR_SITES)) {
const calls: RecordedRunnerCall[] = [];
await drive(
createAppleInteractor(device, { appBundleId: APP }, recordingRunnerProvider(calls)),
createAppleInteractor(
device,
{ appBundleId: APP },
{ env: process.env, cwd: process.cwd() },
recordingRunnerProvider(calls),
),
);
captured.push([name, onlyRequest(name, calls)]);
}
const observationCalls: RecordedRunnerCall[] = [];
await createAppleInteractor(
IOS_SIMULATOR,
{ appBundleId: APP },
{ env: process.env, cwd: process.cwd() },
recordingRunnerProvider(observationCalls, {
snapshot: {
nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ function reachedBackend(): MacOsSurfaceBackend {
test.each(SURFACE_ROWS)(
'every macOS operation on the %s surface reaches the %s backend',
async (surface, backend) => {
const interactor = createAppleInteractor(macOsDevice, {});
const interactor = createAppleInteractor(
macOsDevice,
{},
{ env: process.env, cwd: process.cwd() },
);
const operations: Record<string, () => Promise<unknown>> = {
screenshot: async () => await interactor.screenshot('/tmp/out.png', { surface }),
snapshot: async () => await interactor.snapshot({ surface }),
Expand All @@ -139,7 +143,11 @@ test.each(SURFACE_ROWS)(
test.each(HELPER_ROWS)(
'refuses an explicit --fullscreen on the macOS %s surface before any capture',
async (surface) => {
const interactor = createAppleInteractor(macOsDevice, {});
const interactor = createAppleInteractor(
macOsDevice,
{},
{ env: process.env, cwd: process.cwd() },
);

await expect(
interactor.screenshot('/tmp/out.png', { surface, fullscreen: true }),
Expand All @@ -158,19 +166,27 @@ test.each(HELPER_ROWS)(
test.each(HELPER_ROWS)(
'captures the %s surface through the helper when --fullscreen is not requested',
async (surface) => {
const interactor = createAppleInteractor(macOsDevice, {});
const interactor = createAppleInteractor(
macOsDevice,
{},
{ env: process.env, cwd: process.cwd() },
);

await interactor.screenshot('/tmp/out.png', { surface });

expect(runMacOsScreenshotAction).toHaveBeenCalledOnce();
const [, options] = vi.mocked(runMacOsScreenshotAction).mock.calls[0]!;
expect(options).toEqual({ surface });
expect(options).toEqual({ surface, env: process.env });
expect(Object.hasOwn(options ?? {}, 'fullscreen')).toBe(false);
},
);

test('keeps a macOS app session on the runner path with --fullscreen unchanged', async () => {
const interactor = createAppleInteractor(macOsDevice, {});
const interactor = createAppleInteractor(
macOsDevice,
{},
{ env: process.env, cwd: process.cwd() },
);

await interactor.screenshot('/tmp/out.png', { surface: 'app', fullscreen: true });

Expand Down
Loading
Loading