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
6 changes: 5 additions & 1 deletion packages/host-kit/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ export {
openVerifiedFileForRead,
openVerifiedFileForTruncate,
} from './internal/verified-file.ts';
export { expandUserHomePath, resolveUserPath } from './internal/path-resolution.ts';
export {
expandUserHomePath,
resolveUserConfigPath,
resolveUserPath,
} from './internal/path-resolution.ts';
export {
acquireProcessLock,
acquireProcessLockAcquisition,
Expand Down
20 changes: 19 additions & 1 deletion packages/host-kit/src/internal/path-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { expandUserHomePath, resolveUserPath } from './path-resolution.ts';
import { expandUserHomePath, resolveUserConfigPath, resolveUserPath } from './path-resolution.ts';

test('expandUserHomePath expands the current user home prefix', () => {
const env = { HOME: '/tmp/agent-device-home' };
Expand All @@ -23,3 +23,21 @@ test('resolveUserPath expands home-prefixed and absolute paths', () => {
);
assert.equal(resolveUserPath(absolutePath, { cwd: '/tmp/ignored', env }), absolutePath);
});

// The plugin store and the CLI config loader both need this answer, so it is owned here rather
// than behind either of them; these are the three shapes both consumers depend on.
test('resolveUserConfigPath resolves the home, the relocated home, and rejects a relative one', () => {
assert.equal(
resolveUserConfigPath({ HOME: '/tmp/agent-device-home' }),
path.join('/tmp/agent-device-home', '.agent-device', 'config.json'),
);
// `~/...` expands through this same env object, so HOME is pinned here too: omitting it would
// let the expectation drift to `os.homedir()` while the expansion went elsewhere.
assert.equal(
resolveUserConfigPath({ HOME: '/tmp/agent-device-home', AGENT_DEVICE_HOME: '~/relocated' }),
path.join('/tmp/agent-device-home', 'relocated', 'config.json'),
);
assert.throws(() => resolveUserConfigPath({ AGENT_DEVICE_HOME: './relative' }), {
code: 'INVALID_ARGS',
});
});
19 changes: 19 additions & 0 deletions packages/host-kit/src/internal/path-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import type { EnvMap } from '@agent-device/kernel/source-value';

type PathResolutionOptions = {
Expand All @@ -24,3 +25,21 @@ export function resolveUserPath(inputPath: string, options: PathResolutionOption
if (path.isAbsolute(expandedPath)) return expandedPath;
return path.resolve(options.cwd ?? process.cwd(), expandedPath);
}

/**
* The user's `config.json`, from `AGENT_DEVICE_HOME` or `~/.agent-device`.
*
* This lives beside the other expansions rather than in the CLI's config loader because it is the
* same question those two answer — a location named by the environment becomes an absolute path —
* and it answers it with nothing but `env`: no option schema, no flag grammar, no command facet.
* Plugin discovery needs the answer at daemon start and must not acquire the CLI's schema layer to
* get it (`src/plugins/store.ts`), which is a 153-file zone rather than a path helper.
*/
export function resolveUserConfigPath(env: EnvMap): string {
const home = env.AGENT_DEVICE_HOME
? expandUserHomePath(env.AGENT_DEVICE_HOME, { env })
: path.join(expandUserHomePath('~', { env }), '.agent-device');
if (!path.isAbsolute(home))
throw new AppError('INVALID_ARGS', 'AGENT_DEVICE_HOME must be absolute or ~/...');
return path.join(home, 'config.json');
}
8 changes: 6 additions & 2 deletions src/commands/schema/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { pluginHome } from '../../plugins/plugin.fixtures.ts';
import { resolveConfigBackedFlagDefaults, resolveUserConfigPath } from './cli-config.ts';
import { resolveConfigBackedFlagDefaults } from './cli-config.ts';

test('AGENT_DEVICE_HOME relocates user defaults; project and explicit config cannot select plugins', () => {
const { home, env } = pluginHome();
Expand All @@ -22,5 +22,9 @@ test('AGENT_DEVICE_HOME relocates user defaults; project and explicit config can
};
assert.throws(() => resolveConfigBackedFlagDefaults(explicit), { code: 'INVALID_ARGS' });
const relative = { AGENT_DEVICE_HOME: './relative' };
assert.throws(() => resolveUserConfigPath(relative), { code: 'INVALID_ARGS' });
// The rejection is owned by resolveUserConfigPath (host-kit); what this loader owes its own
// callers is surfacing it, which the direct call below would not have proven.
assert.throws(() => resolveConfigBackedFlagDefaults({ ...options, env: relative }), {
code: 'INVALID_ARGS',
});
});
11 changes: 1 addition & 10 deletions src/commands/schema/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isRecord } from '@agent-device/kernel/record';
import { mergeDefinedFlags } from './merge-flags.ts';
import { type FlagKey } from '@agent-device/command-registry/flag-types';
import { projectConfigFlagKeys } from '@agent-device/command-registry/flag-registry';
import { expandUserHomePath, resolveUserPath } from '@agent-device/host-kit/file';
import { resolveUserConfigPath, resolveUserPath } from '@agent-device/host-kit/file';
import {
getConfigurableOptionSpecs,
getOptionSpec,
Expand Down Expand Up @@ -56,15 +56,6 @@ function resolveConfigPaths(
];
}

export function resolveUserConfigPath(env: EnvMap): string {
const home = env.AGENT_DEVICE_HOME
? expandUserHomePath(env.AGENT_DEVICE_HOME, { env })
: path.join(expandUserHomePath('~', { env }), '.agent-device');
if (!path.isAbsolute(home))
throw new AppError('INVALID_ARGS', 'AGENT_DEVICE_HOME must be absolute or ~/...');
return path.join(home, 'config.json');
}

function resolveInputPath(inputPath: string, cwd: string, env: EnvMap): string {
return resolveUserPath(inputPath, { cwd, env });
}
Expand Down
20 changes: 20 additions & 0 deletions src/daemon/__tests__/generic-route-runtime-completeness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,23 @@ test('the routing gap is what an unrouted command actually reaches', async () =>
// Without this the test above would pass for a table that had no arms at all.
expect(await reachesGenericRoutingGap('not-a-generic-route-command')).toBe(true);
});

test('the screenshot arm reaches its leaf through the deferred import', async () => {
// The leaf loads with `await import()` at the arm so the daemon starts without the
// command surface beneath it. A typo'd specifier or a bundler that dropped the chunk
// would fail here as a resolution error, and an arm that skipped the leaf would answer
// with the routing gap — neither reaches the retired-flag refusal, which only the leaf
// itself raises before any admission or device work.
await expect(
resolveGenericRuntimeExecution({
req: {
command: 'screenshot',
token: '',
positionals: [],
flags: { screenshotMaxSize: 1024 },
},
session: { device: { id: 'device-1', platform: 'android', kind: 'device' } },
context: { logPath: '' },
} as unknown as Parameters<typeof resolveGenericRuntimeExecution>[0]),
).rejects.toThrow(/--max-size was removed/);
});
8 changes: 6 additions & 2 deletions src/daemon/generic-runtime-execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ResolvedGenericExecution } from './generic-leaf-execution.ts';
import { resolveBoundFocusRuntime } from './focus-runtime.ts';
import { resolveScreenshotGenericExecution } from './screenshot-runtime.ts';
import { resolveBoundScrollRuntime } from './scroll-runtime.ts';
import type { ScreenshotRuntimeBindings } from './screenshot-runtime-binding.ts';
import type { DaemonCommandContext } from './context.ts';
Expand Down Expand Up @@ -41,8 +40,13 @@ export async function resolveGenericRuntimeExecution(
});
}
switch (params.req.command) {
case 'screenshot':
case 'screenshot': {
// This branch alone owns an in-process command surface and the interaction implementations
// beneath it; the other leaves here are bound runtimes the dispatcher reaches directly, so
// only this one's module is deferred out of the daemon's startup closure.
const { resolveScreenshotGenericExecution } = await import('./screenshot-runtime.ts');
return await resolveScreenshotGenericExecution(params);
}
case 'focus':
return await resolveBoundFocusRuntime({
device: params.session.device,
Expand Down
7 changes: 5 additions & 2 deletions src/plugins/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import path from 'node:path';
import { AppError, normalizeError } from '@agent-device/kernel/errors';
import { isRecord } from '@agent-device/kernel/record';
import { runCmd } from '@agent-device/host-kit/command';
import { acquireProcessLock, publishFileSync } from '@agent-device/host-kit/file';
import {
acquireProcessLock,
publishFileSync,
resolveUserConfigPath,
} from '@agent-device/host-kit/file';
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
import { resolveUserConfigPath } from '../commands/schema/cli-config.ts';
import {
readPluginManifest,
assertUniquePluginProviders,
Expand Down
Loading