Skip to content
Open
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
3 changes: 2 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@
},
{
"comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.",
"file": "src/cli/commands/{auth,connection,daemon,device,plugins,proxy,recording,replay,screenshot,takeover}.ts",
"file": "src/cli/commands/{auth,connection,daemon,device,host,plugins,proxy,recording,replay,screenshot,takeover}.ts",
"exports": [
"authCommand",
"pluginsCommand",
Expand All @@ -229,6 +229,7 @@
"connectionCommand",
"daemonCommand",
"deviceCommand",
"hostCommand",
"proxyCommand",
"recordingCommand",
"replayCommand",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set
'daemon',
'debug',
'disconnect',
'host',
'human_control',
'install-from-source',
'lease_allocate',
Expand Down
22 changes: 20 additions & 2 deletions packages/command-registry/src/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
names: ['--host'],
type: 'string',
usageLabel: '--host <host>',
usageDescription: 'Proxy: host interface to bind (default: 127.0.0.1)',
usageDescription: 'Proxy and host: interface to bind (default: 127.0.0.1)',
projectConfig: false,
recorded: false,
},
Expand All @@ -87,7 +87,25 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
min: 1,
max: 65535,
usageLabel: '--port <port>',
usageDescription: 'Proxy: TCP port to bind (default: 0, choose a free port)',
usageDescription: 'Proxy and host: TCP port to bind (default: 0, choose a free port)',
projectConfig: false,
recorded: false,
},
{
key: 'hostTlsCert',
names: ['--tls-cert'],
type: 'string',
usageLabel: '--tls-cert <path>',
usageDescription: 'Host: PEM certificate to serve HTTPS (requires --tls-key)',
projectConfig: false,
recorded: false,
},
{
key: 'hostTlsKey',
names: ['--tls-key'],
type: 'string',
usageLabel: '--tls-key <path>',
usageDescription: 'Host: PEM private key to serve HTTPS (requires --tls-cert)',
projectConfig: false,
recorded: false,
},
Expand Down
11 changes: 11 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1760,6 +1760,17 @@ export const RAW_COMMAND_DESCRIPTORS = [
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'host',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/host.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'proxy',
deviceClaimPolicy: 'none',
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type CliFlags = CloudProviderProfileFields &
daemonServerMode?: DaemonServerMode;
proxyHost?: string;
proxyPort?: number;
hostTlsCert?: string;
hostTlsKey?: string;
tenant?: string;
sessionIsolation?: SessionIsolationMode;
runId?: string;
Expand Down
1 change: 1 addition & 0 deletions scripts/__tests__/help-conformance-topic-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const WAIVED_TOPICS: Record<string, string> = {
cdp: 'JS-heap forensics niche; add cases when heap-guidance regressions show up in practice.',
commands:
'Derived command/configuration reference, not a planning loop; catalog completeness is structurally tested.',
host: 'Operator setup for the Host front-end, not a planning loop; no worker-planning case is defined yet.',
macos: 'macOS surface guidance is thin and stable; no observed planning regressions yet.',
maestro: 'Compatibility reference, not a planning loop; conformance is oracle-tested instead.',
'physical-device': 'Needs device-specific setup guidance; no portable planning task defined yet.',
Expand Down
5 changes: 5 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,11 @@ function summarizeProviderScenarioFlagExclusions() {
'stale',
],
},
{
name: 'Host front-end TLS options',
owner: 'Host server tests (src/cli/host/host-server.test.ts)',
keys: ['hostTlsCert', 'hostTlsKey'],
},
{
name: 'daemon lifecycle control',
owner: 'daemon CLI lifecycle tests',
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
'plugins',
'device',
'disconnect',
'host',
'metro',
'proxy',
'session',
Expand Down Expand Up @@ -726,6 +727,7 @@ function resolveActiveConnectionDefaults(options: {
options.command === 'connection' ||
options.command === 'daemon' ||
options.command === 'plugins' ||
options.command === 'host' ||
options.command === 'proxy'
) {
return null;
Expand Down Expand Up @@ -755,6 +757,7 @@ function shouldResolveRemoteAuth(command: string): boolean {
command !== 'daemon' &&
command !== 'plugins' &&
command !== 'device' &&
command !== 'host' &&
command !== 'proxy'
);
}
Expand Down
142 changes: 142 additions & 0 deletions src/cli/commands/host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { test, vi, type TestContext } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import type net from 'node:net';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { createTestClient } from '../../__tests__/remote-connection.fixtures.ts';
import {
closeLoopbackServer,
listenOnLoopback,
skipWhenLoopbackUnavailable,
} from '../../__tests__/test-utils/loopback.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { ensureDaemon } from '../../daemon-client/daemon-client-lifecycle.ts';
import { hostCommand } from './host.ts';

const servedHosts = vi.hoisted(() => [] as net.Server[]);

vi.mock('../../daemon-client/daemon-client-lifecycle.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../../daemon-client/daemon-client-lifecycle.ts')>()),
ensureDaemon: vi.fn(),
}));

vi.mock('../host/local-daemon.ts', async (importOriginal) => {
const original = await importOriginal<typeof import('../host/local-daemon.ts')>();
return {
...original,
listenOnTcp: async (server: net.Server, bind: { host: string; port: number }) => {
servedHosts.push(server);
return await original.listenOnTcp(server, bind);
},
waitForever: async () => {},
};
});

const DAEMON_TOKEN = 'daemon-token-never-leaves-host';

function startHost(stateDir: string, extraFlags: Record<string, string> = {}) {
return hostCommand({
positionals: [],
flags: { json: true, help: false, version: false, stateDir, ...extraFlags },
client: createTestClient(),
});
}

async function refusalBeforeDaemon(run: Promise<unknown>): Promise<unknown> {
vi.mocked(ensureDaemon).mockClear();
try {
await run;
} catch (error) {
assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`);
assert.equal(vi.mocked(ensureDaemon).mock.calls.length, 0, 'no daemon starts');
return error.details?.reason;
}
assert.fail('expected host to refuse to start');
}

function tlsFiles(stateDir: string) {
const hostTlsCert = path.join(stateDir, 'cert.pem');
const hostTlsKey = path.join(stateDir, 'key.pem');
fs.writeFileSync(hostTlsCert, 'not a certificate\n');
fs.writeFileSync(hostTlsKey, 'not a key\n', { mode: 0o600 });
return { hostTlsCert, hostTlsKey };
}

test('a malformed or insecure credential stops host before any daemon starts', async () => {
for (const mode of [0o600, 0o644]) {
const stateDir = mkdtempForTestSync('agent-device-host-start-');
const hostDir = path.join(stateDir, 'host');
fs.mkdirSync(hostDir, { mode: 0o700 });
const file = path.join(hostDir, 'service-credential.json');
fs.writeFileSync(file, '{}\n', { mode });
fs.chmodSync(file, mode);

const expected = mode === 0o600 ? 'host-credential-invalid' : 'host-credential-insecure';
assert.equal(await refusalBeforeDaemon(startHost(stateDir)), expected);
}
});

test('TLS problems are typed refusals before any daemon starts', async () => {
const cases: Array<[string, (stateDir: string) => Record<string, string>]> = [
['host-tls-incomplete', (stateDir) => ({ hostTlsCert: path.join(stateDir, 'cert.pem') })],
[
'host-tls-unreadable',
(stateDir) => ({
hostTlsCert: path.join(stateDir, 'missing-cert.pem'),
hostTlsKey: path.join(stateDir, 'missing-key.pem'),
}),
],
['host-tls-invalid', tlsFiles],
['host-tls-required', () => ({ proxyHost: '0.0.0.0' })],
];
for (const [reason, flags] of cases) {
const stateDir = mkdtempForTestSync('agent-device-host-start-');
assert.equal(await refusalBeforeDaemon(startHost(stateDir, flags(stateDir))), reason, reason);
}
});

async function startServingHost(t: TestContext, stateDir: string) {
const output = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
await startHost(stateDir);
return JSON.parse(String(output.mock.calls.at(-1)?.[0])).data;
} finally {
output.mockRestore();
for (const server of servedHosts.splice(0)) t.onTestFinished(() => closeLoopbackServer(server));
}
}

function rpc(baseUrl: string, token: string): Promise<Response> {
return fetch(`${baseUrl}/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent_device.command', params: {} }),
});
}

test('a started host serves health and forwards with the daemon token, also after a restart', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;
const daemonAuthorizations: Array<string | undefined> = [];
const daemon = http.createServer((req, res) => {
if (req.url?.endsWith('/rpc')) daemonAuthorizations.push(req.headers.authorization);
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, data: {} } }));
});
const httpPort = await listenOnLoopback(daemon);
t.onTestFinished(() => closeLoopbackServer(daemon));
vi.mocked(ensureDaemon).mockResolvedValue({ info: { httpPort, token: DAEMON_TOKEN } } as never);
const stateDir = mkdtempForTestSync('agent-device-host-start-');

const first = await startServingHost(t, stateDir);
const restarted = await startServingHost(t, stateDir);
const health = await fetch(`${restarted.agentDeviceBaseUrl}/health`);

assert.equal(health.status, 200);
assert.equal((await health.json()).ok, true);
assert.equal(restarted.token, undefined, 'the token shows only on the start that creates it');
assert.equal((await rpc(restarted.agentDeviceBaseUrl, 'not-the-service-token')).status, 401);
assert.equal((await rpc(restarted.agentDeviceBaseUrl, first.token)).status, 200);
assert.deepEqual(daemonAuthorizations, [`Bearer ${DAEMON_TOKEN}`]);
});
Loading