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
77 changes: 77 additions & 0 deletions src/__tests__/daemon-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import http from 'node:http';
import { createDaemonProxyServer } from '../remote/daemon-proxy.ts';
import { createDaemonHttpServer } from '../daemon/server/http-server.ts';
import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http.ts';
import {
DAEMON_HTTP_NETWORK_ACCESS_HEADER,
DAEMON_HTTP_PUBLIC_NETWORK_ACCESS,
} from '../daemon/http-contract.ts';
import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts';
import {
closeLoopbackServer,
Expand All @@ -24,6 +30,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)

let upstreamAuth = '';
let upstreamTokenHeader = '';
let upstreamNetworkAccess = '';
let upstreamBody: Record<string, any> | undefined;
const upstream = http.createServer((req, res) => {
if (req.url === '/health') {
Expand All @@ -34,6 +41,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
assert.equal(req.url, '/rpc');
upstreamAuth = String(req.headers.authorization ?? '');
upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
upstreamNetworkAccess = String(req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER] ?? '');
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
Expand Down Expand Up @@ -88,6 +96,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
});
assert.equal(upstreamAuth, 'Bearer daemon-secret');
assert.equal(upstreamTokenHeader, 'daemon-secret');
assert.equal(upstreamNetworkAccess, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS);
assert.equal(upstreamBody?.params?.token, 'daemon-secret');
assert.equal(upstreamBody?.params?.command, 'devices');
} finally {
Expand All @@ -96,6 +105,74 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
}
});

test('proxy enforces public-only Maestro HTTP policy on a local daemon', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

let loopbackRequests = 0;
const loopbackTarget = http.createServer((_req, res) => {
loopbackRequests += 1;
res.end('loopback-secret');
});
const env = { ...process.env };
delete env.AGENT_DEVICE_HTTP_AUTH_HOOK;
delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT;
const daemon = await createDaemonHttpServer({
token: 'daemon-secret',
env,
handleRequest: async (request) => {
const url = request.positionals[0] ?? '';
return {
ok: true,
data: await executeRunScriptHttpRequest({
method: 'GET',
url,
headers: {},
publicNetworkOnly: request.internal?.publicNetworkOnly === true,
}),
};
},
});
const targetPort = await listenOnLoopback(loopbackTarget);
const daemonPort = await listenOnLoopback(daemon);
const proxy = createDaemonProxyServer({
upstreamBaseUrl: `http://127.0.0.1:${daemonPort}`,
upstreamToken: 'daemon-secret',
clientToken: 'proxy-secret',
});

try {
const proxyPort = await listenOnLoopback(proxy);
const post = async (url: string) => {
const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer proxy-secret' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'proxy-trust',
method: 'agent_device.command',
params: {
token: 'proxy-secret',
command: 'run_script_http',
positionals: [url],
flags: {},
},
}),
});
return { status: response.status, body: (await response.json()) as Record<string, any> };
};

const loopbackResponse = await post(`http://127.0.0.1:${targetPort}/secret`);
assert.equal(loopbackResponse.status, 400, JSON.stringify(loopbackResponse.body));
assert.equal(loopbackResponse.body.error?.data?.code, 'INVALID_ARGS');
assert.match(loopbackResponse.body.error?.message ?? '', /non-public address/);
assert.equal(loopbackRequests, 0, 'the proxy path must never reach a loopback target');
} finally {
await closeLoopbackServer(proxy);
await closeLoopbackServer(daemon);
await closeLoopbackServer(loopbackTarget);
}
});

test('daemon proxy rejects unauthenticated rpc requests', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

Expand Down
3 changes: 3 additions & 0 deletions src/cli-schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,9 @@ test('usageForCommand resolves remote help topic', async () => {
assert.match(help, /Multiple agents can share one proxy/);
assert.match(help, /disconnect releases local connection state/);
assert.match(help, /A busy direct-proxy device error means another agent owns the device/);
assert.match(help, /AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote/);
assert.match(help, /host-path install sources are rejected/);
assert.match(help, /uploaded artifacts remain supported/);
assert.match(help, /Limrun, BrowserStack, and AWS Device Farm through local provider profiles/);
assert.match(help, /Limrun uses LIMRUN_API_KEY/);
assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/);
Expand Down
1 change: 1 addition & 0 deletions src/cli-schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,7 @@ Rules:
disconnect releases local connection state; close releases the active session and device lease.
A busy direct-proxy device error means another agent owns the device until it closes or its inactivity lease expires.
Keep the proxy token secret. Anyone with the token can control the proxied daemon.
A daemon with AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote: host-path install sources are rejected, uploaded artifacts remain supported, and Maestro runScript HTTP helpers allow only public network destinations. No-hook local HTTP and socket flows retain their local behavior.
If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, retry after the owning session closes or after lease expiry. If the conflict repeats, clean stale daemon state on the machine with simulator access.
Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings.
For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required.
Expand Down
153 changes: 152 additions & 1 deletion src/daemon/__tests__/http-server-rpc-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createDaemonHttpServer } from '../server/http-server.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { cleanupUploadedArtifact, trackUploadedArtifact } from '../artifact-tracking.ts';
import { resolveInstallSource } from '../install-source-resolution.ts';
import {
closeLoopbackServer,
listenOnLoopback,
skipWhenLoopbackUnavailable,
} from '../../__tests__/test-utils/loopback.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';

type RpcErrorResponse = {
jsonrpc: string;
Expand Down Expand Up @@ -137,6 +142,7 @@ async function withInstallFromSourceRpcServer(
}>,
) => Promise<void>,
t: { skip(reason?: string): void },
env?: NodeJS.ProcessEnv,
): Promise<void> {
if (await skipWhenLoopbackUnavailable(t)) return;

Expand All @@ -145,7 +151,7 @@ async function withInstallFromSourceRpcServer(
dispatched.push(req);
return { ok: true, data: { ok: true } };
};
const server = await createDaemonHttpServer({ handleRequest });
const server = await createDaemonHttpServer({ handleRequest, env });

try {
const port = await listenOnLoopback(server);
Expand Down Expand Up @@ -210,3 +216,148 @@ test('install_from_source still admits github-actions-artifact sources', async (
assert.equal(dispatched[0]?.meta?.installSource?.kind, 'github-actions-artifact');
}, t);
});

test('remote HTTP rejects host path install sources in the command RPC used by the CLI', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

const root = mkdtempForTestSync('agent-device-http-command-path-default-');
const hookPath = writeAllowingAuthHook(root);
let handlerCalls = 0;
const server = await createDaemonHttpServer({
env: remoteHttpEnvironment(hookPath),
handleRequest: async (): Promise<DaemonResponse> => {
handlerCalls += 1;
return { ok: true, data: {} };
},
});

try {
const port = await listenOnLoopback(server);
const response = await postCommandRpc(port, {
command: 'install_source',
positionals: [],
flags: { platform: 'android' },
meta: {
installSource: { kind: 'path', path: path.join(root, 'app.apk') },
},
});
assert.equal(response.status, 400);
assert.equal(response.body.error?.code, -32602);
assert.equal(response.body.error?.data?.code, 'INVALID_ARGS');
assert.match(response.body.error?.message ?? '', /disabled on the remote HTTP surface/);
assert.equal(handlerCalls, 0);
} finally {
await closeLoopbackServer(server);
fs.rmSync(root, { recursive: true, force: true });
}
});

test('remote HTTP accepts an uploaded path artifact without resolving the client path', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

const root = mkdtempForTestSync('agent-device-http-uploaded-path-');
const artifactPath = path.join(root, 'uploaded.apk');
fs.writeFileSync(artifactPath, 'uploaded');
const uploadedArtifactId = trackUploadedArtifact({ artifactPath, tempDir: root });
const hookPath = writeAllowingAuthHook(root);
const received: DaemonRequest[] = [];
const server = await createDaemonHttpServer({
env: remoteHttpEnvironment(hookPath),
handleRequest: async (request): Promise<DaemonResponse> => {
received.push(request);
const resolved = resolveInstallSource(request);
try {
assert.equal(resolved.source.kind, 'path');
assert.equal(resolved.source.path, artifactPath);
} finally {
resolved.cleanup();
}
return { ok: true, data: {} };
},
});

try {
const port = await listenOnLoopback(server);
const response = await postCommandRpc(port, {
command: 'install_source',
positionals: [],
flags: { platform: 'android' },
meta: {
installSource: { kind: 'path', path: '/etc/hosts' },
uploadedArtifactId,
},
});
assert.equal(response.status, 200);
assert.equal(received.length, 1);
} finally {
await closeLoopbackServer(server);
cleanupUploadedArtifact(uploadedArtifactId);
fs.rmSync(root, { recursive: true, force: true });
}
});

test('local command RPC keeps host paths unrestricted', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;
const received: DaemonRequest[] = [];
const server = await createDaemonHttpServer({
env: localHttpEnvironment(),
handleRequest: async (request): Promise<DaemonResponse> => {
received.push(request);
return { ok: true, data: {} };
},
});

try {
const port = await listenOnLoopback(server);
const response = await postCommandRpc(port, {
command: 'install_source',
positionals: [],
flags: { platform: 'android' },
meta: { installSource: { kind: 'path', path: '/tmp/local.apk' } },
});
assert.equal(response.status, 200);
assert.deepEqual(received[0]?.meta?.installSource, {
kind: 'path',
path: '/tmp/local.apk',
});
assert.equal(received[0]?.internal, undefined);
} finally {
await closeLoopbackServer(server);
}
});

function writeAllowingAuthHook(root: string): string {
const hookPath = path.join(root, 'auth-hook.mjs');
fs.writeFileSync(hookPath, "export default () => ({ tenantId: 'tenant-test' });\n");
return hookPath;
}

function remoteHttpEnvironment(hookPath: string): NodeJS.ProcessEnv {
const env = localHttpEnvironment();
env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath;
return env;
}

function localHttpEnvironment(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.AGENT_DEVICE_HTTP_AUTH_HOOK;
delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT;
return env;
}

async function postCommandRpc(
port: number,
params: Record<string, unknown>,
): Promise<{ status: number; body: RpcErrorResponse }> {
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'command-install-source',
method: 'agent_device.command',
params,
}),
});
return { status: response.status, body: (await response.json()) as RpcErrorResponse };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { test } from 'vitest';
import { runCmdSync } from '@agent-device/host-kit/command';
import { runScriptHttpChild } from '../run-script-http-child.ts';

test('the packaged HTTP child reports malformed input', () => {
assert.equal(typeof runScriptHttpChild, 'function');
const childPath = fileURLToPath(new URL('../run-script-http-child.ts', import.meta.url));
const result = runCmdSync(process.execPath, ['--experimental-strip-types', childPath], {
stdin: '{',
allowFailure: true,
});

assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /SyntaxError/);
});
Loading
Loading