Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
- Added strict `wait absent <selector> [timeoutMs]` polling for zero selector matches. Incomplete,
sparse, truncated, scoped, depth-limited, and Android unreadable captures cannot prove absence;
deadline diagnostics retain typed capture evidence and stable first-match details (#2236).
- 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
6 changes: 3 additions & 3 deletions packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { defaultHintForCode, normalizeError } from './errors.ts';
import type { PlatformSelector } from './device.ts';

export type SessionRuntimeHints = {
platform?: 'ios' | 'android';
platform?: 'ios' | 'android' | 'harmonyos';
metroHost?: string;
Comment on lines 7 to 9
Comment on lines 7 to 9
metroPort?: number;
bundleUrl?: string;
Expand Down Expand Up @@ -44,7 +44,7 @@ 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;
export type LeaseBackend = (typeof LEASE_BACKENDS)[number];
Comment on lines +47 to 48
const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const;
export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number];
Expand Down Expand Up @@ -271,7 +271,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', ['ios', 'android', 'harmonyos'] as const, path),
metroHost: optionalString(record, 'metroHost', path),
metroPort: optionalInteger(record, 'metroPort', path),
bundleUrl: optionalString(record, 'bundleUrl', path),
Expand Down
11 changes: 11 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,15 @@ 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
10 changes: 8 additions & 2 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,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 +698,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 +734,11 @@ function isRuntimeCompatibleWithPlatform(
runtime: SessionRuntimeHints,
platform: CliFlags['platform'],
): boolean {
if (!runtime.platform || !platform || (platform !== 'ios' && platform !== 'android')) {
if (
!runtime.platform ||
!platform ||
(platform !== 'ios' && platform !== 'android' && platform !== 'harmonyos')
) {
return true;
}
return runtime.platform === platform;
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
4 changes: 2 additions & 2 deletions src/commands/cli-grammar/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
key: 'leaseBackend',
names: ['--lease-backend'],
type: 'enum',
enumValues: ['ios-simulator', 'ios-instance', 'android-instance'],
usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance',
enumValues: ['ios-simulator', 'ios-instance', 'android-instance', 'harmonyos-instance'],
usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance|harmonyos-instance',
usageDescription: 'Lease backend for remote tenant connection admission',
},
{
Expand Down
5 changes: 5 additions & 0 deletions src/daemon/__tests__/lease-registry-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import {
createLeaseTtlResolver,
leaseDeviceBindingKey,
normalizeAllocateLeaseRequest,
normalizeLeaseBackend,
} from '../lease-registry-scope.ts';
import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE } from './human-control-fixtures.ts';

test('normalizeLeaseBackend accepts HarmonyOS instance backend', () => {
assert.equal(normalizeLeaseBackend('harmonyos-instance'), 'harmonyos-instance');
});

test('allocation and human control share the exact contention identity', () => {
const scope = normalizeAllocateLeaseRequest(HUMAN_CONTROL_LEASE_REQUEST);
assert.equal(leaseDeviceBindingKey(scope), leaseDeviceBindingKey(HUMAN_CONTROL_SCOPE));
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/lease-registry-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function normalizeRequiredLeaseId(raw: string | undefined): string {
export function normalizeLeaseBackend(raw: string | undefined): LeaseBackend {
const value = (raw ?? '').trim().toLowerCase();
if (!value || value === 'ios-simulator') return 'ios-simulator';
if (value === 'ios-instance' || value === 'android-instance') return value;
if (value === 'ios-instance' || value === 'android-instance' || value === 'harmonyos-instance') return value;
throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`);
}

Expand Down
8 changes: 4 additions & 4 deletions src/daemon/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ function normalizeRuntimePlatformInput(
platform?: RuntimePlatform,
): RuntimePlatform | undefined {
if (value === undefined) return platform;
if (value !== 'ios' && value !== 'android') {
if (value !== 'ios' && value !== 'android' && value !== 'harmonyos') {
throw new AppError(
'INVALID_ARGS',
`Invalid open runtime platform: ${String(value)}. Use "ios" or "android".`,
`Invalid open runtime platform: ${String(value)}. Use "ios", "android", or "harmonyos".`,
);
Comment on lines 88 to 93
}
if (platform && value !== platform) {
Expand All @@ -104,7 +104,7 @@ function normalizeRuntimePlatformInput(
export function toRuntimePlatform(
platform: CommandFlags['platform'] | DeviceInfo['platform'] | 'apple' | undefined,
): RuntimePlatform | undefined {
if (platform === 'ios' || platform === 'android') {
if (platform === 'ios' || platform === 'android' || platform === 'harmonyos') {
return platform;
}
return undefined;
Expand Down Expand Up @@ -215,7 +215,7 @@ function resolveSessionRuntimeHints(
if (runtime.platform && device && !deviceRuntimePlatform) {
throw new AppError(
'INVALID_ARGS',
`Session runtime hints are only supported on iOS and Android sessions, but session "${sessionName}" is bound to ${boundPlatform}.`,
`Session runtime hints are only supported on iOS, Android, and HarmonyOS sessions, but session "${sessionName}" is bound to ${boundPlatform}.`,
);
}
if (runtime.platform && deviceRuntimePlatform && runtime.platform !== deviceRuntimePlatform) {
Expand Down
2 changes: 1 addition & 1 deletion src/remote/remote-config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [
{
key: 'leaseBackend',
type: 'enum',
enumValues: ['ios-simulator', 'ios-instance', 'android-instance'],
enumValues: ['ios-simulator', 'ios-instance', 'android-instance', 'harmonyos-instance'],
},
{ key: 'platform', type: 'enum', enumValues: PLATFORM_SELECTORS },
{ key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] },
Expand Down
21 changes: 18 additions & 3 deletions test/wire-compat/ledger.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@
"packages/kernel/src/contracts.ts#DaemonResponseData": "sha256:f16e61b7f0ea8cdae82349508383af2f97a4c16c8aa21952d833094673f86afc",
"packages/kernel/src/contracts.ts#JsonRpcId": "sha256:5a5fdff1cd85971214117d69a8278d0ecaf21a6fa3f4582155d9b3d9cad1fbd0",
"packages/kernel/src/contracts.ts#JsonRpcRequestEnvelope": "sha256:d6328309fc4f8b310b88680181e46c802329de9b0e45682dd2e0f0c3e8d090e4",
"packages/kernel/src/contracts.ts#LEASE_BACKENDS": "sha256:9fb296dcfd56556e4dd020bcb40f3b70890b274600e634aa2c380d860b38c022",
"packages/kernel/src/contracts.ts#LEASE_BACKENDS": "sha256:6bec996666d1043a8d23e60d5b1e50ef9341909a02f19060a3132bb72dcc3155",
"packages/kernel/src/contracts.ts#LeaseBackend": "sha256:dce397343cda82ce4e3e43b534b1f642cd37568ed304f4783caddc3524c1cae5",
"packages/kernel/src/contracts.ts#RESPONSE_LEVELS": "sha256:cef29c3b56c299e35a7fee8058b90dc4a6f65a46a420e4f11f77c8ca7d0359c8",
"packages/kernel/src/contracts.ts#ResponseCost": "sha256:2a44de54e8b5c886b36da1500e22d634ec7e12961d2c93b689d2b1aa8c15f186",
"packages/kernel/src/contracts.ts#ResponseLevel": "sha256:c7329d28e745b5e69b86aa1a686befa314b98248afbe5e0e2d7d395f0d777f9b",
"packages/kernel/src/contracts.ts#SESSION_ISOLATION_MODES": "sha256:3d4b3048e22bb0ceccc98cf8fd93a87a1a1e2dad96fa03cffff6d0ecedb58689",
"packages/kernel/src/contracts.ts#SessionIsolationMode": "sha256:beea941fe57b000902da28af15a27aa18d0c957408209e3ec372fcf7f605fa7c",
"packages/kernel/src/contracts.ts#SessionRuntimeHints": "sha256:6b945688ad5ab68370ec001d962974e1397c39d47c6f7ca2a65b03f5ccdd91c6",
"packages/kernel/src/contracts.ts#SessionRuntimeHints": "sha256:7e055a99bc6adbb3efc37b0691055f5370f35f8b96ae6af73295ccce61e05dfe",
"packages/kernel/src/contracts.ts#commandRpcParamsSchema": "sha256:d0ce05c236ce5b0b52631ab7b8a75d38859fbfba58cfb28dd3f9d84f2dba2c1f",
"packages/kernel/src/contracts.ts#daemonRuntimeSchema": "sha256:010c171164ce9516e1c1b00e89e698a11842ff9c3bac3f2e8e3814de7c8be78c",
"packages/kernel/src/contracts.ts#daemonRuntimeSchema": "sha256:859eabaf6679d117feb2b4873b6ac69731cc06989645080817d39c43a24deea5",
"packages/kernel/src/contracts.ts#jsonRpcRequestSchema": "sha256:67e6b8a28b39a3883424a565ae7033c336dc2ea6b193e9b32bb98eb3e18dca7c",
"packages/kernel/src/device.ts#PLATFORM_SELECTORS": "sha256:36e9da1cc660c0ddfb4cf52521f7f230341ff20e3ebda3405ba7c1799476c42d",
"packages/kernel/src/device.ts#PlatformSelector": "sha256:61de3f003507ea2f53b396b0146c17670bf674a2db2132e19c462ca6c10cc8fd",
Expand Down Expand Up @@ -227,6 +227,21 @@
"declaration": "src/daemon/client/daemon-client-rpc.ts#appErrorFromDaemonError",
"digest": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea",
"rationale": "#1862 client-side only: this rehydrates the new optional structured `cause` when present. Released daemons that omit it follow the unchanged path, and no request field or existing response field is narrowed."
},
{
"declaration": "packages/kernel/src/contracts.ts#LEASE_BACKENDS",
"digest": "sha256:6bec996666d1043a8d23e60d5b1e50ef9341909a02f19060a3132bb72dcc3155",
"rationale": "#2266 adds the additive harmonyos-instance lease backend literal. Protocol-2 peers ignore the new backend unless explicitly requested; existing iOS/Android values and payloads remain unchanged."
},
{
"declaration": "packages/kernel/src/contracts.ts#SessionRuntimeHints",
"digest": "sha256:7e055a99bc6adbb3efc37b0691055f5370f35f8b96ae6af73295ccce61e05dfe",
"rationale": "#2266 adds the optional HarmonyOS platform value to runtime hints. Existing iOS/Android hints retain their shape and released peers can ignore the additive value."
},
{
"declaration": "packages/kernel/src/contracts.ts#daemonRuntimeSchema",
"digest": "sha256:859eabaf6679d117feb2b4873b6ac69731cc06989645080817d39c43a24deea5",
"rationale": "#2266 broadens runtime-hint validation to accept the additive HarmonyOS platform value; existing protocol-2 runtime hints remain valid and unchanged."
}
]
}