Skip to content

Commit ef01a7b

Browse files
committed
refactor(host): read Linux, Vega, web and HarmonyOS configuration from an explicit host (#3794)
1 parent f6fc4f9 commit ef01a7b

30 files changed

Lines changed: 562 additions & 142 deletions

‎packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{
113113
// runner underneath is the suite's own mock.
114114
const runnerOptions = { signal: requestSignal };
115115
const appleInteractor = isApplePlatform(device.platform)
116-
? await applePlugin.createInteractor(device, runnerOptions)
116+
? await applePlugin.createInteractor(device, runnerOptions, {
117+
env: process.env,
118+
cwd: process.cwd(),
119+
})
117120
: undefined;
118121
const alertOptions = (input: AlertRuntimeInput) => ({
119122
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),

‎packages/platform-harmonyos/src/__tests__/hdc.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ test('ensureHarmonyToolchainPathConfigured defaults to the host environment', as
144144
vi.stubEnv('PATH', '/usr/bin');
145145
vi.stubEnv('HDC_SDK_PATH', toolchains);
146146

147-
await ensureHarmonyToolchainPathConfigured();
147+
await ensureHarmonyToolchainPathConfigured(process.env);
148148

149149
assert.equal(process.env.PATH, [toolchains, '/usr/bin'].join(path.delimiter));
150150
});

‎packages/platform-harmonyos/src/hdc.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
runCmd,
1111
type ExecOptions,
1212
type ExecResult,
13-
commandHostEnvironment,
1413
} from '@agent-device/host-kit/command';
1514

1615
export type HarmonyHdcOptions = Pick<
@@ -49,9 +48,7 @@ export async function runHarmonyShell(
4948
* DevEco's command-line tools do not amend PATH for non-interactive processes.
5049
* Honor the documented roots so the daemon sees the same HDC binary as a shell.
5150
*/
52-
export async function ensureHarmonyToolchainPathConfigured(
53-
env: NodeJS.ProcessEnv = commandHostEnvironment().env,
54-
): Promise<void> {
51+
export async function ensureHarmonyToolchainPathConfigured(env: NodeJS.ProcessEnv): Promise<void> {
5552
const toolchainRoots = [
5653
env.HDC_SDK_PATH,
5754
env.DEVECO_SDK_HOME
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { afterAll, beforeEach, test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { bindTestCommandHost } from '@agent-device/test-kit';
4+
5+
vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
6+
const actual = await importOriginal<typeof import('@agent-device/host-kit/command')>();
7+
return { ...actual, runCmd: vi.fn(), whichCmd: vi.fn() };
8+
});
9+
10+
import { runCmd, whichCmd } from '@agent-device/host-kit/command';
11+
import { createLinuxInteractor } from '../interactor.ts';
12+
import { resetInputToolCache } from '../linux-env.ts';
13+
import type { HostEnvironment } from '@agent-device/kernel/source-value';
14+
15+
bindTestCommandHost();
16+
17+
// Two composing runtimes drive the plain local provider through their own hosts. Each interactor
18+
// keeps its own display decision and input-tool cache: A(X11) → B(Wayland) → A resolves
19+
// xdotool → ydotool → xdotool, against a process environment that claims Wayland for everyone,
20+
// and no cache resets between the calls. The old cache keyed by the default singleton answered
21+
// host A's xdotool for host B (#3794).
22+
23+
const originalPlatform = process.platform;
24+
const originalEnv = { ...process.env };
25+
26+
const mockRunCmd = vi.mocked(runCmd);
27+
const mockWhichCmd = vi.mocked(whichCmd);
28+
29+
function hostWithEnv(env: Record<string, string | undefined>): HostEnvironment {
30+
return { env: { ...process.env, ...env }, cwd: process.cwd() };
31+
}
32+
33+
beforeEach(() => {
34+
Object.defineProperty(process, 'platform', { value: 'linux' });
35+
// The process environment claims a Wayland session for the whole machine; neither host names
36+
// this display, so any answer read from the process instead of the runtime host is wrong here.
37+
process.env['WAYLAND_DISPLAY'] = 'wayland-9';
38+
process.env['XDG_SESSION_TYPE'] = 'wayland';
39+
// Only the process-fallback provider can hold stale input state between tests; host-bound
40+
// providers own their entries and no test resets them.
41+
resetInputToolCache();
42+
mockRunCmd.mockReset();
43+
mockWhichCmd.mockReset();
44+
mockWhichCmd.mockImplementation(async (cmd) => cmd === 'xdotool' || cmd === 'ydotool');
45+
mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
46+
});
47+
48+
afterAll(() => {
49+
Object.defineProperty(process, 'platform', { value: originalPlatform });
50+
for (const key of Object.keys(process.env)) {
51+
if (!(key in originalEnv)) delete process.env[key];
52+
}
53+
Object.assign(process.env, originalEnv);
54+
});
55+
56+
function synthesisCommands(): string[] {
57+
return mockRunCmd.mock.calls
58+
.map(([cmd, args]) => ({ cmd, first: (args as string[])[0] }))
59+
.filter(({ cmd, first }) => cmd !== 'xdotool' || first !== 'getmouselocation')
60+
.map(({ cmd }) => cmd);
61+
}
62+
63+
test('interactors on different hosts keep their own display decision and input-tool cache', async () => {
64+
const hostA = hostWithEnv({ WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: 'x11' });
65+
const hostB = hostWithEnv({ WAYLAND_DISPLAY: 'wayland-1', XDG_SESSION_TYPE: 'wayland' });
66+
const interactorA = createLinuxInteractor(hostA);
67+
const interactorB = createLinuxInteractor(hostB);
68+
69+
// Phase A: the X11 host resolves xdotool and caches the choice.
70+
await interactorA.tap!(1, 2);
71+
assert.deepEqual(synthesisCommands(), ['xdotool', 'xdotool']);
72+
73+
// Phase B: the Wayland host must resolve ydotool even though A cached xdotool first. The old
74+
// singleton-keyed cache answered A's cached xdotool here, against B's own host.
75+
mockRunCmd.mockClear();
76+
await interactorB.tap!(3, 4);
77+
assert.deepEqual(
78+
mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, ...(args as string[])]),
79+
[
80+
['ydotool', 'mousemove', '--absolute', '-x', '3', '-y', '4'],
81+
['ydotool', 'click', '0xC0'],
82+
],
83+
);
84+
85+
// Phase A again: A's cached choice survived B untouched.
86+
mockRunCmd.mockClear();
87+
await interactorA.tap!(5, 6);
88+
assert.deepEqual(synthesisCommands(), ['xdotool', 'xdotool']);
89+
});

‎packages/platform-linux/src/__tests__/tool-provider.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ beforeEach(() => {
2020
test('scoped Linux tool provider handles input discovery and command execution', async () => {
2121
const commands: Array<[string, string[]]> = [];
2222
const provider = createLocalLinuxToolProvider({
23+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
2324
whichCommand: async (cmd) => cmd === 'xdotool',
2425
runCommand: async (cmd, args) => {
2526
commands.push([cmd, args]);
@@ -42,6 +43,7 @@ test('scoped Linux tool provider handles input discovery and command execution',
4243
test('xdotool press skips the --sync move when the pointer already sits on the target', async () => {
4344
const commands: Array<[string, string[]]> = [];
4445
const provider = createLocalLinuxToolProvider({
46+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
4547
whichCommand: async (cmd) => cmd === 'xdotool',
4648
runCommand: async (cmd, args) => {
4749
commands.push([cmd, args]);
@@ -64,6 +66,7 @@ test('xdotool press skips the --sync move when the pointer already sits on the t
6466
test('scoped Linux input provider handles semantic input without host tool discovery', async () => {
6567
const inputCalls: Array<[string, string, string, string]> = [];
6668
const provider = createLocalLinuxToolProvider({
69+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
6770
whichCommand: async (cmd) => {
6871
throw new Error(`unexpected input discovery: ${cmd}`);
6972
},
@@ -92,10 +95,18 @@ test('scoped Linux input provider handles semantic input without host tool disco
9295

9396
test('Linux tool provider scopes do not share cached input tool resolution', async () => {
9497
const providerA = createLocalLinuxToolProvider({
98+
host: {
99+
env: { ...process.env, XDG_SESSION_TYPE: 'x11', WAYLAND_DISPLAY: '' },
100+
cwd: process.cwd(),
101+
},
95102
whichCommand: async (cmd) => cmd === 'xdotool',
96103
runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
97104
});
98105
const providerB = createLocalLinuxToolProvider({
106+
host: {
107+
env: { ...process.env, XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
108+
cwd: process.cwd(),
109+
},
99110
whichCommand: async (cmd) => cmd === 'ydotool',
100111
runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
101112
});
@@ -110,6 +121,10 @@ test('Linux tool provider scopes do not share cached input tool resolution', asy
110121

111122
await withLinuxToolProvider(
112123
createLocalLinuxToolProvider({
124+
host: {
125+
env: { ...process.env, XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
126+
cwd: process.cwd(),
127+
},
113128
...providerB,
114129
runCommand: async (cmd, args) => {
115130
commands.push([cmd, args]);
@@ -130,6 +145,7 @@ test('Linux tool provider scopes do not share cached input tool resolution', asy
130145
test('local Linux desktop provider translates semantic lifecycle calls to host tools', async () => {
131146
const commands: Array<[string, string[], boolean | undefined]> = [];
132147
const provider = createLocalLinuxToolProvider({
148+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
133149
whichCommand: async (cmd) => cmd === 'wmctrl',
134150
runCommand: async (cmd, args, options) => {
135151
commands.push([cmd, args, options?.allowFailure]);
@@ -151,6 +167,7 @@ test('local Linux desktop provider translates semantic lifecycle calls to host t
151167
test('local Linux desktop provider falls back to pkill when wmctrl is unavailable', async () => {
152168
const commands: Array<[string, string[], boolean | undefined]> = [];
153169
const provider = createLocalLinuxToolProvider({
170+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
154171
whichCommand: async () => false,
155172
runCommand: async (cmd, args, options) => {
156173
commands.push([cmd, args, options?.allowFailure]);
@@ -166,6 +183,7 @@ test('local Linux desktop provider falls back to pkill when wmctrl is unavailabl
166183
test('local Linux screenshot provider translates capture to available host tool', async () => {
167184
const commands: Array<[string, string[]]> = [];
168185
const provider = createLocalLinuxToolProvider({
186+
host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() },
169187
whichCommand: async (cmd) => cmd === 'scrot',
170188
runCommand: async (cmd, args) => {
171189
commands.push([cmd, args]);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value';
2+
3+
/** The Linux family's display-server vocabulary, from one detector (#3794). */
4+
export type DisplayServer = 'wayland' | 'x11';
5+
6+
/**
7+
* The display decision bound to one host. A local provider builds it at construction from its
8+
* runtime's host, so input, clipboard and screenshot selection share one answer that no other
9+
* runtime's host can change (#3794).
10+
*/
11+
export type LinuxDisplayConfig = {
12+
detectDisplayServer(): DisplayServer;
13+
};
14+
15+
/** Reads the display server from one host's environment; the only display decision in the family. */
16+
export function detectLinuxDisplayServer(env: Readonly<EnvMap>): DisplayServer {
17+
if (env['WAYLAND_DISPLAY']) return 'wayland';
18+
if (env['XDG_SESSION_TYPE'] === 'wayland') return 'wayland';
19+
return 'x11';
20+
}
21+
22+
export function createLinuxDisplayConfig(host: HostEnvironment): LinuxDisplayConfig {
23+
return Object.freeze({
24+
detectDisplayServer: () => detectLinuxDisplayServer(host.env),
25+
});
26+
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Interactor } from '@agent-device/contracts/interactor-types';
2+
import type { HostEnvironment } from '@agent-device/kernel/source-value';
23

3-
export async function createLinuxInteractor(): Promise<Interactor> {
4+
export async function createLinuxInteractor(host: HostEnvironment): Promise<Interactor> {
45
const { createLinuxInteractor: create } = await import('./interactor.ts');
5-
return create();
6+
return create(host);
67
}

‎packages/platform-linux/src/interactor.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ vi.mock('./index.ts', () => ({
2929
beforeEach(() => vi.clearAllMocks());
3030

3131
test('Linux owns alternate buttons while the runtime binder owns primary series', async () => {
32-
const interactor = createLinuxInteractor();
32+
const interactor = createLinuxInteractor({ env: process.env, cwd: process.cwd() });
3333

3434
await interactor.alternateClick!({ x: 10, y: 20 }, 'secondary');
3535
await interactor.alternateClick!({ x: 30, y: 40 }, 'middle');
@@ -46,7 +46,7 @@ test('Linux executes a planned fling as one drag with the resolved duration', as
4646
'linux',
4747
);
4848

49-
await createLinuxInteractor().performGesture!(plan);
49+
await createLinuxInteractor({ env: process.env, cwd: process.cwd() }).performGesture!(plan);
5050

5151
assert.deepEqual(vi.mocked(swipeLinux).mock.calls, [[10, 20, 110, 20, 100]]);
5252
});

‎packages/platform-linux/src/interactor.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { AppError } from '@agent-device/kernel/errors';
22
import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
3+
import { withMethodScope } from '@agent-device/kernel/scoped-provider';
4+
import type { HostEnvironment } from '@agent-device/kernel/source-value';
5+
import {
6+
hasScopedLinuxToolProvider,
7+
hostBoundLinuxToolProvider,
8+
withLinuxToolProvider,
9+
} from './tool-provider.ts';
310
import {
411
backLinux,
512
captureLinuxSurfaceSnapshot,
@@ -24,7 +31,17 @@ import {
2431
import { singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan';
2532
import type { Interactor } from '@agent-device/contracts/interactor-types';
2633

27-
export function createLinuxInteractor(): Interactor {
34+
export function createLinuxInteractor(host: HostEnvironment): Interactor {
35+
// One host-bound provider per interactor: its display decision and input-tool cache belong to
36+
// the composing runtime, never to the process or another runtime (#3794). A request-scoped
37+
// provider still wins: the host-bound one enters only when the request scopes none.
38+
const provider = hostBoundLinuxToolProvider(host);
39+
return withMethodScope(createLinuxInteractorBody(), async (task) =>
40+
hasScopedLinuxToolProvider() ? await task() : await withLinuxToolProvider(provider, task),
41+
);
42+
}
43+
44+
function createLinuxInteractorBody(): Interactor {
2845
return {
2946
open: (app) => openLinuxApp(app),
3047
openDevice: () => Promise.resolve(),
Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,44 @@
1-
import { commandHostEnvironment } from '@agent-device/host-kit/command';
21
/**
3-
* Shared Linux environment detection — display server and input tool.
2+
* Shared Linux input-tool resolution — display server and input tool.
43
*
5-
* Results are cached after the first probe so that every action
6-
* (press, type, scroll…) does not re-run `which` on every call.
4+
* The display decision comes from the resolved provider's bound host, never an ambient read
5+
* (#3794). The chosen tool is cached per provider, so two runtimes with different hosts resolve
6+
* and keep their own selection.
77
*/
88

99
import { AppError } from '@agent-device/kernel/errors';
10-
import { resolveLinuxToolProvider, type LinuxToolProvider } from './tool-provider.ts';
10+
import {
11+
linuxCommandFallbackToolProvider,
12+
resolveLinuxToolProvider,
13+
type LinuxToolProvider,
14+
} from './tool-provider.ts';
15+
import type { DisplayServer } from './display.ts';
1116

12-
export type DisplayServer = 'wayland' | 'x11';
17+
export type { DisplayServer } from './display.ts';
1318
export type InputTool = 'xdotool' | 'ydotool';
1419

15-
function detectDisplayServer(): DisplayServer {
16-
const environment = commandHostEnvironment().env;
17-
if (environment['WAYLAND_DISPLAY']) return 'wayland';
18-
if (environment['XDG_SESSION_TYPE'] === 'wayland') return 'wayland';
19-
return 'x11';
20-
}
21-
22-
// ── Cached input tool resolution ───────────────────────────────────────
23-
24-
let cachedInputTool: {
25-
tool: InputTool;
26-
display: DisplayServer;
27-
provider: LinuxToolProvider;
28-
} | null = null;
20+
// A WeakMap keyed by the provider object: one cache per host-bound provider, dropped with it.
21+
const cachedInputTools = new WeakMap<
22+
LinuxToolProvider,
23+
{ tool: InputTool; display: DisplayServer }
24+
>();
2925

30-
export async function ensureInputTool(): Promise<{
26+
export async function ensureInputTool(
27+
provider: LinuxToolProvider = resolveLinuxToolProvider(),
28+
): Promise<{
3129
tool: InputTool;
3230
display: DisplayServer;
3331
}> {
34-
const provider = resolveLinuxToolProvider();
35-
if (cachedInputTool?.provider === provider) return cachedInputTool;
32+
const cached = cachedInputTools.get(provider);
33+
if (cached) return cached;
3634

37-
const display = detectDisplayServer();
35+
const display = provider.display.detectDisplayServer();
3836

3937
if (display === 'wayland') {
4038
if (await provider.whichCommand('ydotool')) {
41-
cachedInputTool = { tool: 'ydotool', display, provider };
42-
return cachedInputTool;
39+
const selection = { tool: 'ydotool' as const, display };
40+
cachedInputTools.set(provider, selection);
41+
return selection;
4342
}
4443
throw new AppError(
4544
'TOOL_MISSING',
@@ -48,8 +47,9 @@ export async function ensureInputTool(): Promise<{
4847
}
4948

5049
if (await provider.whichCommand('xdotool')) {
51-
cachedInputTool = { tool: 'xdotool', display, provider };
52-
return cachedInputTool;
50+
const selection = { tool: 'xdotool' as const, display };
51+
cachedInputTools.set(provider, selection);
52+
return selection;
5353
}
5454
throw new AppError(
5555
'TOOL_MISSING',
@@ -58,8 +58,9 @@ export async function ensureInputTool(): Promise<{
5858
}
5959

6060
/**
61-
* @internal Test isolation hook for the cached Linux input tool.
61+
* @internal Test isolation hook for the cached input tool of the process-fallback provider. A
62+
* host-bound provider owns its cache entry and never needs this.
6263
*/
6364
export function resetInputToolCache(): void {
64-
cachedInputTool = null;
65+
cachedInputTools.delete(linuxCommandFallbackToolProvider);
6566
}

0 commit comments

Comments
 (0)