Skip to content
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
20 changes: 19 additions & 1 deletion packages/contracts/src/daemon-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const DAEMON_HTTP_BASE_PATH = '/agent-device';
export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant';
export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access';
export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only';
/**
* The principal the Host front-end authenticated (ADR 0021 §6). The daemon trusts it only on a
* request that already carries the daemon token, and the proxy never forwards it from a client.
*/
export const DAEMON_HTTP_PRINCIPAL_HEADER = 'x-agent-device-principal';

export function buildDaemonHttpBaseUrl(baseUrl: string): string {
return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH);
Expand Down Expand Up @@ -52,16 +57,27 @@ export function buildDaemonInstanceMismatchRpcResponse<Id>(

export type DaemonHealthPayload = {
ok: true;
service: 'agent-device-daemon' | 'agent-device-proxy';
service: 'agent-device-daemon' | 'agent-device-proxy' | typeof DAEMON_HOST_SERVICE;
version: string;
rpcProtocolVersion: number;
instanceId?: string;
hostArch?: string;
/** The lease backends this daemon admits; a host checks it before relying on one. */
leaseBackends?: readonly string[];
/** Optional capabilities a client checks before sending a request that relies on one. */
features?: readonly DaemonHealthFeature[];
upstream?: unknown;
};

/**
* Host allocates a fresh device per lease from a shape (`--device "iPhone 16"`) instead of a
* local inventory identity (ADR 0021 §5). A client sends a shape only to a peer advertising it.
*/
export const DAEMON_HOST_DEVICE_SHAPE_FEATURE = 'device-shape';
/** The `service` a Host front-end reports, which a client reads to treat `--device` as a type. */
export const DAEMON_HOST_SERVICE = 'agent-device-host';
export type DaemonHealthFeature = typeof DAEMON_HOST_DEVICE_SHAPE_FEATURE;

export function buildDaemonHealthPayload(
service: DaemonHealthPayload['service'],
version: string,
Expand All @@ -70,6 +86,7 @@ export function buildDaemonHealthPayload(
instanceId?: string;
hostArch?: string;
leaseBackends?: readonly string[];
features?: readonly DaemonHealthFeature[];
} = {},
): DaemonHealthPayload {
return {
Expand All @@ -80,6 +97,7 @@ export function buildDaemonHealthPayload(
...(options.instanceId !== undefined ? { instanceId: options.instanceId } : {}),
...(options.hostArch !== undefined ? { hostArch: options.hostArch } : {}),
...(options.leaseBackends !== undefined ? { leaseBackends: options.leaseBackends } : {}),
...(options.features !== undefined ? { features: options.features } : {}),
...(options.upstream !== undefined ? { upstream: options.upstream } : {}),
};
}
47 changes: 47 additions & 0 deletions packages/proxy/src/daemon-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,53 @@ test('rpc reaches the daemon through the supplied upstream transport with the da
expect(payload.echo.params.token).toBe('daemon-secret');
});

test('a client-sent principal header never reaches the daemon', async () => {
const upstream = recordingUpstream(() => Response.json({ jsonrpc: '2.0', id: 1, result: {} }));
const proxy = proxyWith(upstream.fetch);

await proxy.handle(
rpcRequest(
{ jsonrpc: '2.0', id: 1, method: 'agent-device.command', params: {} },
{
headers: {
authorization: 'Bearer client-secret',
'content-type': 'application/json',
'x-agent-device-principal': 'host-svc-attacker',
},
},
),
);

expect(upstream.requests[0]?.headers.has('x-agent-device-principal')).toBe(false);
});

test('an embedder admits authorized rpc params before they are forwarded', async () => {
const upstream = recordingUpstream(async (request) => Response.json(await request.json()));
const proxy = createDaemonProxy({
upstreamBaseUrl: 'http://daemon.internal:4310',
upstreamToken: 'daemon-secret',
clientToken: 'client-secret',
upstreamFetch: upstream.fetch,
admitRpc: ({ params }) =>
params.command === 'refuse'
? new Response(null, { status: 403 })
: { ...params, rewritten: true },
});
const rpc = (command: string) =>
rpcRequest({ jsonrpc: '2.0', id: 1, method: 'agent-device.command', params: { command } });

expect((await proxy.handle(rpc('refuse'))).status).toBe(403);
expect(upstream.requests).toHaveLength(0);
const forwarded = (await (await proxy.handle(rpc('devices'))).json()) as {
params: Record<string, unknown>;
};
expect(forwarded.params).toMatchObject({
command: 'devices',
rewritten: true,
token: 'daemon-secret',
});
});

test('a request without the client token never reaches the upstream transport', async () => {
const upstream = recordingUpstream(() => Response.json({}));
const proxy = proxyWith(upstream.fetch);
Expand Down
38 changes: 37 additions & 1 deletion packages/proxy/src/daemon-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ import {
*/
export type DaemonProxyUpstreamFetch = (request: Request) => Promise<Response>;

/**
* Admits one authorized JSON-RPC request before it is forwarded: return the params to forward,
* possibly rewritten, or a response to answer with instead. It runs after the proxy checked the
* client token, so an embedder applies its own policy without repeating authentication.
*/
export type DaemonProxyRpcAdmission = (
rpc: Readonly<{ id: unknown; method: string; params: Record<string, unknown> }>,
) => Record<string, unknown> | Response;

export type DaemonProxyOptions = {
/** Base URL of the upstream agent-device daemon HTTP server. */
upstreamBaseUrl: string;
Expand All @@ -37,6 +46,7 @@ export type DaemonProxyOptions = {
maxRpcBodyBytes?: number;
upstreamTimeoutMs?: number;
upstreamFetch?: DaemonProxyUpstreamFetch;
admitRpc?: DaemonProxyRpcAdmission;
};

export type DaemonProxy = {
Expand All @@ -51,7 +61,8 @@ export type DaemonProxy = {
handle(request: Request): Promise<Response>;
};

type NormalizedProxyOptions = Required<DaemonProxyOptions>;
type NormalizedProxyOptions = Required<Omit<DaemonProxyOptions, 'admitRpc'>> &
Pick<DaemonProxyOptions, 'admitRpc'>;

const DEFAULT_MAX_RPC_BODY_BYTES = 1024 * 1024;
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5 * 60 * 1000;
Expand Down Expand Up @@ -137,13 +148,37 @@ async function handleProxyRequest(
);
if (staleInstance) return staleInstance;

const admitted = admitRpcBody(rpcBody, options.admitRpc);
if (admitted instanceof Response) return admitted;
rpcBody = admitted;

if (carriesUnbackedHostPathInstallSource(rpcBody)) {
return hostPathInstallSourceRefusedResponse(readJsonRpcId(rpcBody));
}

return await forwardProxyRequest({ request, route, options, rpcBody });
}

/** A body that is not a JSON-RPC request is forwarded as it came, for the daemon to refuse. */
function admitRpcBody(
rpcBody: string | undefined,
admitRpc: DaemonProxyRpcAdmission | undefined,
): string | undefined | Response {
if (rpcBody === undefined || !admitRpc) return rpcBody;
let envelope: Record<string, unknown>;
try {
envelope = JSON.parse(rpcBody) as Record<string, unknown>;
} catch {
return rpcBody;
}
const { method, params } = envelope ?? {};
if (typeof method !== 'string' || !params || typeof params !== 'object') return rpcBody;
const admitted = admitRpc({ id: envelope.id, method, params: params as Record<string, unknown> });
return admitted instanceof Response
? admitted
: JSON.stringify({ ...envelope, params: admitted });
}

async function proxyHealthResponse(
request: Request,
options: NormalizedProxyOptions,
Expand Down Expand Up @@ -316,6 +351,7 @@ function normalizeProxyOptions(options: DaemonProxyOptions): NormalizedProxyOpti
maxRpcBodyBytes: options.maxRpcBodyBytes ?? DEFAULT_MAX_RPC_BODY_BYTES,
upstreamTimeoutMs: options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
upstreamFetch: options.upstreamFetch ?? ((request) => fetch(request)),
admitRpc: options.admitRpc,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export {
createDaemonProxyServer,
type DaemonProxy,
type DaemonProxyOptions,
type DaemonProxyRpcAdmission,
type DaemonProxyUpstreamFetch,
} from './daemon-proxy.ts';
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
Loading