Skip to content
Merged
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
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);
},
);
44 changes: 18 additions & 26 deletions packages/ad-script/src/internal/script-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import type { SessionAction } from '@agent-device/contracts/session';
import type { SessionRuntimeHints } from '@agent-device/kernel/contracts';
import { appendScreenshotScriptFlags } from '@agent-device/contracts/capture';
import { splitRefGenerationSuffix } from '@agent-device/kernel/snapshot';

const SCRIPT_RUNTIME_PLATFORMS = {
ios: true,
android: true,
harmonyos: true,
} satisfies Record<NonNullable<SessionRuntimeHints['platform']>, true>;

function isScriptRuntimePlatform(
value: unknown,
): value is NonNullable<SessionRuntimeHints['platform']> {
return typeof value === 'string' && Object.hasOwn(SCRIPT_RUNTIME_PLATFORMS, value);
}

/**
* #1076 versioned refs: a recorded ref positional may carry a `~s<generation>`
* pin from the client that issued it (`@e12~s3`). Generations are meaningless
Expand Down Expand Up @@ -164,19 +177,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 (isScriptRuntimePlatform(flags.platform)) {
parts.push('--platform', flags.platform);
}
if (typeof flags.metroHost === 'string' && flags.metroHost.length > 0) {
Expand Down Expand Up @@ -323,29 +327,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 (isScriptRuntimePlatform(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
145 changes: 145 additions & 0 deletions src/__tests__/remote-connection-harmonyos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { expect, test } from 'vitest';
import fs from 'node:fs';
import {
connectionWorkspace,
createTestClient,
forceConnectFlags,
seedConnectionState,
} from './remote-connection.fixtures.ts';
import {
materializeRemoteConnectionForCommand,
resolveRequestedLeaseBackend,
} 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('HarmonyOS platform resolves to its proxy lease backend', () => {
expect(
resolveRequestedLeaseBackend(
forceConnectFlags({
stateDir: '/tmp/agent-device',
remoteConfig: '/tmp/remote.json',
platform: 'harmonyos',
}),
),
).toBe('harmonyos-instance');
});

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();
});
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
Loading