Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
are unchanged at schema v2. Two notes for mixed installations: a daemon older than this release
reads a v3 file as an unreadable claim record and fails closed rather than clearing it, and
`devices` reports no `claimedBy` for such a device until the managed-inventory filter lands.
- Added the `harmonyos-instance` lease contract and CLI/runtime plumbing as a prerequisite for
HarmonyOS proxy support; provider/daemon allocation remains gated until its end-to-end lifecycle
is implemented and validated (#2266).

- Fixed: `settings airplane on|off` now takes an Android device offline. It is applied through
the connectivity service (`cmd connectivity airplane-mode`), which drives the radios, instead of
writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` — a broadcast Android
Expand Down
19 changes: 19 additions & 0 deletions packages/ad-script/src/internal/__tests__/script-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from 'vitest';
import type { SessionAction } from '@agent-device/contracts/session';
import { formatPortableActionLine } from '../script-formatting.ts';
import { parseReplayScriptDetailed } from '../script.ts';

test.each(['ios', 'android', 'harmonyos'] as const)(
'%s runtime survives open and runtime set script roundtrips',
(platform) => {
const runtime = { platform, metroHost: 'localhost', metroPort: 8081 };
const actions: SessionAction[] = [
{ ts: 0, command: 'runtime', positionals: ['set'], flags: runtime },
{ ts: 1, command: 'open', positionals: ['Demo'], flags: {}, runtime },
];
const script = actions.map((action) => formatPortableActionLine(action)).join('\n');
const parsed = parseReplayScriptDetailed(script);
expect(parsed.actions[0]?.flags).toMatchObject(runtime);
expect(parsed.actions[1]?.runtime).toMatchObject(runtime);
},
);
32 changes: 6 additions & 26 deletions packages/ad-script/src/internal/script-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionAction } from '@agent-device/contracts/session';
import { isSessionRuntimePlatform, type SessionRuntimeHints } from '@agent-device/kernel/contracts';
import { appendScreenshotScriptFlags } from '@agent-device/contracts/capture';
import { splitRefGenerationSuffix } from '@agent-device/kernel/snapshot';

Expand Down Expand Up @@ -164,19 +165,10 @@ export function appendScriptSeriesFlags(

export function appendRuntimeHintFlags(
parts: string[],
flags:
| Pick<SessionAction, 'flags'>['flags']
| {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
}
| undefined,
flags: Pick<SessionAction, 'flags'>['flags'] | SessionRuntimeHints | undefined,
): void {
if (!flags) return;
if (flags.platform === 'ios' || flags.platform === 'android') {
if (isSessionRuntimePlatform(flags.platform)) {
parts.push('--platform', flags.platform);
}
if (typeof flags.metroHost === 'string' && flags.metroHost.length > 0) {
Expand Down Expand Up @@ -323,29 +315,17 @@ export function parseReplaySeriesFlags(
// fallow-ignore-next-line complexity
export function parseReplayRuntimeFlags(args: string[]): {
positionals: string[];
flags: {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
};
flags: SessionRuntimeHints;
} {
const positionals: string[] = [];
const flags: {
platform?: 'ios' | 'android';
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
launchUrl?: string;
} = {};
const flags: SessionRuntimeHints = {};

for (let index = 0; index < args.length; index += 1) {
const token = args[index]!;
const nextArg = args[index + 1];
if (token === '--platform' && nextArg !== undefined) {
const platform = nextArg;
if (platform === 'ios' || platform === 'android') {
if (isSessionRuntimePlatform(platform)) {
flags.platform = platform;
}
index += 1;
Expand Down
18 changes: 15 additions & 3 deletions packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ export type { AppErrorCode } from './errors.ts';
export { defaultHintForCode, normalizeError } from './errors.ts';
import type { PlatformSelector } from './device.ts';

const SESSION_RUNTIME_PLATFORMS = ['ios', 'android', 'harmonyos'] as const;
export type SessionRuntimePlatform = (typeof SESSION_RUNTIME_PLATFORMS)[number];

export function isSessionRuntimePlatform(value: unknown): value is SessionRuntimePlatform {
return SESSION_RUNTIME_PLATFORMS.some((platform) => platform === value);
}

export type SessionRuntimeHints = {
platform?: 'ios' | 'android';
platform?: SessionRuntimePlatform;
metroHost?: string;
Comment on lines 14 to 16
Comment on lines 14 to 16
metroPort?: number;
bundleUrl?: string;
Expand Down Expand Up @@ -44,7 +51,12 @@ export type LocalInstallSource = Extract<DaemonInstallSource, { kind: 'url' | 'p

const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const;
export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number];
const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance'] as const;
const LEASE_BACKENDS = [
'ios-simulator',
'ios-instance',
'android-instance',
'harmonyos-instance',
] as const;
Comment on lines +54 to +59
export type LeaseBackend = (typeof LEASE_BACKENDS)[number];
const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const;
export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number];
Expand Down Expand Up @@ -272,7 +284,7 @@ function optionalEnum<T extends string>(
export const daemonRuntimeSchema = schema<SessionRuntimeHints>((input, path) => {
const record = expectObject(input, path);
return {
platform: optionalEnum(record, 'platform', ['ios', 'android'] as const, path),
platform: optionalEnum(record, 'platform', SESSION_RUNTIME_PLATFORMS, path),
metroHost: optionalString(record, 'metroHost', path),
metroPort: optionalInteger(record, 'metroPort', path),
bundleUrl: optionalString(record, 'bundleUrl', path),
Expand Down
129 changes: 129 additions & 0 deletions src/__tests__/remote-connection-harmonyos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { expect, test } from 'vitest';
import fs from 'node:fs';
import {
connectionWorkspace,
createTestClient,
seedConnectionState,
} from './remote-connection.fixtures.ts';
import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts';
import { disconnectCommand } from '../cli/commands/connection.ts';
import { readRemoteConnectionState } from '../remote/remote-connection-state.ts';
import { LeaseRegistry } from '../daemon/lease-registry.ts';

test.each(['apple', 'harmonyos', 'ios', 'android'] as const)(
'stored Harmony runtime compatibility respects %s selection',
async (platform) => {
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
seedConnectionState({
stateDir,
state: {
session: 'runtime-compat',
remoteConfigPath,
daemon: { baseUrl: 'https://daemon.example' },
tenant: 'proxy',
runId: 'compat-run',
leaseId: 'compat-existing',
leaseBackend: 'harmonyos-instance',
runtime: { platform: 'harmonyos', launchUrl: 'demo://open' },
},
});
const materialized = await materializeRemoteConnectionForCommand({
command: 'snapshot',
client: createTestClient(),
flags: {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
session: 'runtime-compat',
platform,
},
});
if (platform === 'apple' || platform === 'harmonyos') {
expect(materialized.runtime?.platform).toBe('harmonyos');
} else {
expect(materialized.runtime).toBeUndefined();
}
},
);

test('proxy HarmonyOS inventory materializes a scoped lease and closes that same lease', async () => {
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-proxy-');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
seedConnectionState({
stateDir,
state: {
session: 'harmony-proxy',
remoteConfigPath,
daemon: { baseUrl: 'https://daemon.example' },
tenant: 'proxy',
runId: 'harmony-run',
leaseProvider: 'proxy',
clientId: 'harmony-client',
},
});
const registry = new LeaseRegistry();
const allocated: string[] = [];
const released: string[] = [];
const client = createTestClient({
listDevices: async () => [
{
platform: 'harmonyos',
target: 'mobile',
kind: 'emulator',
id: '127.0.0.1:16001',
name: 'Harmony Emulator',
booted: true,
identifiers: { serial: '127.0.0.1:16001' },
harmonyos: { serial: '127.0.0.1:16001' },
},
],
allocate: async (request) => {
expect(request.leaseBackend).toBe('harmonyos-instance');
expect(request.deviceKey).toBe('harmonyos:mobile:127.0.0.1:16001');
const lease = registry.allocateLease({ ...request, tenantId: request.tenant });
allocated.push(lease.leaseId);
return lease;
},
heartbeat: async (request) => registry.heartbeatLease({ ...request, tenantId: request.tenant }),
release: async (request) => {
const result = registry.releaseLease({ ...request, tenantId: request.tenant });
expect(result.released).toBe(true);
released.push(request.leaseId);
return result;
},
});
const flags = {
json: true,
help: false,
version: false,
stateDir,
remoteConfig: remoteConfigPath,
session: 'harmony-proxy',
};
const materialized = await materializeRemoteConnectionForCommand({
command: 'open',
flags,
client,
});
expect(materialized.flags.platform).toBe('harmonyos');
expect(materialized.flags.serial).toBe('127.0.0.1:16001');
expect(materialized.flags.leaseBackend).toBe('harmonyos-instance');
expect(materialized.flags.leaseId).toBe(allocated[0]);
expect(readRemoteConnectionState({ stateDir, session: flags.session })?.leaseId).toBe(
allocated[0],
);
expect(() =>
registry.assertLeaseAdmission({
leaseId: allocated[0],
tenantId: 'proxy',
runId: 'harmony-run',
leaseBackend: 'android-instance',
}),
).toThrow();
await disconnectCommand({ positionals: [], flags, client });
expect(released).toEqual(allocated);
expect(readRemoteConnectionState({ stateDir, session: flags.session })).toBeNull();
});
15 changes: 15 additions & 0 deletions src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import {
materializeRemoteConnectionForCommand,
CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS,
PROXY_REMOTE_LEASE_TTL_MS,
resolveRequestedLeaseBackend,
} from '../cli/commands/connection-runtime.ts';

import { stopMetroCompanion } from '../metro/client-metro-companion.ts';
import { AppError } from '@agent-device/kernel/errors';
import {
Expand All @@ -45,6 +47,19 @@ import {
} from '../remote/remote-connection-state.ts';
import type { AgentDeviceClient } from '../agent-device-client.ts';

test('HarmonyOS platform resolves to its proxy lease backend', () => {
assert.equal(
resolveRequestedLeaseBackend(
forceConnectFlags({
stateDir: '/tmp/agent-device',
remoteConfig: '/tmp/remote.json',
platform: 'harmonyos',
}),
),
'harmonyos-instance',
);
});

afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
Expand Down
14 changes: 10 additions & 4 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
import { profileToCliFlags } from '../remote-config-flags.ts';
import type { BatchStep } from '@agent-device/contracts/client';
import { AppError } from '@agent-device/kernel/errors';
import type { LeaseBackend, SessionRuntimeHints } from '@agent-device/kernel/contracts';
import {
isSessionRuntimePlatform,
type LeaseBackend,
type SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import type { CliFlags } from '@agent-device/contracts/command';
import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts';
import type { CloudProviderSessionResult } from '@agent-device/contracts/observability';
Expand Down Expand Up @@ -689,6 +693,7 @@ export function resolveRequestedLeaseBackend(flags: CliFlags): LeaseBackend | un
if (flags.leaseBackend) return flags.leaseBackend;
if (flags.platform === 'android') return 'android-instance';
if (flags.platform === 'ios') return 'ios-instance';
if (flags.platform === 'harmonyos') return 'harmonyos-instance';
return undefined;
}

Expand All @@ -697,7 +702,7 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa
if (leaseBackend) return leaseBackend;
throw new AppError(
'INVALID_ARGS',
`${command} requires --platform ios|android or --lease-backend when the remote connection has not resolved a lease yet.`,
`${command} requires --platform ios|android|harmonyos or --lease-backend when the remote connection has not resolved a lease yet.`,
);
}

Expand Down Expand Up @@ -733,7 +738,7 @@ function isRuntimeCompatibleWithPlatform(
runtime: SessionRuntimeHints,
platform: CliFlags['platform'],
): boolean {
if (!runtime.platform || !platform || (platform !== 'ios' && platform !== 'android')) {
if (!runtime.platform || !platform || !isSessionRuntimePlatform(platform)) {
return true;
}
return runtime.platform === platform;
Expand Down Expand Up @@ -887,7 +892,7 @@ function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void
flags.udid = device.id;
return;
}
if (device.platform === 'android') {
if (device.platform === 'android' || device.platform === 'harmonyos') {
flags.serial = device.id;
}
}
Expand Down Expand Up @@ -931,6 +936,7 @@ function buildProxyDeviceKey(device: DeviceInfo): string {
function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined {
if (isIosFamily(device)) return 'ios-instance';
if (device.platform === 'android') return 'android-instance';
if (device.platform === 'harmonyos') return 'harmonyos-instance';
return undefined;
}

Expand Down
20 changes: 19 additions & 1 deletion src/client/client-normalizers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import assert from 'node:assert/strict';
import { expect, test } from 'vitest';
import { normalizeDevice, normalizeOpenForegroundComposition } from './client-normalizers.ts';
import {
normalizeDevice,
normalizeOpenForegroundComposition,
normalizeRuntimeHints,
} from './client-normalizers.ts';

test.each(['ios', 'android', 'harmonyos'])('runtime response preserves %s platform', (platform) => {
expect(normalizeRuntimeHints({ platform, launchUrl: 'demo://open' })).toMatchObject({
platform,
launchUrl: 'demo://open',
});
});

test.each(['apple', 'unknown', 12])(
'runtime response ignores non-runtime platform %s',
(platform) => {
expect(normalizeRuntimeHints({ platform })?.platform).toBeUndefined();
},
);

test('embedded daemon errors sanitize an untrusted cause before client exposure', () => {
const secret = 'adc_live_remote-secret';
Expand Down
4 changes: 2 additions & 2 deletions src/client/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
isSerialAddressablePlatform,
type AppleOS,
} from '@agent-device/kernel/device';
import type { SessionRuntimeHints } from '@agent-device/kernel/contracts';
import { isSessionRuntimePlatform, type SessionRuntimeHints } from '@agent-device/kernel/contracts';
import { AppError, type DaemonError } from '@agent-device/kernel/errors';
import { sanitizeErrorCause } from '@agent-device/kernel/redaction';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
Expand Down Expand Up @@ -200,7 +200,7 @@ export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | und
const bundleUrl = readOptionalString(value, 'bundleUrl');
const launchUrl = readOptionalString(value, 'launchUrl');
return {
platform: platform === 'ios' || platform === 'android' ? platform : undefined,
platform: isSessionRuntimePlatform(platform) ? platform : undefined,
metroHost,
metroPort,
bundleUrl,
Expand Down
Loading