Skip to content

Commit be45ac1

Browse files
committed
fix: narrow install capability projection
1 parent 81a00c8 commit be45ac1

7 files changed

Lines changed: 360 additions & 139 deletions

‎src/core/capabilities.ts‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@ import type {
88
GestureCommandInput,
99
GestureSemanticInput,
1010
} from '@agent-device/contracts/interaction';
11-
import {
12-
assertAppleMultiTouchSupported,
13-
type PlatformRuntimeOperations,
14-
type RuntimeOperationKey,
15-
} from '@agent-device/contracts/platform';
11+
import { assertAppleMultiTouchSupported } from '@agent-device/contracts/platform';
1612

1713
// Populate the PlatformPlugin registry once at module load (idempotent; registers
1814
// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected
@@ -174,24 +170,6 @@ export function listCapabilityCommands(): string[] {
174170
.sort();
175171
}
176172

177-
/**
178-
* Runtime-backed commands deliberately have no capability bucket. Their advertised support is
179-
* determined from the selected owner's facts for this descriptor-declared operation set.
180-
*/
181-
export function requiredRuntimeOperationsForCommand(
182-
command: string,
183-
): ReadonlyArray<readonly RuntimeOperationKey<PlatformRuntimeOperations>[]> | undefined {
184-
const descriptor = commandDescriptors.find((candidate) => candidate.name === command);
185-
if (descriptor?.platformExecution.kind !== 'device-runtime') return undefined;
186-
const uses =
187-
'use' in descriptor.platformExecution
188-
? [descriptor.platformExecution.use]
189-
: descriptor.platformExecution.uses;
190-
return uses.map(
191-
(use) => use.required as readonly RuntimeOperationKey<PlatformRuntimeOperations>[],
192-
);
193-
}
194-
195173
/**
196174
* The platform families that DO support `command`, derived from the capability
197175
* matrix (a family counts when it has at least one supported device kind). Used
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { expect, test } from 'vitest';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { PUBLIC_COMMANDS } from '../../../command-catalog.ts';
5+
import { makeAndroidSession, makeSessionStore } from '../../../__tests__/test-utils/index.ts';
6+
import type {
7+
BindDeviceRuntime,
8+
InspectDeviceRuntimeFacts,
9+
} from '../../request-runtime-binding.ts';
10+
import {
11+
createCapabilitiesAdmissionRuntime,
12+
legacyCapabilityUses,
13+
} from './session-capabilities.fixtures.ts';
14+
import { handleSessionCommands } from './session-command-harness.ts';
15+
16+
test('capabilities projects the install family from exactly one facts inspection', async () => {
17+
const { sessionName, sessionStore } = createAndroidCapabilitiesSession('install-family');
18+
const runtime = createCapabilitiesAdmissionRuntime({
19+
appLogAvailable: true,
20+
networkAvailable: true,
21+
providerMode: 'local',
22+
deployAvailable: true,
23+
sourceAvailable: true,
24+
pushAvailable: true,
25+
readinessAvailable: true,
26+
});
27+
28+
const response = await dispatchCapabilities({
29+
sessionName,
30+
sessionStore,
31+
bindDevice: runtime.bindDevice,
32+
inspectFacts: runtime.inspectFacts,
33+
});
34+
35+
expect(response).toMatchObject({ ok: true });
36+
if (!response?.ok) return;
37+
expect(response.data?.availableCommands).toEqual(
38+
expect.arrayContaining([
39+
PUBLIC_COMMANDS.install,
40+
PUBLIC_COMMANDS.reinstall,
41+
PUBLIC_COMMANDS.installFromSource,
42+
PUBLIC_COMMANDS.push,
43+
]),
44+
);
45+
expect(runtime.inspections).toHaveLength(1);
46+
expect(runtime.uses).toEqual(legacyCapabilityUses);
47+
});
48+
49+
test('capabilities keeps legacy runtime commands outside the install-family facts projection', async () => {
50+
const { sessionName, sessionStore } = createAndroidCapabilitiesSession('legacy-runtime');
51+
const runtime = createCapabilitiesAdmissionRuntime({
52+
appLogAvailable: true,
53+
networkAvailable: true,
54+
providerMode: 'local',
55+
deployAvailable: true,
56+
sourceAvailable: true,
57+
pushAvailable: true,
58+
readinessAvailable: true,
59+
});
60+
61+
const response = await dispatchCapabilities({
62+
sessionName,
63+
sessionStore,
64+
bindDevice: runtime.bindDevice,
65+
inspectFacts: runtime.inspectFacts,
66+
});
67+
68+
expect(response).toMatchObject({ ok: true });
69+
if (!response?.ok) return;
70+
// `bootTarget` facts are unavailable in this fixture. A global descriptor projection
71+
// would hide boot; only this unit's install family is authorized to consume facts.
72+
expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot);
73+
expect(runtime.inspections).toHaveLength(1);
74+
expect(runtime.uses).toEqual(legacyCapabilityUses);
75+
});
76+
77+
test('capabilities fails closed only for install-family projection when facts inspection fails', async () => {
78+
const { sessionName, sessionStore } = createAndroidCapabilitiesSession('facts-failure');
79+
const runtime = createCapabilitiesAdmissionRuntime({
80+
appLogAvailable: true,
81+
networkAvailable: true,
82+
providerMode: 'local',
83+
});
84+
85+
const response = await dispatchCapabilities({
86+
sessionName,
87+
sessionStore,
88+
bindDevice: runtime.bindDevice,
89+
inspectFacts: async () => {
90+
throw new Error('provider facts unavailable');
91+
},
92+
});
93+
94+
expect(response).toMatchObject({ ok: true });
95+
if (!response?.ok) return;
96+
expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot);
97+
expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.logs);
98+
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.install);
99+
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.reinstall);
100+
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.installFromSource);
101+
expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.push);
102+
expect(runtime.uses).toEqual(legacyCapabilityUses);
103+
});
104+
105+
function createAndroidCapabilitiesSession(suffix: string) {
106+
const sessionName = `android-capabilities-${suffix}`;
107+
const sessionStore = makeSessionStore(`agent-device-capabilities-${suffix}-`);
108+
sessionStore.set(sessionName, makeAndroidSession(sessionName));
109+
return { sessionName, sessionStore };
110+
}
111+
112+
async function dispatchCapabilities(params: {
113+
sessionName: string;
114+
sessionStore: ReturnType<typeof makeSessionStore>;
115+
bindDevice: BindDeviceRuntime;
116+
inspectFacts: InspectDeviceRuntimeFacts;
117+
}) {
118+
return await handleSessionCommands({
119+
req: {
120+
token: 't',
121+
session: params.sessionName,
122+
command: PUBLIC_COMMANDS.capabilities,
123+
positionals: [],
124+
flags: {},
125+
},
126+
sessionName: params.sessionName,
127+
logPath: path.join(os.tmpdir(), 'daemon.log'),
128+
sessionStore: params.sessionStore,
129+
bindDevice: params.bindDevice,
130+
inspectFacts: params.inspectFacts,
131+
invoke: async () => ({ ok: true, data: {} }),
132+
});
133+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device';
2+
import {
3+
localRuntimeOwner,
4+
narrowDeviceBinding,
5+
providerRuntimeOwner,
6+
type DeviceBinding,
7+
type PlatformRuntimeOperations,
8+
type RuntimeFacts,
9+
type RuntimeProviderMode,
10+
} from '@agent-device/contracts/platform';
11+
import type {
12+
BindDeviceRuntime,
13+
InspectDeviceRuntimeFacts,
14+
} from '../../request-runtime-binding.ts';
15+
16+
export type CapabilitiesAdmissionRuntimeOptions = Readonly<{
17+
appLogAvailable: boolean;
18+
networkAvailable: boolean;
19+
providerMode: RuntimeProviderMode;
20+
deployAvailable?: boolean;
21+
sourceAvailable?: boolean;
22+
pushAvailable?: boolean;
23+
readinessAvailable?: boolean;
24+
}>;
25+
26+
export const legacyCapabilityUses = [
27+
{ required: [], preferred: ['appLogInspect'] },
28+
{ required: [], preferred: ['networkDump'] },
29+
{ required: [], preferred: ['screenRecordingStart'] },
30+
];
31+
32+
export function createCapabilitiesAdmissionRuntime(options: CapabilitiesAdmissionRuntimeOptions) {
33+
const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = [];
34+
const inspections: DeviceInfo[] = [];
35+
const bindDevice: BindDeviceRuntime = async (device, use) => {
36+
uses.push({ required: [...use.required], preferred: [...use.preferred] });
37+
return narrowDeviceBinding(createAdmissionBinding(device, options), use);
38+
};
39+
const inspectFacts: InspectDeviceRuntimeFacts = async (device) => {
40+
inspections.push(device);
41+
return createAdmissionFacts(device, options);
42+
};
43+
return { bindDevice, inspectFacts, inspections, uses };
44+
}
45+
46+
function createAdmissionFacts(
47+
device: DeviceInfo,
48+
options: CapabilitiesAdmissionRuntimeOptions,
49+
): RuntimeFacts<PlatformRuntimeOperations> {
50+
const unavailable = unavailableOperationFact(options.providerMode);
51+
const available = { available: true } as const;
52+
return {
53+
device: { ...deviceShape(device), providerMode: options.providerMode },
54+
operations: {
55+
appLogInspect: options.appLogAvailable ? available : unavailable,
56+
appLogDoctor: unavailable,
57+
appLogStart: unavailable,
58+
appLogReattach: unavailable,
59+
appLogCleanup: unavailable,
60+
deployApp: options.deployAvailable ? available : unavailable,
61+
materializeAppSource: options.sourceAvailable ? available : unavailable,
62+
deployMaterializedApp: options.sourceAvailable ? available : unavailable,
63+
sendPushNotification: options.pushAvailable ? available : unavailable,
64+
networkDump: options.networkAvailable ? available : unavailable,
65+
screenRecordingStart: unavailable,
66+
screenRecordingReattach: unavailable,
67+
screenRecordingCleanup: unavailable,
68+
ensureReady: options.readinessAvailable ? available : unavailable,
69+
bootTarget: unavailable,
70+
bootTargetHeadless: unavailable,
71+
},
72+
};
73+
}
74+
75+
function createAdmissionBinding(
76+
device: DeviceInfo,
77+
options: CapabilitiesAdmissionRuntimeOptions,
78+
): DeviceBinding<PlatformRuntimeOperations> {
79+
return {
80+
device,
81+
owner:
82+
options.providerMode === 'provider-runtime'
83+
? providerRuntimeOwner('test', 'capabilities')
84+
: localRuntimeOwner(device.platform),
85+
facts: createAdmissionFacts(device, options),
86+
operations: {
87+
...(options.appLogAvailable ? { appLogInspect: inspectAndroidAppLog } : {}),
88+
...(options.networkAvailable ? { networkDump: dumpEmptyAndroidNetwork } : {}),
89+
},
90+
[Symbol.asyncDispose]: async () => {},
91+
};
92+
}
93+
94+
function unavailableOperationFact(providerMode: RuntimeProviderMode) {
95+
return {
96+
available: false as const,
97+
reason:
98+
providerMode === 'provider-runtime'
99+
? ('unsupported-provider-mode' as const)
100+
: ('owner-capability-missing' as const),
101+
};
102+
}
103+
104+
const inspectAndroidAppLog: PlatformRuntimeOperations['appLogInspect'] = async () => ({
105+
backend: 'android',
106+
});
107+
108+
const dumpEmptyAndroidNetwork: PlatformRuntimeOperations['networkDump'] = async (input) => ({
109+
source: 'app-log',
110+
backend: 'android',
111+
dump: {
112+
path: '/tmp/app.log',
113+
exists: false,
114+
scannedLines: 0,
115+
matchedLines: 0,
116+
entries: [],
117+
include: input.include,
118+
limits: {
119+
maxEntries: input.maxEntries,
120+
maxPayloadChars: input.maxPayloadChars,
121+
maxScanLines: input.maxScanLines,
122+
},
123+
},
124+
notes: [],
125+
});

0 commit comments

Comments
 (0)