From 357e4c93917089397be768d592e8fbd7f565e2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 21:25:27 +0200 Subject: [PATCH 1/8] fix: confine remote HTTP trust boundaries --- .../src/install-source-network-transport.ts | 5 +- .../src/install-source-network.ts | 112 +------ .../src/network-trust-policy.test.ts | 56 ++++ .../provision-kit/src/network-trust-policy.ts | 127 +++++++ src/cli-schema/cli-help-topics.test.ts | 12 + src/cli-schema/cli-help.ts | 9 + .../http-server-rpc-validation.test.ts | 317 +++++++++++++++++- .../__tests__/run-script-http-child.test.ts | 139 ++++++++ .../maestro/__tests__/run-script.test.ts | 18 + .../adapters/maestro/daemon-runtime-port.ts | 1 + .../adapters/maestro/run-script-execution.ts | 120 ++++--- .../adapters/maestro/run-script-http-child.ts | 288 ++++++++++++++++ src/daemon/server/daemon-runtime.ts | 1 + src/daemon/server/http-server.ts | 47 ++- src/daemon/server/http-trust-policy.test.ts | 70 ++++ src/daemon/server/http-trust-policy.ts | 102 ++++++ src/daemon/types.ts | 3 + tsdown.config.ts | 1 + 18 files changed, 1258 insertions(+), 170 deletions(-) create mode 100644 packages/provision-kit/src/network-trust-policy.test.ts create mode 100644 packages/provision-kit/src/network-trust-policy.ts create mode 100644 src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts create mode 100644 src/daemon/adapters/maestro/run-script-http-child.ts create mode 100644 src/daemon/server/http-trust-policy.test.ts create mode 100644 src/daemon/server/http-trust-policy.ts diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index a32efb9f27..b53f75fcdd 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -14,6 +14,8 @@ export async function requestApprovedUrl(params: { family: 4 | 6; headers: Record; signal: AbortSignal; + method?: 'GET' | 'POST'; + body?: string; }): Promise { const proxy = resolveProxyForUrl(params.url); const dispatcher = proxy @@ -27,8 +29,9 @@ export async function requestApprovedUrl(params: { dispatcher, headers: proxy ? { ...params.headers, host: params.url.host } : params.headers, maxRedirections: 0, - method: 'GET' as const, + method: params.method ?? 'GET', signal: params.signal, + ...(params.body !== undefined ? { body: params.body } : {}), }; const response = await request(dispatchUrl, requestOptions); return { diff --git a/packages/provision-kit/src/install-source-network.ts b/packages/provision-kit/src/install-source-network.ts index d135e95c19..de95ae6a31 100644 --- a/packages/provision-kit/src/install-source-network.ts +++ b/packages/provision-kit/src/install-source-network.ts @@ -1,11 +1,9 @@ -import dns from 'node:dns/promises'; -import net from 'node:net'; -import { - AppError, - createRequestCanceledError, - isRequestCanceledError, -} from '@agent-device/kernel/errors'; -import ipaddr from 'ipaddr.js'; +import { approvePublicNetworkUrl } from './network-trust-policy.ts'; +export { + approvePublicNetworkUrl, + isBlockedIpAddress, + isBlockedSourceHostname, +} from './network-trust-policy.ts'; export async function approveDownloadSourceUrl( parsedUrl: URL, @@ -14,99 +12,9 @@ export async function approveDownloadSourceUrl( address: string; family: 4 | 6; }> { - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new AppError('INVALID_ARGS', `Unsupported source URL protocol: ${parsedUrl.protocol}`); - } - if (parsedUrl.username || parsedUrl.password) { - throw new AppError('INVALID_ARGS', 'Source URL credentials are not allowed'); - } - throwIfAborted(signal); - const hostname = canonicalHostname(parsedUrl.hostname); - if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname); - const literalFamily = net.isIP(hostname); - if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; - - let resolved: Array<{ address: string; family: number }>; - try { - resolved = await lookupWithSignal(hostname, signal); - } catch (error) { - if (isRequestCanceledError(error)) throw error; - throw new AppError( - 'INVALID_ARGS', - `Source URL host could not be resolved: ${hostname}`, - { hint: 'Use a public artifact URL.' }, - error, - ); - } - if (resolved.length === 0) { - throw new AppError('INVALID_ARGS', `Source URL host could not be resolved: ${hostname}`, { - hint: 'Use a public artifact URL.', - }); - } - if (resolved.some((entry) => isBlockedIpAddress(entry.address))) blockedHost(hostname); - const selected = resolved[0]!; - return { address: selected.address, family: selected.family as 4 | 6 }; -} - -async function lookupWithSignal( - hostname: string, - signal: AbortSignal | undefined, -): Promise> { - const lookup = dns.lookup(hostname, { all: true, verbatim: true }); - if (!signal) return await lookup; - return await new Promise((resolve, reject) => { - const abort = () => reject(canceledError(signal.reason)); - signal.addEventListener('abort', abort, { once: true }); - void lookup.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + return await approvePublicNetworkUrl(parsedUrl, { + signal, + label: 'source URL', + hint: 'Use a public artifact URL.', }); } - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted) throw canceledError(signal.reason); -} - -function canceledError(cause: unknown): AppError { - return createRequestCanceledError(undefined, cause); -} - -export function isBlockedSourceHostname(hostname: string): boolean { - let canonical: string; - try { - canonical = canonicalHostname(hostname); - } catch { - return true; - } - if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; - return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); -} - -export function isBlockedIpAddress(address: string): boolean { - try { - const parsed = ipaddr.process(stripAddressBrackets(address)); - return parsed.range() !== 'unicast'; - } catch { - return true; - } -} - -function canonicalHostname(hostname: string): string { - const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); - if (!stripped || stripped.includes('%')) { - throw new AppError('INVALID_ARGS', 'Source URL host is not allowed', { - hint: 'Use a public artifact URL.', - }); - } - return stripped; -} - -function stripAddressBrackets(value: string): string { - return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; -} - -function blockedHost(hostname: string): never { - throw new AppError( - 'INVALID_ARGS', - `Source URL host is not allowed because it resolves to a non-public address: ${hostname}`, - { hint: 'Use a public artifact URL.' }, - ); -} diff --git a/packages/provision-kit/src/network-trust-policy.test.ts b/packages/provision-kit/src/network-trust-policy.test.ts new file mode 100644 index 0000000000..52c541a8a4 --- /dev/null +++ b/packages/provision-kit/src/network-trust-policy.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; +import { afterEach, test, vi } from 'vitest'; +import { approvePublicNetworkUrl, isBlockedIpAddress } from './network-trust-policy.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('blocks loopback, private, link-local, shared, and reserved address classes', () => { + for (const address of [ + '127.0.0.1', + '10.0.0.1', + '172.16.0.1', + '192.168.0.1', + '169.254.1.1', + '100.64.0.1', + '203.0.113.10', + '::1', + 'fe80::1', + 'fd00::1', + '::ffff:127.0.0.1', + ]) { + assert.equal(isBlockedIpAddress(address), true, address); + } + assert.equal(isBlockedIpAddress('93.184.216.34'), false); + assert.equal(isBlockedIpAddress('2001:4860:4860::8888'), false); +}); + +test('rejects a hostname when any DNS answer is non-public', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ] as never); + + await assert.rejects( + approvePublicNetworkUrl(new URL('https://example.test/artifact'), { + label: 'source URL', + }), + /non-public address/, + ); +}); + +test('returns the approved address and family for a public literal', async () => { + await assert.doesNotReject( + approvePublicNetworkUrl(new URL('https://93.184.216.34/artifact'), { + label: 'Maestro runScript URL', + }), + ); + assert.deepEqual( + await approvePublicNetworkUrl(new URL('https://93.184.216.34/artifact'), { + label: 'Maestro runScript URL', + }), + { address: '93.184.216.34', family: 4 }, + ); +}); diff --git a/packages/provision-kit/src/network-trust-policy.ts b/packages/provision-kit/src/network-trust-policy.ts new file mode 100644 index 0000000000..e9c3966ef2 --- /dev/null +++ b/packages/provision-kit/src/network-trust-policy.ts @@ -0,0 +1,127 @@ +import dns from 'node:dns/promises'; +import net from 'node:net'; +import { + AppError, + createRequestCanceledError, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; +import ipaddr from 'ipaddr.js'; + +export type ApprovedPublicNetworkAddress = { + address: string; + family: 4 | 6; +}; + +export type PublicNetworkApprovalOptions = { + signal?: AbortSignal; + label?: string; + hint?: string; +}; + +export async function approvePublicNetworkUrl( + parsedUrl: URL, + options: PublicNetworkApprovalOptions = {}, +): Promise { + const label = options.label ?? 'URL'; + const displayLabel = capitalizeLabel(label); + const hint = options.hint ?? 'Use a public URL.'; + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new AppError('INVALID_ARGS', `Unsupported ${label} protocol: ${parsedUrl.protocol}`); + } + if (parsedUrl.username || parsedUrl.password) { + throw new AppError('INVALID_ARGS', `${displayLabel} credentials are not allowed`); + } + throwIfAborted(options.signal); + const hostname = canonicalHostname(parsedUrl.hostname, displayLabel, hint); + if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname, displayLabel, hint); + const literalFamily = net.isIP(hostname); + if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; + + let resolved: Array<{ address: string; family: number }>; + try { + resolved = await lookupWithSignal(hostname, options.signal); + } catch (error) { + if (isRequestCanceledError(error)) throw error; + throw new AppError( + 'INVALID_ARGS', + `${displayLabel} host could not be resolved: ${hostname}`, + { hint }, + error, + ); + } + if (resolved.length === 0) { + throw new AppError('INVALID_ARGS', `${displayLabel} host could not be resolved: ${hostname}`, { + hint, + }); + } + if (resolved.some((entry) => isBlockedIpAddress(entry.address))) { + blockedHost(hostname, displayLabel, hint); + } + const selected = resolved[0]!; + return { address: selected.address, family: selected.family as 4 | 6 }; +} + +export function isBlockedSourceHostname(hostname: string): boolean { + let canonical: string; + try { + canonical = canonicalHostname(hostname, 'Source URL', 'Use a public artifact URL.'); + } catch { + return true; + } + if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; + return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); +} + +export function isBlockedIpAddress(address: string): boolean { + try { + const parsed = ipaddr.process(stripAddressBrackets(address)); + return parsed.range() !== 'unicast'; + } catch { + return true; + } +} + +async function lookupWithSignal( + hostname: string, + signal: AbortSignal | undefined, +): Promise> { + const lookup = dns.lookup(hostname, { all: true, verbatim: true }); + if (!signal) return await lookup; + return await new Promise((resolve, reject) => { + const abort = () => reject(canceledError(signal.reason)); + signal.addEventListener('abort', abort, { once: true }); + void lookup.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw canceledError(signal.reason); +} + +function canceledError(cause: unknown): AppError { + return createRequestCanceledError(undefined, cause); +} + +function canonicalHostname(hostname: string, label: string, hint: string): string { + const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); + if (!stripped || stripped.includes('%')) { + throw new AppError('INVALID_ARGS', `${label} host is not allowed`, { hint }); + } + return stripped; +} + +function blockedHost(hostname: string, label: string, hint: string): never { + throw new AppError( + 'INVALID_ARGS', + `${label} host is not allowed because it resolves to a non-public address: ${hostname}`, + { hint }, + ); +} + +function capitalizeLabel(label: string): string { + return label.length === 0 ? label : `${label[0]!.toUpperCase()}${label.slice(1)}`; +} + +function stripAddressBrackets(value: string): string { + return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; +} diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index 8affb909c3..de6334a3a2 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -143,6 +143,14 @@ test('root help routes detailed reference material to progressive topics', async assert.match(commandsHelp, /Default config files: ~\/\.agent-device\/config\.json/); assert.match(commandsHelp, /^Environment:/m); assert.match(commandsHelp, /AGENT_DEVICE_SESSION\s+Explicit session name/); + assert.match( + commandsHelp, + /AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL\s+Explicit remote host-path install opt-in/, + ); + assert.match( + commandsHelp, + /AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT\s+Realpath root for remote host-path installs/, + ); assert.match(commandsHelp, /^Examples:/m); assert.match(commandsHelp, /agent-device open Settings --platform ios/); @@ -464,6 +472,10 @@ 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, /AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL=true/); + assert.match(help, /AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT/); + assert.match(help, /rejects traversal or symlink escapes/); 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/); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 36bbc4ca97..ef6f4fee6f 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -37,6 +37,14 @@ const ENVIRONMENT_LINES = [ label: 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', description: 'Remote daemon service/API token', }, + { + label: 'AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL', + description: 'Explicit remote host-path install opt-in', + }, + { + label: 'AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT', + description: 'Realpath root for remote host-path installs', + }, { label: 'AGENT_DEVICE_CLOUD_BASE_URL', description: 'Bridge/control-plane API origin for cloud auth and /api-keys', @@ -822,6 +830,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 by default, and Maestro runScript HTTP helpers allow only public network destinations. To opt into path installs, set AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL=true and AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT to an existing directory; the daemon resolves the requested path and rejects traversal or symlink escapes. 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. diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index 92cfdf8545..4dd87119e5 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -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; @@ -137,6 +142,7 @@ async function withInstallFromSourceRpcServer( }>, ) => Promise, t: { skip(reason?: string): void }, + env?: NodeJS.ProcessEnv, ): Promise { if (await skipWhenLoopbackUnavailable(t)) return; @@ -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); @@ -172,15 +178,24 @@ async function withInstallFromSourceRpcServer( } } -test('install_from_source rejects a host path source at the rpc boundary', async (t) => { - await withInstallFromSourceRpcServer(async (post) => { - const { status, body, dispatched } = await post({ kind: 'path', path: '/etc/passwd' }); +test('install_from_source rejects a host path source on an authenticated HTTP surface', async (t) => { + const root = mkdtempForTestSync('agent-device-http-path-boundary-'); + try { + await withInstallFromSourceRpcServer( + async (post) => { + const { status, body, dispatched } = await post({ kind: 'path', path: '/etc/passwd' }); - assert.equal(status, 400); - assert.equal(body.error?.code, -32602); - assert.equal(body.error?.data?.code, 'INVALID_ARGS'); - assert.equal(dispatched.length, 0, 'a host path source must never reach the handler'); - }, t); + assert.equal(status, 400); + assert.equal(body.error?.code, -32602); + assert.equal(body.error?.data?.code, 'INVALID_ARGS'); + assert.equal(dispatched.length, 0, 'a host path source must never reach the handler'); + }, + t, + remoteHttpEnvironment(writeAllowingAuthHook(root)), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); test('install_from_source still admits url sources', async (t) => { @@ -210,3 +225,287 @@ 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 by default', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const root = mkdtempForTestSync('agent-device-http-path-default-'); + const hookPath = writeAllowingAuthHook(root); + let handlerCalls = 0; + const server = await createDaemonHttpServer({ + env: remoteHttpEnvironment(hookPath), + handleRequest: async (): Promise => { + handlerCalls += 1; + return { ok: true, data: {} }; + }, + }); + + try { + const port = await listenOnLoopback(server); + const response = await postInstallFromSource(port, { + 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 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 => { + 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 => { + 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('remote HTTP confines opted-in path installs to the realpath-approved root', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + await withPathConfinementServer(async ({ port, received, inside, traversal, outsideLink }) => { + await assertAllowedPathInstall(port, received, inside); + await assertRejectedPathInstall(port, traversal); + await assertRejectedPathInstall(port, outsideLink); + assert.equal(received.length, 1); + }); +}); + +test('local HTTP keeps path install sources available without an auth hook', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const root = mkdtempForTestSync('agent-device-http-path-local-'); + const artifactPath = path.join(root, 'app.apk'); + fs.writeFileSync(artifactPath, 'local'); + const received: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + env: localHttpEnvironment(), + handleRequest: async (request): Promise => { + received.push(request); + return { ok: true, data: {} }; + }, + }); + + try { + const port = await listenOnLoopback(server); + const response = await postInstallFromSource(port, { kind: 'path', path: artifactPath }); + assert.equal(response.status, 200); + assert.equal( + received[0]?.meta?.installSource?.kind === 'path' + ? received[0].meta.installSource.path + : undefined, + artifactPath, + ); + assert.equal(received[0]?.internal, undefined); + } finally { + await closeLoopbackServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function writeAllowingAuthHook(root: string): string { + const hookPath = path.join(root, 'auth-hook.mjs'); + fs.writeFileSync(hookPath, 'export default () => true;\n'); + return hookPath; +} + +async function withPathConfinementServer( + run: (context: { + port: number; + received: DaemonRequest[]; + inside: string; + traversal: string; + outsideLink: string; + }) => Promise, +): Promise { + const parent = mkdtempForTestSync('agent-device-http-path-confinement-'); + const root = path.join(parent, 'approved'); + const outside = path.join(parent, 'outside.apk'); + const inside = path.join(root, 'inside.apk'); + const insideLink = path.join(root, 'inside-link.apk'); + const outsideLink = path.join(root, 'outside-link.apk'); + fs.mkdirSync(root); + fs.writeFileSync(outside, 'outside'); + fs.writeFileSync(inside, 'inside'); + fs.symlinkSync(inside, insideLink); + fs.symlinkSync(outside, outsideLink); + const hookPath = writeAllowingAuthHook(parent); + const received: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + env: remoteHttpEnvironment(hookPath, { + AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL: 'true', + AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT: root, + }), + handleRequest: async (request): Promise => { + received.push(request); + return { ok: true, data: {} }; + }, + }); + + try { + await run({ + port: await listenOnLoopback(server), + received, + inside, + traversal: path.join(root, '..', path.basename(outside)), + outsideLink, + }); + } finally { + await closeLoopbackServer(server); + fs.rmSync(parent, { recursive: true, force: true }); + } +} + +async function assertAllowedPathInstall( + port: number, + received: DaemonRequest[], + inside: string, +): Promise { + const allowed = await postInstallFromSource(port, { + kind: 'path', + path: path.join(path.dirname(inside), 'inside-link.apk'), + }); + assert.equal(allowed.status, 200); + assert.equal(received[0]?.meta?.installSource?.kind, 'path'); + assert.equal( + received[0]?.meta?.installSource?.kind === 'path' + ? received[0].meta.installSource.path + : undefined, + fs.realpathSync(inside), + ); + assert.equal(received[0]?.internal?.networkAccess, 'public-only'); +} + +async function assertRejectedPathInstall(port: number, rejectedPath: string): Promise { + const rejected = await postInstallFromSource(port, { kind: 'path', path: rejectedPath }); + assert.equal(rejected.status, 400, rejectedPath); + assert.equal(rejected.body.error?.data?.code, 'INVALID_ARGS'); + assert.match(rejected.body.error?.message ?? '', /outside the approved root/); +} + +function remoteHttpEnvironment( + hookPath: string, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + const env = localHttpEnvironment(); + env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath; + return { ...env, ...overrides }; +} + +function localHttpEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.AGENT_DEVICE_HTTP_AUTH_HOOK; + delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT; + delete env.AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL; + delete env.AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT; + return env; +} + +async function postInstallFromSource( + port: number, + source: Record, +): 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: 'install-source', + method: 'agent_device.install_from_source', + params: { platform: 'android', source }, + }), + }); + return { status: response.status, body: (await response.json()) as RpcErrorResponse }; +} + +async function postCommandRpc( + port: number, + params: Record, +): 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 }; +} diff --git a/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts new file mode 100644 index 0000000000..c6822f5ee8 --- /dev/null +++ b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { beforeEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; + +const mocks = vi.hoisted(() => ({ + approvePublicNetworkUrl: vi.fn(), + requestApprovedUrl: vi.fn(), +})); + +vi.mock('@agent-device/provision-kit/install-source-network', () => ({ + approvePublicNetworkUrl: mocks.approvePublicNetworkUrl, +})); +vi.mock('@agent-device/provision-kit/install-source-network-transport', () => ({ + requestApprovedUrl: mocks.requestApprovedUrl, +})); + +import { + executeRunScriptHttpRequest, + MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, +} from '../run-script-http-child.ts'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('revalidates every Maestro HTTP redirect before dispatching it', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 }); + mocks.approvePublicNetworkUrl.mockRejectedValueOnce( + new AppError( + 'INVALID_ARGS', + 'Maestro runScript URL host is not allowed because it resolves to a non-public address: 127.0.0.1', + ), + ); + mocks.requestApprovedUrl.mockResolvedValue(response(302, { location: 'https://127.0.0.1/next' })); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'POST', + url: 'https://example.test/start', + headers: { authorization: 'secret', accept: 'application/json' }, + body: '{}', + networkAccess: 'public-only', + }), + /non-public address/, + ); + assert.equal(mocks.requestApprovedUrl.mock.calls.length, 1); + assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 2); + assert.equal(mocks.approvePublicNetworkUrl.mock.calls[1]?.[0].href, 'https://127.0.0.1/next'); +}); + +test('follows an approved same-origin Maestro HTTP redirect with fetch semantics', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + mocks.requestApprovedUrl + .mockResolvedValueOnce(response(302, { location: 'https://example.test/next' })) + .mockResolvedValueOnce(response(200, {}, 'ok')); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: 'https://example.test/start', + headers: { authorization: 'secret' }, + body: '{}', + networkAccess: 'public-only', + }); + + assert.deepEqual(result, { status: 200, body: 'ok', headers: {} }); + assert.equal(mocks.requestApprovedUrl.mock.calls.length, 2); + assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].method, 'GET'); + assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].body, undefined); + assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].headers.authorization, 'secret'); +}); + +test('classifies redirects before reading their response bodies', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + let resumed = false; + const redirectBody = { + resume: () => { + resumed = true; + }, + } as unknown as NodeJS.ReadableStream; + mocks.requestApprovedUrl + .mockResolvedValueOnce(response(302, { location: 'https://example.test/next' }, redirectBody)) + .mockResolvedValueOnce(response(200, {}, 'ok')); + + const result = await executeRunScriptHttpRequest({ + method: 'GET', + url: 'https://example.test/start', + headers: {}, + networkAccess: 'public-only', + }); + + assert.equal(result.body, 'ok'); + assert.equal(resumed, true); +}); + +test('caps final public response bodies while consuming them incrementally', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + let yieldedChunks = 0; + const body = Readable.from( + (function* () { + yieldedChunks += 1; + yield Buffer.alloc(MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES / 2, 0x61); + yieldedChunks += 1; + yield Buffer.alloc(MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES / 2, 0x62); + yieldedChunks += 1; + yield Buffer.from('overflow'); + })(), + ); + mocks.requestApprovedUrl.mockResolvedValue(response(200, {}, body)); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'https://example.test/large', + headers: {}, + networkAccess: 'public-only', + }), + new RegExp(`response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`), + ); + assert.equal(yieldedChunks, 3); +}); + +function response( + statusCode: number, + headers: Record = {}, + body: string | NodeJS.ReadableStream = '', +): { + statusCode: number; + headers: Record; + body: NodeJS.ReadableStream; + close: () => Promise; +} { + return { + statusCode, + headers, + body: typeof body === 'string' ? Readable.from([body]) : body, + close: async () => {}, + }; +} diff --git a/src/daemon/adapters/maestro/__tests__/run-script.test.ts b/src/daemon/adapters/maestro/__tests__/run-script.test.ts index d276a029f2..7906506d70 100644 --- a/src/daemon/adapters/maestro/__tests__/run-script.test.ts +++ b/src/daemon/adapters/maestro/__tests__/run-script.test.ts @@ -95,3 +95,21 @@ output.result = [ fs.rmSync(root, { recursive: true, force: true }); } }); + +test('executeRunScriptFile blocks non-public HTTP destinations for remote requests', () => { + const root = mkdtempForTestSync('agent-device-maestro-run-script-'); + const scriptPath = path.join(root, 'setup.js'); + fs.writeFileSync(scriptPath, `output.result = http.post('http://127.0.0.1:1')`); + + try { + expect(() => + executeRunScriptFile({ + scriptPath, + env: {}, + networkAccess: 'public-only', + }), + ).toThrow(/non-public address/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/adapters/maestro/daemon-runtime-port.ts b/src/daemon/adapters/maestro/daemon-runtime-port.ts index b3ed9b8c3f..9c0a7b463c 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-port.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port.ts @@ -267,6 +267,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper runScript: async (input, context) => ({ outputEnv: executeRunScriptFile({ scriptPath: resolveScriptPath(input.file, context, options.sourcePath), + networkAccess: options.baseReq.internal?.networkAccess, env: { ...context.env, ...(input.env ? stringifyEnvironment(input.env) : {}), diff --git a/src/daemon/adapters/maestro/run-script-execution.ts b/src/daemon/adapters/maestro/run-script-execution.ts index 15427f56b3..011555274b 100644 --- a/src/daemon/adapters/maestro/run-script-execution.ts +++ b/src/daemon/adapters/maestro/run-script-execution.ts @@ -1,8 +1,11 @@ import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { runCmdSync } from '@agent-device/host-kit/command'; import { stripUndefined } from '@agent-device/kernel/record'; +import type { DaemonNetworkAccessPolicy } from '../../types.ts'; const RUN_SCRIPT_TIMEOUT_MS = 30_000; const RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS = 1_000; @@ -13,29 +16,6 @@ type HttpResponse = { headers: Record; }; -const HTTP_REQUEST_SCRIPT = ` -const fs = require('node:fs'); -const input = JSON.parse(fs.readFileSync(0, 'utf8')); -if (typeof fetch !== 'function') { - console.error('global fetch is required for Maestro runScript http helpers'); - process.exit(1); -} -fetch(input.url, { - method: input.method, - headers: input.headers, - body: input.body, -}).then(async response => { - process.stdout.write(JSON.stringify({ - status: response.status, - body: await response.text(), - headers: Object.fromEntries(response.headers.entries()), - })); -}).catch(error => { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); -}); -`; - /** * Executes a trusted flow-local script with the compatibility helpers Maestro * exposes. `node:vm` isolates globals for the run but is not a security @@ -44,15 +24,16 @@ fetch(input.url, { export function executeRunScriptFile(params: { scriptPath: string; env: Record; + networkAccess?: DaemonNetworkAccessPolicy; }): Record { - const { scriptPath, env } = params; + const { scriptPath, env, networkAccess = 'unrestricted' } = params; const script = fs.readFileSync(scriptPath, 'utf8'); const output: Record = Object.create(null) as Record; try { // The synchronous script budget is independent from the child-process // budget used by http.post below. - vm.runInNewContext(script, buildScriptGlobals(env, output), { + vm.runInNewContext(script, buildScriptGlobals(env, output, networkAccess), { filename: scriptPath, timeout: RUN_SCRIPT_TIMEOUT_MS, }); @@ -86,6 +67,7 @@ export function executeRunScriptFile(params: { function buildScriptGlobals( env: Record, output: Record, + networkAccess: DaemonNetworkAccessPolicy, ): vm.Context { return { ...env, @@ -93,7 +75,7 @@ function buildScriptGlobals( json: parseRunScriptJson, http: { post: (url: string, options?: { headers?: Record; body?: string }) => - runHttpRequestSync('POST', url, options), + runHttpRequestSync('POST', url, options, networkAccess), }, }; } @@ -130,38 +112,90 @@ function runHttpRequestSync( method: string, url: string, options?: { headers?: Record; body?: string }, + networkAccess: DaemonNetworkAccessPolicy = 'unrestricted', ): HttpResponse { - // Keep http.post synchronous from the flow author's point of view while the - // network request remains timeout-bounded independently from node:vm. - const result = runCmdSync(process.execPath, ['-e', HTTP_REQUEST_SCRIPT], { - stdin: JSON.stringify({ - method, - url, - headers: options?.headers ?? {}, - body: options?.body ?? '', - }), + const result = runCmdSync(process.execPath, resolveHttpChildArgs(), { + stdin: JSON.stringify(buildHttpChildInput(method, url, options, networkAccess)), timeoutMs: RUN_SCRIPT_TIMEOUT_MS, allowFailure: true, }); if (result.exitCode !== 0) { + throwHttpChildFailure(method, url, result.exitCode, result.stderr); + } + return parseHttpChildResponse(method, url, result.stdout, result.stderr); +} + +function resolveHttpChildArgs(): string[] { + const modulePath = resolveHttpChildModulePath(import.meta.url); + if (!modulePath) { throw new AppError( 'COMMAND_FAILED', - `Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(result.stderr)}`, - { - exitCode: result.exitCode, - stderr: result.stderr, - }, + 'Maestro runScript http helper entrypoint is unavailable; rebuild the package', ); } + return modulePath.endsWith('.ts') ? ['--experimental-strip-types', modulePath] : [modulePath]; +} + +function resolveHttpChildModulePath(importMetaUrl: string): string | null { + try { + const currentModulePath = fileURLToPath(importMetaUrl); + const extension = path.extname(currentModulePath) || '.js'; + const candidates = [ + path.join(path.dirname(currentModulePath), 'run-script-http-child' + extension), + path.join(path.dirname(currentModulePath), 'internal', 'run-script-http-child' + extension), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; + } catch { + return null; + } +} + +function buildHttpChildInput( + method: string, + url: string, + options: { headers?: Record; body?: string } | undefined, + networkAccess: DaemonNetworkAccessPolicy, +): Record { + return { + method, + url, + headers: options?.headers ?? {}, + body: options?.body ?? '', + networkAccess, + }; +} + +function throwHttpChildFailure( + method: string, + url: string, + exitCode: number | null, + stderr: string, +): never { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(stderr)}`, + { + exitCode, + stderr, + }, + ); +} + +function parseHttpChildResponse( + method: string, + url: string, + stdout: string, + stderr: string, +): HttpResponse { try { - return JSON.parse(result.stdout) as HttpResponse; + return JSON.parse(stdout) as HttpResponse; } catch (error) { throw new AppError( 'COMMAND_FAILED', `Maestro runScript http.${method.toLowerCase()} returned invalid JSON for ${url}`, { - stdout: result.stdout.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), - stderr: result.stderr.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), + stdout: stdout.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), + stderr: stderr.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), }, error instanceof Error ? error : undefined, ); diff --git a/src/daemon/adapters/maestro/run-script-http-child.ts b/src/daemon/adapters/maestro/run-script-http-child.ts new file mode 100644 index 0000000000..2db3fd9a29 --- /dev/null +++ b/src/daemon/adapters/maestro/run-script-http-child.ts @@ -0,0 +1,288 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { pathToFileURL } from 'node:url'; +import type { DaemonNetworkAccessPolicy } from '../../types.ts'; +import { + requestApprovedUrl, + type InstallSourceNetworkResponse, +} from '@agent-device/provision-kit/install-source-network-transport'; +import { approvePublicNetworkUrl } from '@agent-device/provision-kit/install-source-network'; + +const MAX_REDIRECTS = 5; +export const MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES = 8 * 1024 * 1024; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const REDIRECT_TO_GET_STATUSES = new Set([301, 302, 303]); +const CROSS_ORIGIN_HEADERS = new Set(['accept', 'user-agent']); + +export type RunScriptHttpRequest = { + method: 'GET' | 'POST'; + url: string; + headers: Record; + body?: string; + networkAccess: DaemonNetworkAccessPolicy; +}; + +export type RunScriptHttpResponse = { + status: number; + body: string; + headers: Record; +}; + +type PublicRequestState = { + url: URL; + headers: Record; + method: 'GET' | 'POST'; + body?: string; +}; + +type PublicResponseOutcome = + | { response: RunScriptHttpResponse } + | { nextRequest: PublicRequestState }; + +export async function executeRunScriptHttpRequest( + input: RunScriptHttpRequest, +): Promise { + if (input.networkAccess === 'public-only') return await executePublicRequest(input); + return await executeUnrestrictedRequest(input); +} + +async function executeUnrestrictedRequest( + input: RunScriptHttpRequest, +): Promise { + if (typeof fetch !== 'function') { + throw new Error('global fetch is required for Maestro runScript http helpers'); + } + const response = await fetch(input.url, { + method: input.method, + headers: input.headers, + ...(input.body !== undefined ? { body: input.body } : {}), + }); + return { + status: response.status, + body: await response.text(), + headers: Object.fromEntries(response.headers.entries()), + }; +} + +async function executePublicRequest(input: RunScriptHttpRequest): Promise { + let request = createPublicRequest(input); + const signal = AbortSignal.timeout(30_000); + + for (let redirectCount = 0; ; redirectCount += 1) { + const response = await requestPublicHop(request, signal); + try { + const outcome = await processPublicResponse(response, request, redirectCount); + if ('response' in outcome) return outcome.response; + request = outcome.nextRequest; + } finally { + await response.close(); + } + } +} + +function createPublicRequest(input: RunScriptHttpRequest): PublicRequestState { + try { + return { + url: new URL(input.url), + headers: { ...input.headers }, + method: input.method, + ...(input.body !== undefined ? { body: input.body } : {}), + }; + } catch { + throw new AppError('INVALID_ARGS', 'Invalid Maestro runScript HTTP URL'); + } +} + +async function requestPublicHop( + request: PublicRequestState, + signal: AbortSignal, +): Promise { + const approved = await approvePublicNetworkUrl(request.url, { + signal, + label: 'Maestro runScript URL', + hint: 'Use a public URL.', + }); + return await requestApprovedUrl({ + url: request.url, + approvedAddress: approved.address, + family: approved.family, + headers: request.headers, + signal, + method: request.method, + ...(request.body !== undefined ? { body: request.body } : {}), + }); +} + +async function processPublicResponse( + response: InstallSourceNetworkResponse, + request: PublicRequestState, + redirectCount: number, +): Promise { + if (REDIRECT_STATUSES.has(response.statusCode)) { + response.body.resume?.(); + return { nextRequest: createRedirectRequest(response, request, redirectCount) }; + } + const responseBody = await readResponseBody(response); + return { + response: { + status: response.statusCode, + body: responseBody, + headers: responseHeadersToRecord(response.headers), + }, + }; +} + +function createRedirectRequest( + response: InstallSourceNetworkResponse, + request: PublicRequestState, + redirectCount: number, +): PublicRequestState { + const location = readHeader(response.headers, 'location'); + if (!location || redirectCount >= MAX_REDIRECTS) { + throw new AppError('COMMAND_FAILED', 'Maestro runScript HTTP redirect limit was exceeded', { + status: response.statusCode, + }); + } + const redirected = new URL(location, request.url); + if (request.url.protocol === 'https:' && redirected.protocol !== 'https:') { + throw new AppError('COMMAND_FAILED', 'Maestro runScript HTTP redirect downgraded HTTPS'); + } + return { + url: redirected, + headers: + redirected.origin === request.url.origin + ? request.headers + : crossOriginHeaders(request.headers), + ...redirectMethod(response.statusCode, request.method, request.body), + }; +} + +function redirectMethod( + statusCode: number, + method: 'GET' | 'POST', + body: string | undefined, +): { method: 'GET' | 'POST'; body?: string } { + if (method === 'GET' || !REDIRECT_TO_GET_STATUSES.has(statusCode)) return { method, body }; + return { method: 'GET' }; +} + +async function readResponseBody(response: InstallSourceNetworkResponse): Promise { + const chunks: Buffer[] = []; + let bytesSeen = 0; + for await (const chunk of response.body as AsyncIterable) { + const chunkBytes = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength; + const nextBytesSeen = bytesSeen + chunkBytes; + if (nextBytesSeen > MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES) { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript HTTP response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`, + { limitBytes: MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, status: response.statusCode }, + ); + } + bytesSeen = nextBytesSeen; + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); + } + return Buffer.concat(chunks, bytesSeen).toString('utf8'); +} + +function responseHeadersToRecord( + headers: InstallSourceNetworkResponse['headers'], +): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(', ') : (value ?? ''), + ]), + ); +} + +function readHeader( + headers: InstallSourceNetworkResponse['headers'], + name: string, +): string | undefined { + const lowerName = name.toLowerCase(); + const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1]; + return Array.isArray(entry) ? entry[0] : entry; +} + +function crossOriginHeaders(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).filter(([name]) => CROSS_ORIGIN_HEADERS.has(name.toLowerCase())), + ); +} + +async function readInput(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; +} + +function parseInput(value: unknown): RunScriptHttpRequest { + const record = parseInputRecord(value); + return { + method: parseMethod(record.method), + url: parseInputUrl(record.url), + headers: parseHeaders(record.headers), + ...parseBody(record.body), + networkAccess: parseNetworkAccess(record.networkAccess), + }; +} + +function parseInputRecord(value: unknown): Record { + if (!value || typeof value !== 'object') throw new Error('invalid Maestro runScript HTTP input'); + return value as Record; +} + +function parseMethod(value: unknown): 'GET' | 'POST' { + if (value !== 'GET' && value !== 'POST') { + throw new Error('invalid Maestro runScript HTTP method'); + } + return value; +} + +function parseInputUrl(value: unknown): string { + if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP input'); + return value; +} + +function parseHeaders(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid Maestro runScript HTTP headers'); + } + const headers: Record = {}; + for (const [key, headerValue] of Object.entries(value as Record)) { + if (typeof headerValue !== 'string') throw new Error('invalid Maestro runScript HTTP headers'); + headers[key] = headerValue; + } + return headers; +} + +function parseBody(value: unknown): { body?: string } { + if (value === undefined) return {}; + if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP body'); + return { body: value }; +} + +function parseNetworkAccess(value: unknown): DaemonNetworkAccessPolicy { + if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP input'); + if (value !== 'unrestricted' && value !== 'public-only') { + throw new Error('invalid Maestro runScript HTTP network policy'); + } + return value; +} + +async function runChild(): Promise { + const response = await executeRunScriptHttpRequest(parseInput(await readInput())); + process.stdout.write(JSON.stringify(response)); +} + +const isDirectRun = process.argv[1] + ? pathToFileURL(process.argv[1]).href === import.meta.url + : false; +if (isDirectRun) { + void runChild().catch((error: unknown) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 982ba0a01b..82b838a895 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -446,6 +446,7 @@ export async function startDaemonRuntime( handleRequest, token, retainArtifacts, + env, // #1801: the same record `DaemonError.logPath` names, addressed by its // locator so a remote caller can fetch what it cannot read by path. resolveRequestDiagnosticsPath: (ref) => diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index f33859af63..c31830a24b 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -39,6 +39,7 @@ import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; +import { applyHttpInstallSourceTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -78,8 +79,6 @@ type HttpAuthDecision = | { ok: true; tenantId?: string } | { ok: false; statusCode: number; response: JsonRpcResponse }; -type HttpInstallSource = Exclude; - const MAX_HTTP_RPC_BODY_BYTES = 1024 * 1024; const COMMAND_RPC_METHODS = new Set(['agent_device.command', 'agent-device.command']); const INSTALL_FROM_SOURCE_RPC_METHODS = new Set([ @@ -223,7 +222,7 @@ function readGitHubArtifactInteger(record: Record, key: 'artifa return parsed; } -function parseGitHubActionsArtifactSource(record: Record): HttpInstallSource { +function parseGitHubActionsArtifactSource(record: Record): DaemonInstallSource { const owner = readRequiredGitHubArtifactText(record, 'owner'); const repo = readRequiredGitHubArtifactText(record, 'repo'); const hasArtifactId = record.artifactId !== undefined; @@ -292,7 +291,7 @@ function toLeaseDaemonRequest( }; } -function parseInstallSource(params: Record): HttpInstallSource { +function parseInstallSource(params: Record): DaemonInstallSource { const source = params.source; if (!source || typeof source !== 'object') { throw new AppError('INVALID_ARGS', 'Invalid params: source is required'); @@ -322,18 +321,21 @@ function parseInstallSource(params: Record): HttpInstallSource return Object.keys(headers).length > 0 ? { kind: 'url', url, headers } : { kind: 'url', url }; } if (record.kind === 'path') { - throw new AppError( - 'INVALID_ARGS', - 'Invalid params: source.kind "path" names a file on the daemon host and is not accepted over HTTP', - { hint: 'Use a "url" or "github-actions-artifact" source.' }, - ); + const artifactPath = typeof record.path === 'string' ? record.path.trim() : ''; + if (!artifactPath) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: source.path is required for path sources', + ); + } + return { kind: 'path', path: artifactPath }; } if (record.kind === 'github-actions-artifact') { return parseGitHubActionsArtifactSource(record); } throw new AppError( 'INVALID_ARGS', - 'Invalid params: source.kind must be "url" or "github-actions-artifact"', + 'Invalid params: source.kind must be "url", "path", or "github-actions-artifact"', ); } @@ -491,10 +493,12 @@ async function runHttpAuthHook( return { ok: true }; } -async function loadHttpAuthHook(): Promise { - const hookPath = process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; +async function loadHttpAuthHook( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const hookPath = env.AGENT_DEVICE_HTTP_AUTH_HOOK; if (!hookPath) return null; - const exportName = process.env.AGENT_DEVICE_HTTP_AUTH_EXPORT || 'default'; + const exportName = env.AGENT_DEVICE_HTTP_AUTH_EXPORT || 'default'; const resolvedPath = path.isAbsolute(hookPath) ? hookPath : path.resolve(hookPath); let imported: Record; try { @@ -519,6 +523,7 @@ export async function createDaemonHttpServer(options: { handleRequest: DaemonInvokeFn; token?: string; retainArtifacts?: boolean; + env?: NodeJS.ProcessEnv; /** * Resolves a request diagnostics record path for the `/sessions/.../requests/...` * route (#1801). Omitted by embedded servers with no session store; the route @@ -527,7 +532,12 @@ export async function createDaemonHttpServer(options: { */ resolveRequestDiagnosticsPath?: (ref: DiagnosticsRecordRef) => string; }): Promise { - const authHook = await loadHttpAuthHook(); + const environment = options.env ?? process.env; + const authHook = await loadHttpAuthHook(environment); + const trustPolicy = await resolveHttpTrustPolicy({ + authHookConfigured: authHook !== null, + env: environment, + }); const { handleRequest, token, retainArtifacts = false, resolveRequestDiagnosticsPath } = options; return http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { @@ -644,7 +654,7 @@ export async function createDaemonHttpServer(options: { let handlerCompleted = false; try { const params = rpcRequest.params as Record; - const daemonRequest = methodToDaemonRequest(rpcRequest.method, params, req.headers); + let daemonRequest = methodToDaemonRequest(rpcRequest.method, params, req.headers); if ( isCommandRpcMethod(rpcRequest.method) && (typeof daemonRequest.command !== 'string' || daemonRequest.command.length === 0) @@ -703,6 +713,13 @@ export async function createDaemonHttpServer(options: { if (daemonRequest.flags?.tenant !== undefined) { daemonRequest.flags = { ...daemonRequest.flags, tenant: tenantTrust.tenantId }; } + daemonRequest = await applyHttpInstallSourceTrustPolicy(daemonRequest, trustPolicy); + if (trustPolicy.networkAccess === 'public-only') { + daemonRequest.internal = { + ...daemonRequest.internal, + networkAccess: trustPolicy.networkAccess, + }; + } let canceledInFlight = false; // Request-scoped cancellation: mark this request canceled whenever its client diff --git a/src/daemon/server/http-trust-policy.test.ts b/src/daemon/server/http-trust-policy.test.ts new file mode 100644 index 0000000000..ab81c19b90 --- /dev/null +++ b/src/daemon/server/http-trust-policy.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { + HTTP_ALLOW_HOST_PATH_INSTALL_ENV, + HTTP_HOST_PATH_INSTALL_ROOT_ENV, + confineHttpInstallSourcePath, + resolveHttpTrustPolicy, +} from './http-trust-policy.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +test('remote host-path opt-in fails closed without an approved root', async () => { + await assert.rejects( + resolveHttpTrustPolicy({ + authHookConfigured: true, + env: { [HTTP_ALLOW_HOST_PATH_INSTALL_ENV]: 'true' }, + }), + new RegExp( + `${HTTP_ALLOW_HOST_PATH_INSTALL_ENV} requires ${HTTP_HOST_PATH_INSTALL_ROOT_ENV}`, + 'i', + ), + ); +}); + +test('remote host-path opt-in resolves a symlinked approved root', async () => { + const parent = mkdtempForTestSync('agent-device-http-trust-root-'); + const root = path.join(parent, 'root'); + const rootLink = path.join(parent, 'root-link'); + fs.mkdirSync(root); + fs.symlinkSync(root, rootLink); + + try { + const policy = await resolveHttpTrustPolicy({ + authHookConfigured: true, + env: { + [HTTP_ALLOW_HOST_PATH_INSTALL_ENV]: 'yes', + [HTTP_HOST_PATH_INSTALL_ROOT_ENV]: rootLink, + }, + }); + assert.equal(policy.networkAccess, 'public-only'); + assert.equal(policy.hostPathInstallRoot, fs.realpathSync(root)); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test('path confinement returns the canonical target and rejects a symlink escape', async () => { + const parent = mkdtempForTestSync('agent-device-http-trust-path-'); + const root = path.join(parent, 'root'); + const inside = path.join(root, 'inside.apk'); + const outside = path.join(parent, 'outside.apk'); + const link = path.join(root, 'outside-link.apk'); + fs.mkdirSync(root); + fs.writeFileSync(inside, 'inside'); + fs.writeFileSync(outside, 'outside'); + fs.symlinkSync(outside, link); + + try { + const approvedRoot = fs.realpathSync(root); + assert.equal(await confineHttpInstallSourcePath(inside, approvedRoot), fs.realpathSync(inside)); + await assert.rejects( + confineHttpInstallSourcePath(link, approvedRoot), + (error: unknown) => + error instanceof Error && error.message.includes('resolves outside the approved root'), + ); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/http-trust-policy.ts b/src/daemon/server/http-trust-policy.ts new file mode 100644 index 0000000000..078673411e --- /dev/null +++ b/src/daemon/server/http-trust-policy.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonNetworkAccessPolicy, DaemonRequest } from '../types.ts'; +import { expandUserHomePath } from '@agent-device/host-kit/file'; +import { isEnvTruthy } from '@agent-device/host-kit/retry'; + +export const HTTP_ALLOW_HOST_PATH_INSTALL_ENV = 'AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL'; +export const HTTP_HOST_PATH_INSTALL_ROOT_ENV = 'AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT'; + +export type HttpTrustPolicy = { + networkAccess: DaemonNetworkAccessPolicy; + hostPathInstallRoot?: string; +}; + +export async function resolveHttpTrustPolicy(params: { + authHookConfigured: boolean; + env: NodeJS.ProcessEnv; +}): Promise { + if (!params.authHookConfigured) return { networkAccess: 'unrestricted' }; + if (!isEnvTruthy(params.env[HTTP_ALLOW_HOST_PATH_INSTALL_ENV])) { + return { networkAccess: 'public-only' }; + } + + const rawRoot = (params.env[HTTP_HOST_PATH_INSTALL_ROOT_ENV] ?? '').trim(); + if (!rawRoot) { + throw new AppError( + 'INVALID_ARGS', + `${HTTP_ALLOW_HOST_PATH_INSTALL_ENV} requires ${HTTP_HOST_PATH_INSTALL_ROOT_ENV}`, + ); + } + const configuredRoot = path.resolve(expandUserHomePath(rawRoot, { env: params.env })); + let root: string; + try { + root = await fs.realpath(configuredRoot); + const stats = await fs.stat(root); + if (!stats.isDirectory()) throw new Error('not a directory'); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + `${HTTP_HOST_PATH_INSTALL_ROOT_ENV} must name an existing directory: ${configuredRoot}`, + { root: configuredRoot }, + error, + ); + } + return { networkAccess: 'public-only', hostPathInstallRoot: root }; +} + +export async function confineHttpInstallSourcePath(rawPath: string, root: string): Promise { + const candidate = path.resolve(expandUserHomePath(rawPath)); + let resolved: string; + try { + resolved = await fs.realpath(candidate); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: source.path must name an existing path below the approved root', + { root }, + error, + ); + } + const relative = path.relative(root, resolved); + if ( + relative !== '' && + (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + ) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: source.path resolves outside the approved root', + { root }, + ); + } + return resolved; +} + +export async function applyHttpInstallSourceTrustPolicy( + request: DaemonRequest, + policy: HttpTrustPolicy, +): Promise { + if (policy.networkAccess !== 'public-only') return request; + const source = request.meta?.installSource; + if (!source || source.kind !== 'path') return request; + + const uploadedArtifactId = request.meta?.uploadedArtifactId; + if (typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) return request; + if (!policy.hostPathInstallRoot) { + throw new AppError( + 'INVALID_ARGS', + `Invalid params: path install sources are disabled on the remote HTTP surface; set ${HTTP_ALLOW_HOST_PATH_INSTALL_ENV}=true and ${HTTP_HOST_PATH_INSTALL_ROOT_ENV} to opt in`, + ); + } + return { + ...request, + meta: { + ...request.meta, + installSource: { + kind: 'path', + path: await confineHttpInstallSourcePath(source.path, policy.hostPathInstallRoot), + }, + }, + }; +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 7ca6c58f6c..691f41c433 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -49,7 +49,10 @@ export type DaemonOpenLifecycle = { beforeDispatch?: (session: SessionState) => Promise; }; +export type DaemonNetworkAccessPolicy = 'unrestricted' | 'public-only'; + type DaemonRequestInternal = { + networkAccess?: DaemonNetworkAccessPolicy; openLifecycle?: DaemonOpenLifecycle; /** * Request-owned capability used when a fresh replay discovers its device diff --git a/tsdown.config.ts b/tsdown.config.ts index 1277f297d8..894f49387f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ 'internal/bin': 'src/bin.ts', 'internal/companion-tunnel': 'src/client/companion-tunnel.ts', 'internal/daemon': 'src/daemon.ts', + 'internal/run-script-http-child': 'src/daemon/adapters/maestro/run-script-http-child.ts', 'internal/png-worker': 'packages/capture-kit/src/png-worker.ts', 'internal/update-check-entry': 'src/utils/update-check-entry.ts', }, From c2d5ac633c8e841a2558d23827bc009a7560db4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 08:32:57 +0200 Subject: [PATCH 2/8] refactor: simplify trust policy plumbing --- .../src/install-source-download.ts | 1 + .../src/install-source-network-transport.ts | 4 +- ...test.ts => install-source-network.test.ts} | 2 +- .../src/install-source-network.ts | 138 ++++++++++++++++-- .../provision-kit/src/network-trust-policy.ts | 127 ---------------- .../adapters/maestro/run-script-execution.ts | 75 ++++------ .../adapters/maestro/run-script-http-child.ts | 115 ++++++--------- src/daemon/server/http-server.ts | 15 +- src/daemon/server/http-trust-policy.ts | 39 +++-- 9 files changed, 228 insertions(+), 288 deletions(-) rename packages/provision-kit/src/{network-trust-policy.test.ts => install-source-network.test.ts} (94%) delete mode 100644 packages/provision-kit/src/network-trust-policy.ts diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index 25277c9ad5..589425931b 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -64,6 +64,7 @@ async function requestHop( family: approved.family, headers: { ...headers, 'accept-encoding': 'identity' }, signal, + method: 'GET', }); } catch (error) { if (error instanceof AppError) throw error; diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index b53f75fcdd..a1805a72de 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -14,7 +14,7 @@ export async function requestApprovedUrl(params: { family: 4 | 6; headers: Record; signal: AbortSignal; - method?: 'GET' | 'POST'; + method: 'GET' | 'POST'; body?: string; }): Promise { const proxy = resolveProxyForUrl(params.url); @@ -29,7 +29,7 @@ export async function requestApprovedUrl(params: { dispatcher, headers: proxy ? { ...params.headers, host: params.url.host } : params.headers, maxRedirections: 0, - method: params.method ?? 'GET', + method: params.method, signal: params.signal, ...(params.body !== undefined ? { body: params.body } : {}), }; diff --git a/packages/provision-kit/src/network-trust-policy.test.ts b/packages/provision-kit/src/install-source-network.test.ts similarity index 94% rename from packages/provision-kit/src/network-trust-policy.test.ts rename to packages/provision-kit/src/install-source-network.test.ts index 52c541a8a4..42a08c3a8a 100644 --- a/packages/provision-kit/src/network-trust-policy.test.ts +++ b/packages/provision-kit/src/install-source-network.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import dns from 'node:dns/promises'; import { afterEach, test, vi } from 'vitest'; -import { approvePublicNetworkUrl, isBlockedIpAddress } from './network-trust-policy.ts'; +import { approvePublicNetworkUrl, isBlockedIpAddress } from './install-source-network.ts'; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/provision-kit/src/install-source-network.ts b/packages/provision-kit/src/install-source-network.ts index de95ae6a31..5841421bbe 100644 --- a/packages/provision-kit/src/install-source-network.ts +++ b/packages/provision-kit/src/install-source-network.ts @@ -1,20 +1,138 @@ -import { approvePublicNetworkUrl } from './network-trust-policy.ts'; -export { - approvePublicNetworkUrl, - isBlockedIpAddress, - isBlockedSourceHostname, -} from './network-trust-policy.ts'; +import dns from 'node:dns/promises'; +import net from 'node:net'; +import { + AppError, + createRequestCanceledError, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; +import ipaddr from 'ipaddr.js'; + +type ApprovedPublicNetworkAddress = { + address: string; + family: 4 | 6; +}; + +type PublicNetworkApprovalOptions = { + signal?: AbortSignal; + label?: string; + hint?: string; +}; export async function approveDownloadSourceUrl( parsedUrl: URL, signal?: AbortSignal, -): Promise<{ - address: string; - family: 4 | 6; -}> { +): Promise { return await approvePublicNetworkUrl(parsedUrl, { signal, label: 'source URL', hint: 'Use a public artifact URL.', }); } + +export async function approvePublicNetworkUrl( + parsedUrl: URL, + options: PublicNetworkApprovalOptions = {}, +): Promise { + const label = options.label ?? 'URL'; + const displayLabel = capitalizeLabel(label); + const hint = options.hint ?? 'Use a public URL.'; + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new AppError('INVALID_ARGS', `Unsupported ${label} protocol: ${parsedUrl.protocol}`); + } + if (parsedUrl.username || parsedUrl.password) { + throw new AppError('INVALID_ARGS', `${displayLabel} credentials are not allowed`); + } + throwIfAborted(options.signal); + const hostname = canonicalHostname(parsedUrl.hostname, displayLabel, hint); + if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname, displayLabel, hint); + const literalFamily = net.isIP(hostname); + if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; + + let resolved: Array<{ address: string; family: number }>; + try { + resolved = await lookupWithSignal(hostname, options.signal); + } catch (error) { + if (isRequestCanceledError(error)) throw error; + throw new AppError( + 'INVALID_ARGS', + `${displayLabel} host could not be resolved: ${hostname}`, + { hint }, + error, + ); + } + if (resolved.length === 0) { + throw new AppError('INVALID_ARGS', `${displayLabel} host could not be resolved: ${hostname}`, { + hint, + }); + } + if (resolved.some((entry) => isBlockedIpAddress(entry.address))) { + blockedHost(hostname, displayLabel, hint); + } + const selected = resolved[0]!; + return { address: selected.address, family: selected.family as 4 | 6 }; +} + +export function isBlockedSourceHostname(hostname: string): boolean { + let canonical: string; + try { + canonical = canonicalHostname(hostname, 'Source URL', 'Use a public artifact URL.'); + } catch { + return true; + } + if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; + return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); +} + +export function isBlockedIpAddress(address: string): boolean { + try { + const parsed = ipaddr.process(stripAddressBrackets(address)); + return parsed.range() !== 'unicast'; + } catch { + return true; + } +} + +async function lookupWithSignal( + hostname: string, + signal: AbortSignal | undefined, +): Promise> { + const lookup = dns.lookup(hostname, { all: true, verbatim: true }); + if (!signal) return await lookup; + return await new Promise((resolve, reject) => { + const abort = () => reject(canceledError(signal.reason)); + signal.addEventListener('abort', abort, { once: true }); + void lookup.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw canceledError(signal.reason); +} + +function canceledError(cause: unknown): AppError { + return createRequestCanceledError(undefined, cause); +} + +function canonicalHostname(hostname: string, label: string, hint: string): string { + const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); + if (!stripped || stripped.includes('%')) { + throw new AppError('INVALID_ARGS', `${label} host is not allowed`, { hint }); + } + return stripped; +} + +function blockedHost(hostname: string, label: string, hint: string): never { + throw new AppError( + 'INVALID_ARGS', + `${label} host is not allowed because it resolves to a non-public address: ${hostname}`, + { hint }, + ); +} + +function capitalizeLabel(label: string): string { + return label.length === 0 ? label : `${label[0]!.toUpperCase()}${label.slice(1)}`; +} + +function stripAddressBrackets(value: string): string { + return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; +} diff --git a/packages/provision-kit/src/network-trust-policy.ts b/packages/provision-kit/src/network-trust-policy.ts deleted file mode 100644 index e9c3966ef2..0000000000 --- a/packages/provision-kit/src/network-trust-policy.ts +++ /dev/null @@ -1,127 +0,0 @@ -import dns from 'node:dns/promises'; -import net from 'node:net'; -import { - AppError, - createRequestCanceledError, - isRequestCanceledError, -} from '@agent-device/kernel/errors'; -import ipaddr from 'ipaddr.js'; - -export type ApprovedPublicNetworkAddress = { - address: string; - family: 4 | 6; -}; - -export type PublicNetworkApprovalOptions = { - signal?: AbortSignal; - label?: string; - hint?: string; -}; - -export async function approvePublicNetworkUrl( - parsedUrl: URL, - options: PublicNetworkApprovalOptions = {}, -): Promise { - const label = options.label ?? 'URL'; - const displayLabel = capitalizeLabel(label); - const hint = options.hint ?? 'Use a public URL.'; - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new AppError('INVALID_ARGS', `Unsupported ${label} protocol: ${parsedUrl.protocol}`); - } - if (parsedUrl.username || parsedUrl.password) { - throw new AppError('INVALID_ARGS', `${displayLabel} credentials are not allowed`); - } - throwIfAborted(options.signal); - const hostname = canonicalHostname(parsedUrl.hostname, displayLabel, hint); - if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname, displayLabel, hint); - const literalFamily = net.isIP(hostname); - if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; - - let resolved: Array<{ address: string; family: number }>; - try { - resolved = await lookupWithSignal(hostname, options.signal); - } catch (error) { - if (isRequestCanceledError(error)) throw error; - throw new AppError( - 'INVALID_ARGS', - `${displayLabel} host could not be resolved: ${hostname}`, - { hint }, - error, - ); - } - if (resolved.length === 0) { - throw new AppError('INVALID_ARGS', `${displayLabel} host could not be resolved: ${hostname}`, { - hint, - }); - } - if (resolved.some((entry) => isBlockedIpAddress(entry.address))) { - blockedHost(hostname, displayLabel, hint); - } - const selected = resolved[0]!; - return { address: selected.address, family: selected.family as 4 | 6 }; -} - -export function isBlockedSourceHostname(hostname: string): boolean { - let canonical: string; - try { - canonical = canonicalHostname(hostname, 'Source URL', 'Use a public artifact URL.'); - } catch { - return true; - } - if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; - return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); -} - -export function isBlockedIpAddress(address: string): boolean { - try { - const parsed = ipaddr.process(stripAddressBrackets(address)); - return parsed.range() !== 'unicast'; - } catch { - return true; - } -} - -async function lookupWithSignal( - hostname: string, - signal: AbortSignal | undefined, -): Promise> { - const lookup = dns.lookup(hostname, { all: true, verbatim: true }); - if (!signal) return await lookup; - return await new Promise((resolve, reject) => { - const abort = () => reject(canceledError(signal.reason)); - signal.addEventListener('abort', abort, { once: true }); - void lookup.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); - }); -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted) throw canceledError(signal.reason); -} - -function canceledError(cause: unknown): AppError { - return createRequestCanceledError(undefined, cause); -} - -function canonicalHostname(hostname: string, label: string, hint: string): string { - const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); - if (!stripped || stripped.includes('%')) { - throw new AppError('INVALID_ARGS', `${label} host is not allowed`, { hint }); - } - return stripped; -} - -function blockedHost(hostname: string, label: string, hint: string): never { - throw new AppError( - 'INVALID_ARGS', - `${label} host is not allowed because it resolves to a non-public address: ${hostname}`, - { hint }, - ); -} - -function capitalizeLabel(label: string): string { - return label.length === 0 ? label : `${label[0]!.toUpperCase()}${label.slice(1)}`; -} - -function stripAddressBrackets(value: string): string { - return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; -} diff --git a/src/daemon/adapters/maestro/run-script-execution.ts b/src/daemon/adapters/maestro/run-script-execution.ts index 011555274b..907f94e920 100644 --- a/src/daemon/adapters/maestro/run-script-execution.ts +++ b/src/daemon/adapters/maestro/run-script-execution.ts @@ -6,16 +6,11 @@ import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { runCmdSync } from '@agent-device/host-kit/command'; import { stripUndefined } from '@agent-device/kernel/record'; import type { DaemonNetworkAccessPolicy } from '../../types.ts'; +import type { RunScriptHttpRequest, RunScriptHttpResponse } from './run-script-http-child.ts'; const RUN_SCRIPT_TIMEOUT_MS = 30_000; const RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS = 1_000; -type HttpResponse = { - status: number; - body: string; - headers: Record; -}; - /** * Executes a trusted flow-local script with the compatibility helpers Maestro * exposes. `node:vm` isolates globals for the run but is not a security @@ -109,20 +104,39 @@ function safeRunScriptJsonReviver(key: string, value: unknown): unknown { } function runHttpRequestSync( - method: string, + method: RunScriptHttpRequest['method'], url: string, options?: { headers?: Record; body?: string }, networkAccess: DaemonNetworkAccessPolicy = 'unrestricted', -): HttpResponse { +): RunScriptHttpResponse { const result = runCmdSync(process.execPath, resolveHttpChildArgs(), { stdin: JSON.stringify(buildHttpChildInput(method, url, options, networkAccess)), timeoutMs: RUN_SCRIPT_TIMEOUT_MS, allowFailure: true, }); if (result.exitCode !== 0) { - throwHttpChildFailure(method, url, result.exitCode, result.stderr); + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(result.stderr)}`, + { + exitCode: result.exitCode, + stderr: result.stderr, + }, + ); + } + try { + return JSON.parse(result.stdout) as RunScriptHttpResponse; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript http.${method.toLowerCase()} returned invalid JSON for ${url}`, + { + stdout: result.stdout.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), + stderr: result.stderr.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), + }, + error instanceof Error ? error : undefined, + ); } - return parseHttpChildResponse(method, url, result.stdout, result.stderr); } function resolveHttpChildArgs(): string[] { @@ -151,11 +165,11 @@ function resolveHttpChildModulePath(importMetaUrl: string): string | null { } function buildHttpChildInput( - method: string, + method: RunScriptHttpRequest['method'], url: string, options: { headers?: Record; body?: string } | undefined, networkAccess: DaemonNetworkAccessPolicy, -): Record { +): RunScriptHttpRequest { return { method, url, @@ -165,43 +179,6 @@ function buildHttpChildInput( }; } -function throwHttpChildFailure( - method: string, - url: string, - exitCode: number | null, - stderr: string, -): never { - throw new AppError( - 'COMMAND_FAILED', - `Maestro runScript http.${method.toLowerCase()} failed for ${url}: ${trimHttpErrorOutput(stderr)}`, - { - exitCode, - stderr, - }, - ); -} - -function parseHttpChildResponse( - method: string, - url: string, - stdout: string, - stderr: string, -): HttpResponse { - try { - return JSON.parse(stdout) as HttpResponse; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Maestro runScript http.${method.toLowerCase()} returned invalid JSON for ${url}`, - { - stdout: stdout.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), - stderr: stderr.slice(0, RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS), - }, - error instanceof Error ? error : undefined, - ); - } -} - function validateOutputKeys(output: Record, scriptPath: string): void { for (const key of Object.keys(output)) { if (!key.includes('.')) continue; diff --git a/src/daemon/adapters/maestro/run-script-http-child.ts b/src/daemon/adapters/maestro/run-script-http-child.ts index 2db3fd9a29..87904dec6c 100644 --- a/src/daemon/adapters/maestro/run-script-http-child.ts +++ b/src/daemon/adapters/maestro/run-script-http-child.ts @@ -1,4 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; +import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import type { DaemonNetworkAccessPolicy } from '../../types.ts'; import { @@ -34,10 +35,6 @@ type PublicRequestState = { body?: string; }; -type PublicResponseOutcome = - | { response: RunScriptHttpResponse } - | { nextRequest: PublicRequestState }; - export async function executeRunScriptHttpRequest( input: RunScriptHttpRequest, ): Promise { @@ -64,24 +61,9 @@ async function executeUnrestrictedRequest( } async function executePublicRequest(input: RunScriptHttpRequest): Promise { - let request = createPublicRequest(input); - const signal = AbortSignal.timeout(30_000); - - for (let redirectCount = 0; ; redirectCount += 1) { - const response = await requestPublicHop(request, signal); - try { - const outcome = await processPublicResponse(response, request, redirectCount); - if ('response' in outcome) return outcome.response; - request = outcome.nextRequest; - } finally { - await response.close(); - } - } -} - -function createPublicRequest(input: RunScriptHttpRequest): PublicRequestState { + let request: PublicRequestState; try { - return { + request = { url: new URL(input.url), headers: { ...input.headers }, method: input.method, @@ -90,45 +72,37 @@ function createPublicRequest(input: RunScriptHttpRequest): PublicRequestState { } catch { throw new AppError('INVALID_ARGS', 'Invalid Maestro runScript HTTP URL'); } -} - -async function requestPublicHop( - request: PublicRequestState, - signal: AbortSignal, -): Promise { - const approved = await approvePublicNetworkUrl(request.url, { - signal, - label: 'Maestro runScript URL', - hint: 'Use a public URL.', - }); - return await requestApprovedUrl({ - url: request.url, - approvedAddress: approved.address, - family: approved.family, - headers: request.headers, - signal, - method: request.method, - ...(request.body !== undefined ? { body: request.body } : {}), - }); -} + const signal = AbortSignal.timeout(30_000); -async function processPublicResponse( - response: InstallSourceNetworkResponse, - request: PublicRequestState, - redirectCount: number, -): Promise { - if (REDIRECT_STATUSES.has(response.statusCode)) { - response.body.resume?.(); - return { nextRequest: createRedirectRequest(response, request, redirectCount) }; + for (let redirectCount = 0; ; redirectCount += 1) { + const approved = await approvePublicNetworkUrl(request.url, { + signal, + label: 'Maestro runScript URL', + hint: 'Use a public URL.', + }); + const response = await requestApprovedUrl({ + url: request.url, + approvedAddress: approved.address, + family: approved.family, + headers: request.headers, + signal, + method: request.method, + ...(request.body !== undefined ? { body: request.body } : {}), + }); + try { + if (!REDIRECT_STATUSES.has(response.statusCode)) { + return { + status: response.statusCode, + body: await readResponseBody(response), + headers: responseHeadersToRecord(response.headers), + }; + } + response.body.resume?.(); + request = createRedirectRequest(response, request, redirectCount); + } finally { + await response.close(); + } } - const responseBody = await readResponseBody(response); - return { - response: { - status: response.statusCode, - body: responseBody, - headers: responseHeadersToRecord(response.headers), - }, - }; } function createRedirectRequest( @@ -169,8 +143,8 @@ async function readResponseBody(response: InstallSourceNetworkResponse): Promise const chunks: Buffer[] = []; let bytesSeen = 0; for await (const chunk of response.body as AsyncIterable) { - const chunkBytes = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength; - const nextBytesSeen = bytesSeen + chunkBytes; + const buffer = Buffer.from(chunk); + const nextBytesSeen = bytesSeen + buffer.byteLength; if (nextBytesSeen > MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES) { throw new AppError( 'COMMAND_FAILED', @@ -179,7 +153,7 @@ async function readResponseBody(response: InstallSourceNetworkResponse): Promise ); } bytesSeen = nextBytesSeen; - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); + chunks.push(buffer); } return Buffer.concat(chunks, bytesSeen).toString('utf8'); } @@ -210,19 +184,15 @@ function crossOriginHeaders(headers: Record): Record { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); - } - return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; +function readInput(): unknown { + return JSON.parse(readFileSync(0, 'utf8')) as unknown; } function parseInput(value: unknown): RunScriptHttpRequest { const record = parseInputRecord(value); return { method: parseMethod(record.method), - url: parseInputUrl(record.url), + url: parseUrl(record.url), headers: parseHeaders(record.headers), ...parseBody(record.body), networkAccess: parseNetworkAccess(record.networkAccess), @@ -230,7 +200,9 @@ function parseInput(value: unknown): RunScriptHttpRequest { } function parseInputRecord(value: unknown): Record { - if (!value || typeof value !== 'object') throw new Error('invalid Maestro runScript HTTP input'); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid Maestro runScript HTTP input'); + } return value as Record; } @@ -241,7 +213,7 @@ function parseMethod(value: unknown): 'GET' | 'POST' { return value; } -function parseInputUrl(value: unknown): string { +function parseUrl(value: unknown): string { if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP input'); return value; } @@ -265,7 +237,6 @@ function parseBody(value: unknown): { body?: string } { } function parseNetworkAccess(value: unknown): DaemonNetworkAccessPolicy { - if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP input'); if (value !== 'unrestricted' && value !== 'public-only') { throw new Error('invalid Maestro runScript HTTP network policy'); } @@ -273,7 +244,7 @@ function parseNetworkAccess(value: unknown): DaemonNetworkAccessPolicy { } async function runChild(): Promise { - const response = await executeRunScriptHttpRequest(parseInput(await readInput())); + const response = await executeRunScriptHttpRequest(parseInput(readInput())); process.stdout.write(JSON.stringify(response)); } diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index c31830a24b..f4bcbf3b60 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -39,7 +39,7 @@ import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; -import { applyHttpInstallSourceTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; +import { applyHttpTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -534,10 +534,7 @@ export async function createDaemonHttpServer(options: { }): Promise { const environment = options.env ?? process.env; const authHook = await loadHttpAuthHook(environment); - const trustPolicy = await resolveHttpTrustPolicy({ - authHookConfigured: authHook !== null, - env: environment, - }); + const trustPolicy = resolveHttpTrustPolicy({ authHookConfigured: authHook !== null }); const { handleRequest, token, retainArtifacts = false, resolveRequestDiagnosticsPath } = options; return http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { @@ -713,13 +710,7 @@ export async function createDaemonHttpServer(options: { if (daemonRequest.flags?.tenant !== undefined) { daemonRequest.flags = { ...daemonRequest.flags, tenant: tenantTrust.tenantId }; } - daemonRequest = await applyHttpInstallSourceTrustPolicy(daemonRequest, trustPolicy); - if (trustPolicy.networkAccess === 'public-only') { - daemonRequest.internal = { - ...daemonRequest.internal, - networkAccess: trustPolicy.networkAccess, - }; - } + daemonRequest = applyHttpTrustPolicy(daemonRequest, trustPolicy); let canceledInFlight = false; // Request-scoped cancellation: mark this request canceled whenever its client diff --git a/src/daemon/server/http-trust-policy.ts b/src/daemon/server/http-trust-policy.ts index 078673411e..fd107461c3 100644 --- a/src/daemon/server/http-trust-policy.ts +++ b/src/daemon/server/http-trust-policy.ts @@ -73,30 +73,39 @@ export async function confineHttpInstallSourcePath(rawPath: string, root: string return resolved; } -export async function applyHttpInstallSourceTrustPolicy( +export async function applyHttpTrustPolicy( request: DaemonRequest, policy: HttpTrustPolicy, ): Promise { if (policy.networkAccess !== 'public-only') return request; const source = request.meta?.installSource; - if (!source || source.kind !== 'path') return request; - const uploadedArtifactId = request.meta?.uploadedArtifactId; - if (typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) return request; - if (!policy.hostPathInstallRoot) { - throw new AppError( - 'INVALID_ARGS', - `Invalid params: path install sources are disabled on the remote HTTP surface; set ${HTTP_ALLOW_HOST_PATH_INSTALL_ENV}=true and ${HTTP_HOST_PATH_INSTALL_ROOT_ENV} to opt in`, - ); + let confinedSource = source; + if ( + source?.kind === 'path' && + !(typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) + ) { + const root = policy.hostPathInstallRoot; + if (!root) { + throw new AppError( + 'INVALID_ARGS', + `Invalid params: path install sources are disabled on the remote HTTP surface; set ${HTTP_ALLOW_HOST_PATH_INSTALL_ENV}=true and ${HTTP_HOST_PATH_INSTALL_ROOT_ENV} to opt in`, + ); + } + confinedSource = { + ...source, + path: await confineHttpInstallSourcePath(source.path, root), + }; } + return { ...request, - meta: { - ...request.meta, - installSource: { - kind: 'path', - path: await confineHttpInstallSourcePath(source.path, policy.hostPathInstallRoot), - }, + ...(confinedSource === source + ? {} + : { meta: { ...request.meta, installSource: confinedSource } }), + internal: { + ...request.internal, + networkAccess: policy.networkAccess, }, }; } From 5dc736ab1fd97f7a69873c890e670ce8064bee09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 10:23:44 +0200 Subject: [PATCH 3/8] fix: remove remote host path install opt-in --- src/cli-schema/cli-help-topics.test.ts | 13 +- src/cli-schema/cli-help.ts | 10 +- .../http-server-rpc-validation.test.ts | 125 ++---------------- src/daemon/server/http-trust-policy.test.ts | 99 ++++++-------- src/daemon/server/http-trust-policy.ts | 91 ++----------- 5 files changed, 60 insertions(+), 278 deletions(-) diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index de6334a3a2..c59c6fb4ea 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -143,14 +143,6 @@ test('root help routes detailed reference material to progressive topics', async assert.match(commandsHelp, /Default config files: ~\/\.agent-device\/config\.json/); assert.match(commandsHelp, /^Environment:/m); assert.match(commandsHelp, /AGENT_DEVICE_SESSION\s+Explicit session name/); - assert.match( - commandsHelp, - /AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL\s+Explicit remote host-path install opt-in/, - ); - assert.match( - commandsHelp, - /AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT\s+Realpath root for remote host-path installs/, - ); assert.match(commandsHelp, /^Examples:/m); assert.match(commandsHelp, /agent-device open Settings --platform ios/); @@ -473,9 +465,8 @@ test('usageForCommand resolves remote help topic', async () => { 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, /AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL=true/); - assert.match(help, /AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT/); - assert.match(help, /rejects traversal or symlink escapes/); + 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/); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index ef6f4fee6f..6a4b4a11a3 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -37,14 +37,6 @@ const ENVIRONMENT_LINES = [ label: 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', description: 'Remote daemon service/API token', }, - { - label: 'AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL', - description: 'Explicit remote host-path install opt-in', - }, - { - label: 'AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT', - description: 'Realpath root for remote host-path installs', - }, { label: 'AGENT_DEVICE_CLOUD_BASE_URL', description: 'Bridge/control-plane API origin for cloud auth and /api-keys', @@ -830,7 +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 by default, and Maestro runScript HTTP helpers allow only public network destinations. To opt into path installs, set AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL=true and AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT to an existing directory; the daemon resolves the requested path and rejects traversal or symlink escapes. No-hook local HTTP and socket flows retain their local behavior. + 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. diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index 4dd87119e5..cd2de40284 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -178,26 +178,6 @@ async function withInstallFromSourceRpcServer( } } -test('install_from_source rejects a host path source on an authenticated HTTP surface', async (t) => { - const root = mkdtempForTestSync('agent-device-http-path-boundary-'); - try { - await withInstallFromSourceRpcServer( - async (post) => { - const { status, body, dispatched } = await post({ kind: 'path', path: '/etc/passwd' }); - - assert.equal(status, 400); - assert.equal(body.error?.code, -32602); - assert.equal(body.error?.data?.code, 'INVALID_ARGS'); - assert.equal(dispatched.length, 0, 'a host path source must never reach the handler'); - }, - t, - remoteHttpEnvironment(writeAllowingAuthHook(root)), - ); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - test('install_from_source still admits url sources', async (t) => { await withInstallFromSourceRpcServer(async (post) => { const { status, dispatched } = await post({ kind: 'url', url: 'https://example.com/app.apk' }); @@ -225,14 +205,19 @@ 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 by default', async (t) => { +test('remote HTTP rejects host path install sources', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const root = mkdtempForTestSync('agent-device-http-path-default-'); const hookPath = writeAllowingAuthHook(root); + const artifactPath = path.join(root, 'app.apk'); + fs.writeFileSync(artifactPath, 'untrusted host file'); + const env = remoteHttpEnvironment(hookPath); + env.AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL = 'true'; + env.AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT = root; let handlerCalls = 0; const server = await createDaemonHttpServer({ - env: remoteHttpEnvironment(hookPath), + env, handleRequest: async (): Promise => { handlerCalls += 1; return { ok: true, data: {} }; @@ -243,7 +228,7 @@ test('remote HTTP rejects host path install sources by default', async (t) => { const port = await listenOnLoopback(server); const response = await postInstallFromSource(port, { kind: 'path', - path: path.join(root, 'app.apk'), + path: artifactPath, }); assert.equal(response.status, 400); assert.equal(response.body.error?.code, -32602); @@ -335,17 +320,6 @@ test('remote HTTP accepts an uploaded path artifact without resolving the client } }); -test('remote HTTP confines opted-in path installs to the realpath-approved root', async (t) => { - if (await skipWhenLoopbackUnavailable(t)) return; - - await withPathConfinementServer(async ({ port, received, inside, traversal, outsideLink }) => { - await assertAllowedPathInstall(port, received, inside); - await assertRejectedPathInstall(port, traversal); - await assertRejectedPathInstall(port, outsideLink); - assert.equal(received.length, 1); - }); -}); - test('local HTTP keeps path install sources available without an auth hook', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; @@ -384,95 +358,16 @@ function writeAllowingAuthHook(root: string): string { return hookPath; } -async function withPathConfinementServer( - run: (context: { - port: number; - received: DaemonRequest[]; - inside: string; - traversal: string; - outsideLink: string; - }) => Promise, -): Promise { - const parent = mkdtempForTestSync('agent-device-http-path-confinement-'); - const root = path.join(parent, 'approved'); - const outside = path.join(parent, 'outside.apk'); - const inside = path.join(root, 'inside.apk'); - const insideLink = path.join(root, 'inside-link.apk'); - const outsideLink = path.join(root, 'outside-link.apk'); - fs.mkdirSync(root); - fs.writeFileSync(outside, 'outside'); - fs.writeFileSync(inside, 'inside'); - fs.symlinkSync(inside, insideLink); - fs.symlinkSync(outside, outsideLink); - const hookPath = writeAllowingAuthHook(parent); - const received: DaemonRequest[] = []; - const server = await createDaemonHttpServer({ - env: remoteHttpEnvironment(hookPath, { - AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL: 'true', - AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT: root, - }), - handleRequest: async (request): Promise => { - received.push(request); - return { ok: true, data: {} }; - }, - }); - - try { - await run({ - port: await listenOnLoopback(server), - received, - inside, - traversal: path.join(root, '..', path.basename(outside)), - outsideLink, - }); - } finally { - await closeLoopbackServer(server); - fs.rmSync(parent, { recursive: true, force: true }); - } -} - -async function assertAllowedPathInstall( - port: number, - received: DaemonRequest[], - inside: string, -): Promise { - const allowed = await postInstallFromSource(port, { - kind: 'path', - path: path.join(path.dirname(inside), 'inside-link.apk'), - }); - assert.equal(allowed.status, 200); - assert.equal(received[0]?.meta?.installSource?.kind, 'path'); - assert.equal( - received[0]?.meta?.installSource?.kind === 'path' - ? received[0].meta.installSource.path - : undefined, - fs.realpathSync(inside), - ); - assert.equal(received[0]?.internal?.networkAccess, 'public-only'); -} - -async function assertRejectedPathInstall(port: number, rejectedPath: string): Promise { - const rejected = await postInstallFromSource(port, { kind: 'path', path: rejectedPath }); - assert.equal(rejected.status, 400, rejectedPath); - assert.equal(rejected.body.error?.data?.code, 'INVALID_ARGS'); - assert.match(rejected.body.error?.message ?? '', /outside the approved root/); -} - -function remoteHttpEnvironment( - hookPath: string, - overrides: NodeJS.ProcessEnv = {}, -): NodeJS.ProcessEnv { +function remoteHttpEnvironment(hookPath: string): NodeJS.ProcessEnv { const env = localHttpEnvironment(); env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath; - return { ...env, ...overrides }; + return env; } function localHttpEnvironment(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.AGENT_DEVICE_HTTP_AUTH_HOOK; delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT; - delete env.AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL; - delete env.AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT; return env; } diff --git a/src/daemon/server/http-trust-policy.test.ts b/src/daemon/server/http-trust-policy.test.ts index ab81c19b90..01670f082b 100644 --- a/src/daemon/server/http-trust-policy.test.ts +++ b/src/daemon/server/http-trust-policy.test.ts @@ -1,70 +1,49 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; import { test } from 'vitest'; -import { - HTTP_ALLOW_HOST_PATH_INSTALL_ENV, - HTTP_HOST_PATH_INSTALL_ROOT_ENV, - confineHttpInstallSourcePath, - resolveHttpTrustPolicy, -} from './http-trust-policy.ts'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonRequest } from '../types.ts'; +import { applyHttpTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; -test('remote host-path opt-in fails closed without an approved root', async () => { - await assert.rejects( - resolveHttpTrustPolicy({ - authHookConfigured: true, - env: { [HTTP_ALLOW_HOST_PATH_INSTALL_ENV]: 'true' }, - }), - new RegExp( - `${HTTP_ALLOW_HOST_PATH_INSTALL_ENV} requires ${HTTP_HOST_PATH_INSTALL_ROOT_ENV}`, - 'i', - ), - ); +test('an auth-hook HTTP server uses the public-only trust policy', () => { + assert.deepEqual(resolveHttpTrustPolicy({ authHookConfigured: true }), { + networkAccess: 'public-only', + }); }); -test('remote host-path opt-in resolves a symlinked approved root', async () => { - const parent = mkdtempForTestSync('agent-device-http-trust-root-'); - const root = path.join(parent, 'root'); - const rootLink = path.join(parent, 'root-link'); - fs.mkdirSync(root); - fs.symlinkSync(root, rootLink); +test('an HTTP server without an auth hook keeps local unrestricted behavior', () => { + assert.deepEqual(resolveHttpTrustPolicy({ authHookConfigured: false }), { + networkAccess: 'unrestricted', + }); +}); - try { - const policy = await resolveHttpTrustPolicy({ - authHookConfigured: true, - env: { - [HTTP_ALLOW_HOST_PATH_INSTALL_ENV]: 'yes', - [HTTP_HOST_PATH_INSTALL_ROOT_ENV]: rootLink, - }, - }); - assert.equal(policy.networkAccess, 'public-only'); - assert.equal(policy.hostPathInstallRoot, fs.realpathSync(root)); - } finally { - fs.rmSync(parent, { recursive: true, force: true }); - } +test('the public-only policy rejects every unbacked host path source', () => { + assert.throws( + () => + applyHttpTrustPolicy( + requestWithMeta({ installSource: { kind: 'path', path: '/etc/passwd' } }), + { networkAccess: 'public-only' }, + ), + /path install sources are disabled on the remote HTTP surface/, + ); }); -test('path confinement returns the canonical target and rejects a symlink escape', async () => { - const parent = mkdtempForTestSync('agent-device-http-trust-path-'); - const root = path.join(parent, 'root'); - const inside = path.join(root, 'inside.apk'); - const outside = path.join(parent, 'outside.apk'); - const link = path.join(root, 'outside-link.apk'); - fs.mkdirSync(root); - fs.writeFileSync(inside, 'inside'); - fs.writeFileSync(outside, 'outside'); - fs.symlinkSync(outside, link); +test('the public-only policy preserves daemon-owned uploaded path sources', () => { + const request = requestWithMeta({ + installSource: { kind: 'path', path: '/client/path.apk' }, + uploadedArtifactId: 'artifact-1', + }); - try { - const approvedRoot = fs.realpathSync(root); - assert.equal(await confineHttpInstallSourcePath(inside, approvedRoot), fs.realpathSync(inside)); - await assert.rejects( - confineHttpInstallSourcePath(link, approvedRoot), - (error: unknown) => - error instanceof Error && error.message.includes('resolves outside the approved root'), - ); - } finally { - fs.rmSync(parent, { recursive: true, force: true }); - } + assert.deepEqual(applyHttpTrustPolicy(request, { networkAccess: 'public-only' }), { + ...request, + internal: { networkAccess: 'public-only' }, + }); }); + +function requestWithMeta(meta: DaemonRequest['meta']): DaemonRequest { + return { + command: 'install_source', + positionals: [], + token: 'test-token', + session: 'test-session', + meta, + }; +} diff --git a/src/daemon/server/http-trust-policy.ts b/src/daemon/server/http-trust-policy.ts index fd107461c3..d68334fa40 100644 --- a/src/daemon/server/http-trust-policy.ts +++ b/src/daemon/server/http-trust-policy.ts @@ -1,108 +1,33 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonNetworkAccessPolicy, DaemonRequest } from '../types.ts'; -import { expandUserHomePath } from '@agent-device/host-kit/file'; -import { isEnvTruthy } from '@agent-device/host-kit/retry'; - -export const HTTP_ALLOW_HOST_PATH_INSTALL_ENV = 'AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL'; -export const HTTP_HOST_PATH_INSTALL_ROOT_ENV = 'AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT'; export type HttpTrustPolicy = { networkAccess: DaemonNetworkAccessPolicy; - hostPathInstallRoot?: string; }; -export async function resolveHttpTrustPolicy(params: { - authHookConfigured: boolean; - env: NodeJS.ProcessEnv; -}): Promise { - if (!params.authHookConfigured) return { networkAccess: 'unrestricted' }; - if (!isEnvTruthy(params.env[HTTP_ALLOW_HOST_PATH_INSTALL_ENV])) { - return { networkAccess: 'public-only' }; - } - - const rawRoot = (params.env[HTTP_HOST_PATH_INSTALL_ROOT_ENV] ?? '').trim(); - if (!rawRoot) { - throw new AppError( - 'INVALID_ARGS', - `${HTTP_ALLOW_HOST_PATH_INSTALL_ENV} requires ${HTTP_HOST_PATH_INSTALL_ROOT_ENV}`, - ); - } - const configuredRoot = path.resolve(expandUserHomePath(rawRoot, { env: params.env })); - let root: string; - try { - root = await fs.realpath(configuredRoot); - const stats = await fs.stat(root); - if (!stats.isDirectory()) throw new Error('not a directory'); - } catch (error) { - throw new AppError( - 'INVALID_ARGS', - `${HTTP_HOST_PATH_INSTALL_ROOT_ENV} must name an existing directory: ${configuredRoot}`, - { root: configuredRoot }, - error, - ); - } - return { networkAccess: 'public-only', hostPathInstallRoot: root }; -} - -export async function confineHttpInstallSourcePath(rawPath: string, root: string): Promise { - const candidate = path.resolve(expandUserHomePath(rawPath)); - let resolved: string; - try { - resolved = await fs.realpath(candidate); - } catch (error) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid params: source.path must name an existing path below the approved root', - { root }, - error, - ); - } - const relative = path.relative(root, resolved); - if ( - relative !== '' && - (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) - ) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid params: source.path resolves outside the approved root', - { root }, - ); - } - return resolved; +export function resolveHttpTrustPolicy(params: { authHookConfigured: boolean }): HttpTrustPolicy { + return { networkAccess: params.authHookConfigured ? 'public-only' : 'unrestricted' }; } -export async function applyHttpTrustPolicy( +export function applyHttpTrustPolicy( request: DaemonRequest, policy: HttpTrustPolicy, -): Promise { +): DaemonRequest { if (policy.networkAccess !== 'public-only') return request; const source = request.meta?.installSource; const uploadedArtifactId = request.meta?.uploadedArtifactId; - let confinedSource = source; if ( source?.kind === 'path' && !(typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) ) { - const root = policy.hostPathInstallRoot; - if (!root) { - throw new AppError( - 'INVALID_ARGS', - `Invalid params: path install sources are disabled on the remote HTTP surface; set ${HTTP_ALLOW_HOST_PATH_INSTALL_ENV}=true and ${HTTP_HOST_PATH_INSTALL_ROOT_ENV} to opt in`, - ); - } - confinedSource = { - ...source, - path: await confineHttpInstallSourcePath(source.path, root), - }; + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: path install sources are disabled on the remote HTTP surface', + ); } return { ...request, - ...(confinedSource === source - ? {} - : { meta: { ...request.meta, installSource: confinedSource } }), internal: { ...request.internal, networkAccess: policy.networkAccess, From 9fca4c94fd91f37e5c8d8f64b926ad303644fe1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:51:22 +0200 Subject: [PATCH 4/8] test: cover remote HTTP trust boundaries --- .../src/install-source-network.test.ts | 53 +++- .../__tests__/run-script-http-child.test.ts | 228 +++++++++++++++++- .../adapters/maestro/run-script-http-child.ts | 4 +- 3 files changed, 277 insertions(+), 8 deletions(-) diff --git a/packages/provision-kit/src/install-source-network.test.ts b/packages/provision-kit/src/install-source-network.test.ts index 42a08c3a8a..0606e470f4 100644 --- a/packages/provision-kit/src/install-source-network.test.ts +++ b/packages/provision-kit/src/install-source-network.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import dns from 'node:dns/promises'; import { afterEach, test, vi } from 'vitest'; -import { approvePublicNetworkUrl, isBlockedIpAddress } from './install-source-network.ts'; +import { + approvePublicNetworkUrl, + isBlockedIpAddress, + isBlockedSourceHostname, +} from './install-source-network.ts'; afterEach(() => { vi.restoreAllMocks(); @@ -27,6 +31,32 @@ test('blocks loopback, private, link-local, shared, and reserved address classes assert.equal(isBlockedIpAddress('2001:4860:4860::8888'), false); }); +test('rejects credentials and malformed hosts before DNS lookup', async () => { + const lookupMock = vi.spyOn(dns, 'lookup'); + + await assert.rejects( + approvePublicNetworkUrl(new URL('https://user:pass@example.test/artifact'), { + label: 'source URL', + }), + /credentials are not allowed/, + ); + await assert.rejects( + approvePublicNetworkUrl( + { + protocol: 'https:', + username: '', + password: '', + hostname: 'bad%host', + } as URL, + { label: 'source URL' }, + ), + /host is not allowed/, + ); + + assert.equal(isBlockedSourceHostname('bad%host'), true); + assert.equal(lookupMock.mock.calls.length, 0); +}); + test('rejects a hostname when any DNS answer is non-public', async () => { vi.spyOn(dns, 'lookup').mockResolvedValue([ { address: '93.184.216.34', family: 4 }, @@ -41,6 +71,27 @@ test('rejects a hostname when any DNS answer is non-public', async () => { ); }); +test('fails closed when DNS resolution fails or returns no answers', async () => { + const lookupMock = vi + .spyOn(dns, 'lookup') + .mockRejectedValueOnce(new Error('DNS unavailable')) + .mockResolvedValueOnce([] as never); + + await assert.rejects( + approvePublicNetworkUrl(new URL('https://unavailable.example/artifact'), { + label: 'source URL', + }), + /host could not be resolved/, + ); + await assert.rejects( + approvePublicNetworkUrl(new URL('https://empty.example/artifact'), { + label: 'source URL', + }), + /host could not be resolved/, + ); + assert.equal(lookupMock.mock.calls.length, 2); +}); + test('returns the approved address and family for a public literal', async () => { await assert.doesNotReject( approvePublicNetworkUrl(new URL('https://93.184.216.34/artifact'), { diff --git a/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts index c6822f5ee8..3aabad791b 100644 --- a/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts +++ b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; import { Readable } from 'node:stream'; -import { beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; +import { runCmdSync } from '@agent-device/host-kit/command'; const mocks = vi.hoisted(() => ({ approvePublicNetworkUrl: vi.fn(), @@ -18,12 +20,67 @@ vi.mock('@agent-device/provision-kit/install-source-network-transport', () => ({ import { executeRunScriptHttpRequest, MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, + parseRunScriptHttpRequest, } from '../run-script-http-child.ts'; beforeEach(() => { vi.resetAllMocks(); }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('keeps unrestricted requests on the local fetch path', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + status: 201, + text: async () => 'created', + headers: new Headers([['x-result', 'ok']]), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: 'http://127.0.0.1:8080/local', + headers: { authorization: 'secret' }, + body: '{}', + networkAccess: 'unrestricted', + }); + + assert.deepEqual(result, { status: 201, body: 'created', headers: { 'x-result': 'ok' } }); + assert.deepEqual(fetchMock.mock.calls[0], [ + 'http://127.0.0.1:8080/local', + { method: 'POST', headers: { authorization: 'secret' }, body: '{}' }, + ]); +}); + +test('fails unrestricted requests when the local fetch implementation is unavailable', async () => { + vi.stubGlobal('fetch', undefined); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'http://127.0.0.1:8080/local', + headers: {}, + networkAccess: 'unrestricted', + }), + /global fetch is required/, + ); +}); + +test('rejects malformed public URLs before network approval', async () => { + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'not a URL', + headers: {}, + networkAccess: 'public-only', + }), + /Invalid Maestro runScript HTTP URL/, + ); + assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 0); +}); + test('revalidates every Maestro HTTP redirect before dispatching it', async () => { mocks.approvePublicNetworkUrl.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 }); mocks.approvePublicNetworkUrl.mockRejectedValueOnce( @@ -93,6 +150,96 @@ test('classifies redirects before reading their response bodies', async () => { assert.equal(resumed, true); }); +test('rejects redirects without a location and closes the response', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + const redirect = response(302); + mocks.requestApprovedUrl.mockResolvedValue(redirect); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'https://example.test/start', + headers: {}, + networkAccess: 'public-only', + }), + /redirect limit was exceeded/, + ); + assert.equal(redirect.close.mock.calls.length, 1); +}); + +test('rejects HTTPS redirects that downgrade to HTTP', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + const redirect = response(302, { location: 'http://example.test/next' }); + mocks.requestApprovedUrl.mockResolvedValue(redirect); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'https://example.test/start', + headers: {}, + networkAccess: 'public-only', + }), + /redirect downgraded HTTPS/, + ); + assert.equal(mocks.requestApprovedUrl.mock.calls.length, 1); +}); + +test('limits cross-origin redirect headers and preserves POST bodies for 307', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + mocks.requestApprovedUrl + .mockResolvedValueOnce(response(307, { location: ['https://other.test/next'] })) + .mockResolvedValueOnce( + response(200, { 'set-cookie': ['a=1', 'b=2'], 'x-empty': undefined }, 'ok'), + ); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: 'https://example.test/start', + headers: { + authorization: 'secret', + accept: 'application/json', + 'user-agent': 'agent-device-test', + }, + body: '{}', + networkAccess: 'public-only', + }); + + assert.deepEqual(result, { + status: 200, + body: 'ok', + headers: { 'set-cookie': 'a=1, b=2', 'x-empty': '' }, + }); + const redirectedRequest = mocks.requestApprovedUrl.mock.calls[1]?.[0]; + assert.equal(redirectedRequest?.url.href, 'https://other.test/next'); + assert.deepEqual(redirectedRequest?.headers, { + accept: 'application/json', + 'user-agent': 'agent-device-test', + }); + assert.equal(redirectedRequest?.approvedAddress, '93.184.216.34'); + assert.equal(redirectedRequest?.family, 4); + assert.equal(redirectedRequest?.method, 'POST'); + assert.equal(redirectedRequest?.body, '{}'); +}); + +test('stops after the redirect limit', async () => { + mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + mocks.requestApprovedUrl.mockImplementation(async () => + response(302, { location: 'https://example.test/next' }), + ); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'https://example.test/start', + headers: {}, + networkAccess: 'public-only', + }), + /redirect limit was exceeded/, + ); + assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 6); + assert.equal(mocks.requestApprovedUrl.mock.calls.length, 6); +}); + test('caps final public response bodies while consuming them incrementally', async () => { mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); let yieldedChunks = 0; @@ -122,18 +269,89 @@ test('caps final public response bodies while consuming them incrementally', asy function response( statusCode: number, - headers: Record = {}, + headers: Record = {}, body: string | NodeJS.ReadableStream = '', ): { statusCode: number; - headers: Record; + headers: Record; body: NodeJS.ReadableStream; - close: () => Promise; + close: ReturnType; } { return { statusCode, headers, body: typeof body === 'string' ? Readable.from([body]) : body, - close: async () => {}, + close: vi.fn(async () => {}), }; } + +const childModulePath = fileURLToPath(new URL('../run-script-http-child.ts', import.meta.url)); + +test('reports malformed JSON received by the HTTP child', () => { + const result = runHttpChildRaw('{'); + + assert.notEqual(result.exitCode, 0); + assert.match(result.stderr, /SyntaxError|Unexpected end/); +}); + +for (const [name, input, errorMessage] of [ + ['null input', null, 'invalid Maestro runScript HTTP input'], + ['array input', [], 'invalid Maestro runScript HTTP input'], + [ + 'unsupported method', + { method: 'PUT', url: 'https://example.test', headers: {}, networkAccess: 'public-only' }, + 'invalid Maestro runScript HTTP method', + ], + [ + 'non-string URL', + { method: 'GET', url: 42, headers: {}, networkAccess: 'public-only' }, + 'invalid Maestro runScript HTTP input', + ], + [ + 'missing headers', + { method: 'GET', url: 'https://example.test', networkAccess: 'public-only' }, + 'invalid Maestro runScript HTTP headers', + ], + [ + 'array headers', + { method: 'GET', url: 'https://example.test', headers: [], networkAccess: 'public-only' }, + 'invalid Maestro runScript HTTP headers', + ], + [ + 'non-string header value', + { + method: 'GET', + url: 'https://example.test', + headers: { authorization: 42 }, + networkAccess: 'public-only', + }, + 'invalid Maestro runScript HTTP headers', + ], + [ + 'non-string body', + { + method: 'GET', + url: 'https://example.test', + headers: {}, + body: 42, + networkAccess: 'public-only', + }, + 'invalid Maestro runScript HTTP body', + ], + [ + 'unsupported network policy', + { method: 'GET', url: 'https://example.test', headers: {} }, + 'invalid Maestro runScript HTTP network policy', + ], +] as const) { + test(`rejects ${name} from the HTTP child`, () => { + assert.throws(() => parseRunScriptHttpRequest(input), new RegExp(errorMessage)); + }); +} + +function runHttpChildRaw(stdin: string) { + return runCmdSync(process.execPath, ['--experimental-strip-types', childModulePath], { + stdin, + allowFailure: true, + }); +} diff --git a/src/daemon/adapters/maestro/run-script-http-child.ts b/src/daemon/adapters/maestro/run-script-http-child.ts index 87904dec6c..e44e869d98 100644 --- a/src/daemon/adapters/maestro/run-script-http-child.ts +++ b/src/daemon/adapters/maestro/run-script-http-child.ts @@ -188,7 +188,7 @@ function readInput(): unknown { return JSON.parse(readFileSync(0, 'utf8')) as unknown; } -function parseInput(value: unknown): RunScriptHttpRequest { +export function parseRunScriptHttpRequest(value: unknown): RunScriptHttpRequest { const record = parseInputRecord(value); return { method: parseMethod(record.method), @@ -244,7 +244,7 @@ function parseNetworkAccess(value: unknown): DaemonNetworkAccessPolicy { } async function runChild(): Promise { - const response = await executeRunScriptHttpRequest(parseInput(readInput())); + const response = await executeRunScriptHttpRequest(parseRunScriptHttpRequest(readInput())); process.stdout.write(JSON.stringify(response)); } From 99f57d9604db01fd28bb3dc460d9751f5866b7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:54:52 +0200 Subject: [PATCH 5/8] test: attest remote RPC tenant fixtures --- src/daemon/__tests__/http-server-rpc-validation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index cd2de40284..8d7728d6b5 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -354,7 +354,7 @@ test('local HTTP keeps path install sources available without an auth hook', asy function writeAllowingAuthHook(root: string): string { const hookPath = path.join(root, 'auth-hook.mjs'); - fs.writeFileSync(hookPath, 'export default () => true;\n'); + fs.writeFileSync(hookPath, "export default () => ({ tenantId: 'tenant-test' });\n"); return hookPath; } From 2dd7fb61ab6f4f5db54c2073f7f4ebe7026e4e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 14:02:07 +0200 Subject: [PATCH 6/8] test: cover malformed network addresses --- packages/provision-kit/src/install-source-network.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provision-kit/src/install-source-network.test.ts b/packages/provision-kit/src/install-source-network.test.ts index 0606e470f4..5d582f27dd 100644 --- a/packages/provision-kit/src/install-source-network.test.ts +++ b/packages/provision-kit/src/install-source-network.test.ts @@ -27,6 +27,7 @@ test('blocks loopback, private, link-local, shared, and reserved address classes ]) { assert.equal(isBlockedIpAddress(address), true, address); } + assert.equal(isBlockedIpAddress('not-an-ip'), true); assert.equal(isBlockedIpAddress('93.184.216.34'), false); assert.equal(isBlockedIpAddress('2001:4860:4860::8888'), false); }); From 5349a5ab082b60b453e49b3dffe3550270e46d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 15:11:04 +0200 Subject: [PATCH 7/8] fix: constrain proxy HTTP network policy --- src/__tests__/daemon-proxy.test.ts | 107 +++++++++++++++++++- src/daemon/http-contract.ts | 2 + src/daemon/server/http-server.ts | 11 +- src/daemon/server/http-trust-policy.test.ts | 20 ++++ src/daemon/server/http-trust-policy.ts | 12 ++- src/remote/daemon-proxy.ts | 5 + 6 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts index 7361d87526..2a924df586 100644 --- a/src/__tests__/daemon-proxy.test.ts +++ b/src/__tests__/daemon-proxy.test.ts @@ -1,8 +1,15 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import http from 'node:http'; +import { Readable } from 'node:stream'; import { createDaemonProxyServer } from '../remote/daemon-proxy.ts'; +import { createDaemonHttpServer } from '../daemon/server/http-server.ts'; +import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http-child.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, @@ -10,6 +17,12 @@ import { skipWhenLoopbackUnavailable, } from './test-utils/loopback.ts'; +const requestApprovedUrlMock = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/provision-kit/install-source-network-transport', () => ({ + requestApprovedUrl: requestApprovedUrlMock, +})); + const PROXY_ARTIFACT_INVENTORY_ENTRY = { id: 'shot-1', filename: 'shot.png', @@ -24,6 +37,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t) let upstreamAuth = ''; let upstreamTokenHeader = ''; + let upstreamNetworkAccess = ''; let upstreamBody: Record | undefined; const upstream = http.createServer((req, res) => { if (req.url === '/health') { @@ -34,6 +48,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) => { @@ -88,6 +103,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 { @@ -96,6 +112,95 @@ 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: {}, + networkAccess: request.internal?.networkAccess ?? 'unrestricted', + }), + }; + }, + }); + 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 }; + }; + + const loopbackResponse = await post(`http://127.0.0.1:${targetPort}/secret`); + assert.equal(loopbackResponse.status, 400); + 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'); + assert.equal(requestApprovedUrlMock.mock.calls.length, 0); + + requestApprovedUrlMock.mockResolvedValue({ + statusCode: 200, + headers: {}, + body: Readable.from(['public-response']), + close: async () => {}, + }); + const publicUrl = 'https://93.184.216.34/public'; + const publicResponse = await post(publicUrl); + assert.equal(publicResponse.status, 200); + assert.deepEqual(publicResponse.body.result?.data, { + status: 200, + body: 'public-response', + headers: {}, + }); + assert.equal(requestApprovedUrlMock.mock.calls.length, 1); + assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].url.href, publicUrl); + assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].approvedAddress, '93.184.216.34'); + assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].family, 4); + } finally { + requestApprovedUrlMock.mockReset(); + await closeLoopbackServer(proxy); + await closeLoopbackServer(daemon); + await closeLoopbackServer(loopbackTarget); + } +}); + test('daemon proxy rejects unauthenticated rpc requests', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; diff --git a/src/daemon/http-contract.ts b/src/daemon/http-contract.ts index 2079f21e3b..7ab89f0337 100644 --- a/src/daemon/http-contract.ts +++ b/src/daemon/http-contract.ts @@ -1,5 +1,7 @@ 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'; export function buildDaemonHttpBaseUrl(baseUrl: string): string { return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH); diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index f4bcbf3b60..1e31a02eeb 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -33,7 +33,7 @@ import { shouldStreamRequestProgress, } from '../request-progress-protocol.ts'; import { buildDaemonHealthPayload } from '../http-health.ts'; -import { DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts'; +import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts'; import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts'; import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; @@ -534,7 +534,6 @@ export async function createDaemonHttpServer(options: { }): Promise { const environment = options.env ?? process.env; const authHook = await loadHttpAuthHook(environment); - const trustPolicy = resolveHttpTrustPolicy({ authHookConfigured: authHook !== null }); const { handleRequest, token, retainArtifacts = false, resolveRequestDiagnosticsPath } = options; return http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { @@ -710,7 +709,13 @@ export async function createDaemonHttpServer(options: { if (daemonRequest.flags?.tenant !== undefined) { daemonRequest.flags = { ...daemonRequest.flags, tenant: tenantTrust.tenantId }; } - daemonRequest = applyHttpTrustPolicy(daemonRequest, trustPolicy); + daemonRequest = applyHttpTrustPolicy( + daemonRequest, + resolveHttpTrustPolicy({ + authHookConfigured: authHook !== null, + networkAccessMarker: req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER], + }), + ); let canceledInFlight = false; // Request-scoped cancellation: mark this request canceled whenever its client diff --git a/src/daemon/server/http-trust-policy.test.ts b/src/daemon/server/http-trust-policy.test.ts index 01670f082b..a70a586076 100644 --- a/src/daemon/server/http-trust-policy.test.ts +++ b/src/daemon/server/http-trust-policy.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { DAEMON_HTTP_PUBLIC_NETWORK_ACCESS } from '../http-contract.ts'; import type { DaemonRequest } from '../types.ts'; import { applyHttpTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; @@ -15,6 +16,25 @@ test('an HTTP server without an auth hook keeps local unrestricted behavior', () }); }); +test('a proxy network marker selects public-only behavior without an auth hook', () => { + assert.deepEqual( + resolveHttpTrustPolicy({ + authHookConfigured: false, + networkAccessMarker: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + }), + { networkAccess: 'public-only' }, + ); +}); + +test('an invalid or ambiguous proxy network marker fails closed', () => { + for (const networkAccessMarker of ['unrestricted', ['public-only', 'public-only']]) { + assert.throws( + () => resolveHttpTrustPolicy({ authHookConfigured: false, networkAccessMarker }), + /Invalid daemon HTTP network access marker/, + ); + } +}); + test('the public-only policy rejects every unbacked host path source', () => { assert.throws( () => diff --git a/src/daemon/server/http-trust-policy.ts b/src/daemon/server/http-trust-policy.ts index d68334fa40..299fd6f8e6 100644 --- a/src/daemon/server/http-trust-policy.ts +++ b/src/daemon/server/http-trust-policy.ts @@ -1,11 +1,21 @@ import { AppError } from '@agent-device/kernel/errors'; import type { DaemonNetworkAccessPolicy, DaemonRequest } from '../types.ts'; +import { DAEMON_HTTP_PUBLIC_NETWORK_ACCESS } from '../http-contract.ts'; export type HttpTrustPolicy = { networkAccess: DaemonNetworkAccessPolicy; }; -export function resolveHttpTrustPolicy(params: { authHookConfigured: boolean }): HttpTrustPolicy { +export function resolveHttpTrustPolicy(params: { + authHookConfigured: boolean; + networkAccessMarker?: string | string[]; +}): HttpTrustPolicy { + if (params.networkAccessMarker !== undefined) { + if (params.networkAccessMarker !== DAEMON_HTTP_PUBLIC_NETWORK_ACCESS) { + throw new AppError('INVALID_ARGS', 'Invalid daemon HTTP network access marker'); + } + return { networkAccess: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS }; + } return { networkAccess: params.authHookConfigured ? 'public-only' : 'unrestricted' }; } diff --git a/src/remote/daemon-proxy.ts b/src/remote/daemon-proxy.ts index 236f437d86..9781a8d85c 100644 --- a/src/remote/daemon-proxy.ts +++ b/src/remote/daemon-proxy.ts @@ -7,6 +7,8 @@ import { readNodeHttpRequestBody } from '../utils/node-http.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import { DAEMON_HTTP_BASE_PATH, + DAEMON_HTTP_NETWORK_ACCESS_HEADER, + DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, DAEMON_HTTP_TENANT_HEADER, buildDaemonHttpAuthHeaders, buildDaemonHttpUrl, @@ -335,6 +337,9 @@ function buildUpstreamHeaders( if (route === '/rpc' && !headers.has('content-type')) { headers.set('content-type', 'application/json'); } + if (route === '/rpc') { + headers.set(DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS); + } for (const [name, value] of Object.entries(buildDaemonHttpAuthHeaders(upstreamToken))) { headers.set(name, value); } From f02681888661862dd90b681cfbc99d798941f808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 16:31:27 +0200 Subject: [PATCH 8/8] refactor: preserve fetch semantics in remote HTTP --- .../src/install-source-download.ts | 1 - .../src/install-source-network-transport.ts | 5 +- .../src/install-source-network.test.ts | 108 ------ .../src/install-source-network.ts | 118 +++--- src/__tests__/daemon-proxy.test.ts | 36 +- .../http-server-rpc-validation.test.ts | 87 ++--- .../__tests__/run-script-http-child.test.ts | 358 +----------------- .../maestro/__tests__/run-script-http.test.ts | 257 +++++++++++++ .../maestro/__tests__/run-script.test.ts | 18 +- .../adapters/maestro/daemon-runtime-port.ts | 2 +- .../adapters/maestro/run-script-execution.ts | 21 +- .../adapters/maestro/run-script-http-child.ts | 255 +------------ .../adapters/maestro/run-script-http.ts | 204 ++++++++++ src/daemon/server/http-server.ts | 65 +++- src/daemon/server/http-trust-policy.test.ts | 69 ---- src/daemon/server/http-trust-policy.ts | 46 --- src/daemon/types.ts | 4 +- 17 files changed, 620 insertions(+), 1034 deletions(-) delete mode 100644 packages/provision-kit/src/install-source-network.test.ts create mode 100644 src/daemon/adapters/maestro/__tests__/run-script-http.test.ts create mode 100644 src/daemon/adapters/maestro/run-script-http.ts delete mode 100644 src/daemon/server/http-trust-policy.test.ts delete mode 100644 src/daemon/server/http-trust-policy.ts diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index 589425931b..25277c9ad5 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -64,7 +64,6 @@ async function requestHop( family: approved.family, headers: { ...headers, 'accept-encoding': 'identity' }, signal, - method: 'GET', }); } catch (error) { if (error instanceof AppError) throw error; diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index a1805a72de..a32efb9f27 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -14,8 +14,6 @@ export async function requestApprovedUrl(params: { family: 4 | 6; headers: Record; signal: AbortSignal; - method: 'GET' | 'POST'; - body?: string; }): Promise { const proxy = resolveProxyForUrl(params.url); const dispatcher = proxy @@ -29,9 +27,8 @@ export async function requestApprovedUrl(params: { dispatcher, headers: proxy ? { ...params.headers, host: params.url.host } : params.headers, maxRedirections: 0, - method: params.method, + method: 'GET' as const, signal: params.signal, - ...(params.body !== undefined ? { body: params.body } : {}), }; const response = await request(dispatchUrl, requestOptions); return { diff --git a/packages/provision-kit/src/install-source-network.test.ts b/packages/provision-kit/src/install-source-network.test.ts deleted file mode 100644 index 5d582f27dd..0000000000 --- a/packages/provision-kit/src/install-source-network.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict'; -import dns from 'node:dns/promises'; -import { afterEach, test, vi } from 'vitest'; -import { - approvePublicNetworkUrl, - isBlockedIpAddress, - isBlockedSourceHostname, -} from './install-source-network.ts'; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -test('blocks loopback, private, link-local, shared, and reserved address classes', () => { - for (const address of [ - '127.0.0.1', - '10.0.0.1', - '172.16.0.1', - '192.168.0.1', - '169.254.1.1', - '100.64.0.1', - '203.0.113.10', - '::1', - 'fe80::1', - 'fd00::1', - '::ffff:127.0.0.1', - ]) { - assert.equal(isBlockedIpAddress(address), true, address); - } - assert.equal(isBlockedIpAddress('not-an-ip'), true); - assert.equal(isBlockedIpAddress('93.184.216.34'), false); - assert.equal(isBlockedIpAddress('2001:4860:4860::8888'), false); -}); - -test('rejects credentials and malformed hosts before DNS lookup', async () => { - const lookupMock = vi.spyOn(dns, 'lookup'); - - await assert.rejects( - approvePublicNetworkUrl(new URL('https://user:pass@example.test/artifact'), { - label: 'source URL', - }), - /credentials are not allowed/, - ); - await assert.rejects( - approvePublicNetworkUrl( - { - protocol: 'https:', - username: '', - password: '', - hostname: 'bad%host', - } as URL, - { label: 'source URL' }, - ), - /host is not allowed/, - ); - - assert.equal(isBlockedSourceHostname('bad%host'), true); - assert.equal(lookupMock.mock.calls.length, 0); -}); - -test('rejects a hostname when any DNS answer is non-public', async () => { - vi.spyOn(dns, 'lookup').mockResolvedValue([ - { address: '93.184.216.34', family: 4 }, - { address: '127.0.0.1', family: 4 }, - ] as never); - - await assert.rejects( - approvePublicNetworkUrl(new URL('https://example.test/artifact'), { - label: 'source URL', - }), - /non-public address/, - ); -}); - -test('fails closed when DNS resolution fails or returns no answers', async () => { - const lookupMock = vi - .spyOn(dns, 'lookup') - .mockRejectedValueOnce(new Error('DNS unavailable')) - .mockResolvedValueOnce([] as never); - - await assert.rejects( - approvePublicNetworkUrl(new URL('https://unavailable.example/artifact'), { - label: 'source URL', - }), - /host could not be resolved/, - ); - await assert.rejects( - approvePublicNetworkUrl(new URL('https://empty.example/artifact'), { - label: 'source URL', - }), - /host could not be resolved/, - ); - assert.equal(lookupMock.mock.calls.length, 2); -}); - -test('returns the approved address and family for a public literal', async () => { - await assert.doesNotReject( - approvePublicNetworkUrl(new URL('https://93.184.216.34/artifact'), { - label: 'Maestro runScript URL', - }), - ); - assert.deepEqual( - await approvePublicNetworkUrl(new URL('https://93.184.216.34/artifact'), { - label: 'Maestro runScript URL', - }), - { address: '93.184.216.34', family: 4 }, - ); -}); diff --git a/packages/provision-kit/src/install-source-network.ts b/packages/provision-kit/src/install-source-network.ts index 5841421bbe..d135e95c19 100644 --- a/packages/provision-kit/src/install-source-network.ts +++ b/packages/provision-kit/src/install-source-network.ts @@ -7,91 +7,47 @@ import { } from '@agent-device/kernel/errors'; import ipaddr from 'ipaddr.js'; -type ApprovedPublicNetworkAddress = { - address: string; - family: 4 | 6; -}; - -type PublicNetworkApprovalOptions = { - signal?: AbortSignal; - label?: string; - hint?: string; -}; - export async function approveDownloadSourceUrl( parsedUrl: URL, signal?: AbortSignal, -): Promise { - return await approvePublicNetworkUrl(parsedUrl, { - signal, - label: 'source URL', - hint: 'Use a public artifact URL.', - }); -} - -export async function approvePublicNetworkUrl( - parsedUrl: URL, - options: PublicNetworkApprovalOptions = {}, -): Promise { - const label = options.label ?? 'URL'; - const displayLabel = capitalizeLabel(label); - const hint = options.hint ?? 'Use a public URL.'; +): Promise<{ + address: string; + family: 4 | 6; +}> { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new AppError('INVALID_ARGS', `Unsupported ${label} protocol: ${parsedUrl.protocol}`); + throw new AppError('INVALID_ARGS', `Unsupported source URL protocol: ${parsedUrl.protocol}`); } if (parsedUrl.username || parsedUrl.password) { - throw new AppError('INVALID_ARGS', `${displayLabel} credentials are not allowed`); + throw new AppError('INVALID_ARGS', 'Source URL credentials are not allowed'); } - throwIfAborted(options.signal); - const hostname = canonicalHostname(parsedUrl.hostname, displayLabel, hint); - if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname, displayLabel, hint); + throwIfAborted(signal); + const hostname = canonicalHostname(parsedUrl.hostname); + if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname); const literalFamily = net.isIP(hostname); if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; let resolved: Array<{ address: string; family: number }>; try { - resolved = await lookupWithSignal(hostname, options.signal); + resolved = await lookupWithSignal(hostname, signal); } catch (error) { if (isRequestCanceledError(error)) throw error; throw new AppError( 'INVALID_ARGS', - `${displayLabel} host could not be resolved: ${hostname}`, - { hint }, + `Source URL host could not be resolved: ${hostname}`, + { hint: 'Use a public artifact URL.' }, error, ); } if (resolved.length === 0) { - throw new AppError('INVALID_ARGS', `${displayLabel} host could not be resolved: ${hostname}`, { - hint, + throw new AppError('INVALID_ARGS', `Source URL host could not be resolved: ${hostname}`, { + hint: 'Use a public artifact URL.', }); } - if (resolved.some((entry) => isBlockedIpAddress(entry.address))) { - blockedHost(hostname, displayLabel, hint); - } + if (resolved.some((entry) => isBlockedIpAddress(entry.address))) blockedHost(hostname); const selected = resolved[0]!; return { address: selected.address, family: selected.family as 4 | 6 }; } -export function isBlockedSourceHostname(hostname: string): boolean { - let canonical: string; - try { - canonical = canonicalHostname(hostname, 'Source URL', 'Use a public artifact URL.'); - } catch { - return true; - } - if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; - return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); -} - -export function isBlockedIpAddress(address: string): boolean { - try { - const parsed = ipaddr.process(stripAddressBrackets(address)); - return parsed.range() !== 'unicast'; - } catch { - return true; - } -} - async function lookupWithSignal( hostname: string, signal: AbortSignal | undefined, @@ -113,26 +69,44 @@ function canceledError(cause: unknown): AppError { return createRequestCanceledError(undefined, cause); } -function canonicalHostname(hostname: string, label: string, hint: string): string { - const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); - if (!stripped || stripped.includes('%')) { - throw new AppError('INVALID_ARGS', `${label} host is not allowed`, { hint }); +export function isBlockedSourceHostname(hostname: string): boolean { + let canonical: string; + try { + canonical = canonicalHostname(hostname); + } catch { + return true; } - return stripped; + if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; + return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); } -function blockedHost(hostname: string, label: string, hint: string): never { - throw new AppError( - 'INVALID_ARGS', - `${label} host is not allowed because it resolves to a non-public address: ${hostname}`, - { hint }, - ); +export function isBlockedIpAddress(address: string): boolean { + try { + const parsed = ipaddr.process(stripAddressBrackets(address)); + return parsed.range() !== 'unicast'; + } catch { + return true; + } } -function capitalizeLabel(label: string): string { - return label.length === 0 ? label : `${label[0]!.toUpperCase()}${label.slice(1)}`; +function canonicalHostname(hostname: string): string { + const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); + if (!stripped || stripped.includes('%')) { + throw new AppError('INVALID_ARGS', 'Source URL host is not allowed', { + hint: 'Use a public artifact URL.', + }); + } + return stripped; } function stripAddressBrackets(value: string): string { return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; } + +function blockedHost(hostname: string): never { + throw new AppError( + 'INVALID_ARGS', + `Source URL host is not allowed because it resolves to a non-public address: ${hostname}`, + { hint: 'Use a public artifact URL.' }, + ); +} diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts index 2a924df586..6d66d96264 100644 --- a/src/__tests__/daemon-proxy.test.ts +++ b/src/__tests__/daemon-proxy.test.ts @@ -1,11 +1,10 @@ -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import http from 'node:http'; -import { Readable } from 'node:stream'; import { createDaemonProxyServer } from '../remote/daemon-proxy.ts'; import { createDaemonHttpServer } from '../daemon/server/http-server.ts'; -import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http-child.ts'; +import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http.ts'; import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, @@ -17,12 +16,6 @@ import { skipWhenLoopbackUnavailable, } from './test-utils/loopback.ts'; -const requestApprovedUrlMock = vi.hoisted(() => vi.fn()); - -vi.mock('@agent-device/provision-kit/install-source-network-transport', () => ({ - requestApprovedUrl: requestApprovedUrlMock, -})); - const PROXY_ARTIFACT_INVENTORY_ENTRY = { id: 'shot-1', filename: 'shot.png', @@ -134,7 +127,7 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async ( method: 'GET', url, headers: {}, - networkAccess: request.internal?.networkAccess ?? 'unrestricted', + publicNetworkOnly: request.internal?.publicNetworkOnly === true, }), }; }, @@ -169,32 +162,11 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async ( }; const loopbackResponse = await post(`http://127.0.0.1:${targetPort}/secret`); - assert.equal(loopbackResponse.status, 400); + 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'); - assert.equal(requestApprovedUrlMock.mock.calls.length, 0); - - requestApprovedUrlMock.mockResolvedValue({ - statusCode: 200, - headers: {}, - body: Readable.from(['public-response']), - close: async () => {}, - }); - const publicUrl = 'https://93.184.216.34/public'; - const publicResponse = await post(publicUrl); - assert.equal(publicResponse.status, 200); - assert.deepEqual(publicResponse.body.result?.data, { - status: 200, - body: 'public-response', - headers: {}, - }); - assert.equal(requestApprovedUrlMock.mock.calls.length, 1); - assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].url.href, publicUrl); - assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].approvedAddress, '93.184.216.34'); - assert.equal(requestApprovedUrlMock.mock.calls[0]?.[0].family, 4); } finally { - requestApprovedUrlMock.mockReset(); await closeLoopbackServer(proxy); await closeLoopbackServer(daemon); await closeLoopbackServer(loopbackTarget); diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index 8d7728d6b5..8975a4390e 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -178,6 +178,17 @@ async function withInstallFromSourceRpcServer( } } +test('install_from_source rejects a host path source at the rpc boundary', async (t) => { + await withInstallFromSourceRpcServer(async (post) => { + const { status, body, dispatched } = await post({ kind: 'path', path: '/etc/passwd' }); + + assert.equal(status, 400); + assert.equal(body.error?.code, -32602); + assert.equal(body.error?.data?.code, 'INVALID_ARGS'); + assert.equal(dispatched.length, 0, 'a host path source must never reach the handler'); + }, t); +}); + test('install_from_source still admits url sources', async (t) => { await withInstallFromSourceRpcServer(async (post) => { const { status, dispatched } = await post({ kind: 'url', url: 'https://example.com/app.apk' }); @@ -205,41 +216,6 @@ 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', async (t) => { - if (await skipWhenLoopbackUnavailable(t)) return; - - const root = mkdtempForTestSync('agent-device-http-path-default-'); - const hookPath = writeAllowingAuthHook(root); - const artifactPath = path.join(root, 'app.apk'); - fs.writeFileSync(artifactPath, 'untrusted host file'); - const env = remoteHttpEnvironment(hookPath); - env.AGENT_DEVICE_HTTP_ALLOW_HOST_PATH_INSTALL = 'true'; - env.AGENT_DEVICE_HTTP_HOST_PATH_INSTALL_ROOT = root; - let handlerCalls = 0; - const server = await createDaemonHttpServer({ - env, - handleRequest: async (): Promise => { - handlerCalls += 1; - return { ok: true, data: {} }; - }, - }); - - try { - const port = await listenOnLoopback(server); - const response = await postInstallFromSource(port, { - kind: 'path', - path: artifactPath, - }); - 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 rejects host path install sources in the command RPC used by the CLI', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; @@ -320,12 +296,8 @@ test('remote HTTP accepts an uploaded path artifact without resolving the client } }); -test('local HTTP keeps path install sources available without an auth hook', async (t) => { +test('local command RPC keeps host paths unrestricted', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; - - const root = mkdtempForTestSync('agent-device-http-path-local-'); - const artifactPath = path.join(root, 'app.apk'); - fs.writeFileSync(artifactPath, 'local'); const received: DaemonRequest[] = []; const server = await createDaemonHttpServer({ env: localHttpEnvironment(), @@ -337,18 +309,20 @@ test('local HTTP keeps path install sources available without an auth hook', asy try { const port = await listenOnLoopback(server); - const response = await postInstallFromSource(port, { kind: 'path', path: artifactPath }); + 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.equal( - received[0]?.meta?.installSource?.kind === 'path' - ? received[0].meta.installSource.path - : undefined, - artifactPath, - ); + assert.deepEqual(received[0]?.meta?.installSource, { + kind: 'path', + path: '/tmp/local.apk', + }); assert.equal(received[0]?.internal, undefined); } finally { await closeLoopbackServer(server); - fs.rmSync(root, { recursive: true, force: true }); } }); @@ -371,23 +345,6 @@ function localHttpEnvironment(): NodeJS.ProcessEnv { return env; } -async function postInstallFromSource( - port: number, - source: Record, -): 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: 'install-source', - method: 'agent_device.install_from_source', - params: { platform: 'android', source }, - }), - }); - return { status: response.status, body: (await response.json()) as RpcErrorResponse }; -} - async function postCommandRpc( port: number, params: Record, diff --git a/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts index 3aabad791b..281b4141d1 100644 --- a/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts +++ b/src/daemon/adapters/maestro/__tests__/run-script-http-child.test.ts @@ -1,357 +1,17 @@ import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; -import { Readable } from 'node:stream'; -import { afterEach, beforeEach, test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import { test } from 'vitest'; import { runCmdSync } from '@agent-device/host-kit/command'; +import { runScriptHttpChild } from '../run-script-http-child.ts'; -const mocks = vi.hoisted(() => ({ - approvePublicNetworkUrl: vi.fn(), - requestApprovedUrl: vi.fn(), -})); - -vi.mock('@agent-device/provision-kit/install-source-network', () => ({ - approvePublicNetworkUrl: mocks.approvePublicNetworkUrl, -})); -vi.mock('@agent-device/provision-kit/install-source-network-transport', () => ({ - requestApprovedUrl: mocks.requestApprovedUrl, -})); - -import { - executeRunScriptHttpRequest, - MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, - parseRunScriptHttpRequest, -} from '../run-script-http-child.ts'; - -beforeEach(() => { - vi.resetAllMocks(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -test('keeps unrestricted requests on the local fetch path', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - status: 201, - text: async () => 'created', - headers: new Headers([['x-result', 'ok']]), - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: 'http://127.0.0.1:8080/local', - headers: { authorization: 'secret' }, - body: '{}', - networkAccess: 'unrestricted', - }); - - assert.deepEqual(result, { status: 201, body: 'created', headers: { 'x-result': 'ok' } }); - assert.deepEqual(fetchMock.mock.calls[0], [ - 'http://127.0.0.1:8080/local', - { method: 'POST', headers: { authorization: 'secret' }, body: '{}' }, - ]); -}); - -test('fails unrestricted requests when the local fetch implementation is unavailable', async () => { - vi.stubGlobal('fetch', undefined); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'http://127.0.0.1:8080/local', - headers: {}, - networkAccess: 'unrestricted', - }), - /global fetch is required/, - ); -}); - -test('rejects malformed public URLs before network approval', async () => { - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'not a URL', - headers: {}, - networkAccess: 'public-only', - }), - /Invalid Maestro runScript HTTP URL/, - ); - assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 0); -}); - -test('revalidates every Maestro HTTP redirect before dispatching it', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 }); - mocks.approvePublicNetworkUrl.mockRejectedValueOnce( - new AppError( - 'INVALID_ARGS', - 'Maestro runScript URL host is not allowed because it resolves to a non-public address: 127.0.0.1', - ), - ); - mocks.requestApprovedUrl.mockResolvedValue(response(302, { location: 'https://127.0.0.1/next' })); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'POST', - url: 'https://example.test/start', - headers: { authorization: 'secret', accept: 'application/json' }, - body: '{}', - networkAccess: 'public-only', - }), - /non-public address/, - ); - assert.equal(mocks.requestApprovedUrl.mock.calls.length, 1); - assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 2); - assert.equal(mocks.approvePublicNetworkUrl.mock.calls[1]?.[0].href, 'https://127.0.0.1/next'); -}); - -test('follows an approved same-origin Maestro HTTP redirect with fetch semantics', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - mocks.requestApprovedUrl - .mockResolvedValueOnce(response(302, { location: 'https://example.test/next' })) - .mockResolvedValueOnce(response(200, {}, 'ok')); - - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: 'https://example.test/start', - headers: { authorization: 'secret' }, - body: '{}', - networkAccess: 'public-only', - }); - - assert.deepEqual(result, { status: 200, body: 'ok', headers: {} }); - assert.equal(mocks.requestApprovedUrl.mock.calls.length, 2); - assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].method, 'GET'); - assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].body, undefined); - assert.equal(mocks.requestApprovedUrl.mock.calls[1]?.[0].headers.authorization, 'secret'); -}); - -test('classifies redirects before reading their response bodies', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - let resumed = false; - const redirectBody = { - resume: () => { - resumed = true; - }, - } as unknown as NodeJS.ReadableStream; - mocks.requestApprovedUrl - .mockResolvedValueOnce(response(302, { location: 'https://example.test/next' }, redirectBody)) - .mockResolvedValueOnce(response(200, {}, 'ok')); - - const result = await executeRunScriptHttpRequest({ - method: 'GET', - url: 'https://example.test/start', - headers: {}, - networkAccess: 'public-only', - }); - - assert.equal(result.body, 'ok'); - assert.equal(resumed, true); -}); - -test('rejects redirects without a location and closes the response', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - const redirect = response(302); - mocks.requestApprovedUrl.mockResolvedValue(redirect); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'https://example.test/start', - headers: {}, - networkAccess: 'public-only', - }), - /redirect limit was exceeded/, - ); - assert.equal(redirect.close.mock.calls.length, 1); -}); - -test('rejects HTTPS redirects that downgrade to HTTP', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - const redirect = response(302, { location: 'http://example.test/next' }); - mocks.requestApprovedUrl.mockResolvedValue(redirect); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'https://example.test/start', - headers: {}, - networkAccess: 'public-only', - }), - /redirect downgraded HTTPS/, - ); - assert.equal(mocks.requestApprovedUrl.mock.calls.length, 1); -}); - -test('limits cross-origin redirect headers and preserves POST bodies for 307', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - mocks.requestApprovedUrl - .mockResolvedValueOnce(response(307, { location: ['https://other.test/next'] })) - .mockResolvedValueOnce( - response(200, { 'set-cookie': ['a=1', 'b=2'], 'x-empty': undefined }, 'ok'), - ); - - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: 'https://example.test/start', - headers: { - authorization: 'secret', - accept: 'application/json', - 'user-agent': 'agent-device-test', - }, - body: '{}', - networkAccess: 'public-only', - }); - - assert.deepEqual(result, { - status: 200, - body: 'ok', - headers: { 'set-cookie': 'a=1, b=2', 'x-empty': '' }, - }); - const redirectedRequest = mocks.requestApprovedUrl.mock.calls[1]?.[0]; - assert.equal(redirectedRequest?.url.href, 'https://other.test/next'); - assert.deepEqual(redirectedRequest?.headers, { - accept: 'application/json', - 'user-agent': 'agent-device-test', +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.equal(redirectedRequest?.approvedAddress, '93.184.216.34'); - assert.equal(redirectedRequest?.family, 4); - assert.equal(redirectedRequest?.method, 'POST'); - assert.equal(redirectedRequest?.body, '{}'); -}); - -test('stops after the redirect limit', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - mocks.requestApprovedUrl.mockImplementation(async () => - response(302, { location: 'https://example.test/next' }), - ); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'https://example.test/start', - headers: {}, - networkAccess: 'public-only', - }), - /redirect limit was exceeded/, - ); - assert.equal(mocks.approvePublicNetworkUrl.mock.calls.length, 6); - assert.equal(mocks.requestApprovedUrl.mock.calls.length, 6); -}); - -test('caps final public response bodies while consuming them incrementally', async () => { - mocks.approvePublicNetworkUrl.mockResolvedValue({ address: '93.184.216.34', family: 4 }); - let yieldedChunks = 0; - const body = Readable.from( - (function* () { - yieldedChunks += 1; - yield Buffer.alloc(MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES / 2, 0x61); - yieldedChunks += 1; - yield Buffer.alloc(MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES / 2, 0x62); - yieldedChunks += 1; - yield Buffer.from('overflow'); - })(), - ); - mocks.requestApprovedUrl.mockResolvedValue(response(200, {}, body)); - - await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'https://example.test/large', - headers: {}, - networkAccess: 'public-only', - }), - new RegExp(`response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`), - ); - assert.equal(yieldedChunks, 3); -}); - -function response( - statusCode: number, - headers: Record = {}, - body: string | NodeJS.ReadableStream = '', -): { - statusCode: number; - headers: Record; - body: NodeJS.ReadableStream; - close: ReturnType; -} { - return { - statusCode, - headers, - body: typeof body === 'string' ? Readable.from([body]) : body, - close: vi.fn(async () => {}), - }; -} - -const childModulePath = fileURLToPath(new URL('../run-script-http-child.ts', import.meta.url)); - -test('reports malformed JSON received by the HTTP child', () => { - const result = runHttpChildRaw('{'); assert.notEqual(result.exitCode, 0); - assert.match(result.stderr, /SyntaxError|Unexpected end/); + assert.match(result.stderr, /SyntaxError/); }); - -for (const [name, input, errorMessage] of [ - ['null input', null, 'invalid Maestro runScript HTTP input'], - ['array input', [], 'invalid Maestro runScript HTTP input'], - [ - 'unsupported method', - { method: 'PUT', url: 'https://example.test', headers: {}, networkAccess: 'public-only' }, - 'invalid Maestro runScript HTTP method', - ], - [ - 'non-string URL', - { method: 'GET', url: 42, headers: {}, networkAccess: 'public-only' }, - 'invalid Maestro runScript HTTP input', - ], - [ - 'missing headers', - { method: 'GET', url: 'https://example.test', networkAccess: 'public-only' }, - 'invalid Maestro runScript HTTP headers', - ], - [ - 'array headers', - { method: 'GET', url: 'https://example.test', headers: [], networkAccess: 'public-only' }, - 'invalid Maestro runScript HTTP headers', - ], - [ - 'non-string header value', - { - method: 'GET', - url: 'https://example.test', - headers: { authorization: 42 }, - networkAccess: 'public-only', - }, - 'invalid Maestro runScript HTTP headers', - ], - [ - 'non-string body', - { - method: 'GET', - url: 'https://example.test', - headers: {}, - body: 42, - networkAccess: 'public-only', - }, - 'invalid Maestro runScript HTTP body', - ], - [ - 'unsupported network policy', - { method: 'GET', url: 'https://example.test', headers: {} }, - 'invalid Maestro runScript HTTP network policy', - ], -] as const) { - test(`rejects ${name} from the HTTP child`, () => { - assert.throws(() => parseRunScriptHttpRequest(input), new RegExp(errorMessage)); - }); -} - -function runHttpChildRaw(stdin: string) { - return runCmdSync(process.execPath, ['--experimental-strip-types', childModulePath], { - stdin, - allowFailure: true, - }); -} diff --git a/src/daemon/adapters/maestro/__tests__/run-script-http.test.ts b/src/daemon/adapters/maestro/__tests__/run-script-http.test.ts new file mode 100644 index 0000000000..6bdbd8efcf --- /dev/null +++ b/src/daemon/adapters/maestro/__tests__/run-script-http.test.ts @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { gzipSync } from 'node:zlib'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import type { Dispatcher } from 'undici'; +import { AppError } from '@agent-device/kernel/errors'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../../../__tests__/test-utils/loopback.ts'; + +const approveDownloadSourceUrl = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/provision-kit/install-source-network', () => ({ + approveDownloadSourceUrl, +})); + +import { + executeRunScriptHttpRequest, + MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, + PublicFetchDispatcher, +} from '../run-script-http.ts'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +test('keeps local requests on the existing Fetch path', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('created', { + status: 201, + headers: { 'x-result': 'ok' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: 'http://127.0.0.1:8080/local', + headers: { authorization: 'secret' }, + body: '{}', + publicNetworkOnly: false, + }); + + assert.deepEqual(result, { + status: 201, + body: 'created', + headers: { 'content-type': 'text/plain;charset=UTF-8', 'x-result': 'ok' }, + }); + assert.deepEqual(fetchMock.mock.calls[0], [ + 'http://127.0.0.1:8080/local', + { method: 'POST', headers: { authorization: 'secret' }, body: '{}' }, + ]); +}); + +test('preserves Fetch decompression for public requests', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const server = http.createServer((_req, res) => { + const body = gzipSync('decoded'); + res.writeHead(200, { + 'content-encoding': 'gzip', + 'content-length': String(body.byteLength), + }); + res.end(body); + }); + try { + const port = await listenOnLoopback(server); + approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: `http://public.test:${port}/data`, + headers: {}, + body: '{}', + publicNetworkOnly: true, + }); + + assert.equal(result.body, 'decoded'); + } finally { + await closeLoopbackServer(server); + } +}); + +test('uses Fetch cross-origin 307 header and body semantics', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let redirectedHeaders: http.IncomingHttpHeaders | undefined; + let redirectedBody = ''; + const server = http.createServer((req, res) => { + if (req.url === '/start') { + const port = (server.address() as { port: number }).port; + res.writeHead(307, { location: `http://other.test:${port}/next` }); + res.end(); + return; + } + redirectedHeaders = req.headers; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + redirectedBody += chunk; + }); + req.on('end', () => res.end('ok')); + }); + try { + const port = await listenOnLoopback(server); + approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); + + const result = await executeRunScriptHttpRequest({ + method: 'POST', + url: `http://public.test:${port}/start`, + headers: { + authorization: 'secret', + 'content-type': 'application/json', + }, + body: '{}', + publicNetworkOnly: true, + }); + + assert.equal(result.body, 'ok'); + assert.equal(redirectedHeaders?.authorization, undefined); + assert.equal(redirectedHeaders?.['content-type'], 'application/json'); + assert.equal(redirectedBody, '{}'); + assert.equal(approveDownloadSourceUrl.mock.calls.length, 2); + } finally { + await closeLoopbackServer(server); + } +}); + +test('reapproves a redirect before its connection', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let requests = 0; + const server = http.createServer((_req, res) => { + requests += 1; + const port = (server.address() as { port: number }).port; + res.writeHead(302, { location: `http://private.test:${port}/secret` }); + res.end(); + }); + try { + const port = await listenOnLoopback(server); + approveDownloadSourceUrl + .mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }) + .mockRejectedValueOnce(new AppError('INVALID_ARGS', 'non-public address')); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: `http://public.test:${port}/start`, + headers: {}, + publicNetworkOnly: true, + }), + /non-public address/, + ); + assert.equal(requests, 1); + } finally { + await closeLoopbackServer(server); + } +}); + +test('validates an IP literal before connecting', async () => { + approveDownloadSourceUrl.mockRejectedValue(new AppError('INVALID_ARGS', 'non-public address')); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: 'http://169.254.169.254/latest/meta-data', + headers: {}, + publicNetworkOnly: true, + }), + /non-public address/, + ); + assert.equal(approveDownloadSourceUrl.mock.calls.length, 1); +}); + +test('preserves configured proxy routing while pinning the approved destination', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let connectTarget = ''; + let targetHost = ''; + const target = http.createServer((req, res) => { + targetHost = req.headers.host ?? ''; + res.end('proxied'); + }); + const proxy = http.createServer(); + proxy.on('connect', (req, client, head) => { + connectTarget = req.url ?? ''; + const [host, rawPort] = connectTarget.split(':'); + const upstream = net.connect(Number(rawPort), host, () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.byteLength > 0) upstream.write(head); + client.pipe(upstream); + upstream.pipe(client); + }); + }); + try { + const targetPort = await listenOnLoopback(target); + const proxyPort = await listenOnLoopback(proxy); + vi.stubEnv('http_proxy', `http://127.0.0.1:${proxyPort}`); + vi.stubEnv('HTTP_PROXY', `http://127.0.0.1:${proxyPort}`); + vi.stubEnv('no_proxy', ''); + vi.stubEnv('NO_PROXY', ''); + approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); + + const result = await executeRunScriptHttpRequest({ + method: 'GET', + url: `http://public.test:${targetPort}/data`, + headers: {}, + publicNetworkOnly: true, + }); + + assert.equal(result.body, 'proxied'); + assert.equal(connectTarget, `127.0.0.1:${targetPort}`); + assert.equal(targetHost, `public.test:${targetPort}`); + } finally { + await closeLoopbackServer(proxy); + await closeLoopbackServer(target); + } +}); + +test('rejects an HTTPS downgrade before approval or dispatch', async () => { + const dispatcher = new PublicFetchDispatcher(true, AbortSignal.timeout(1_000)); + const error = await new Promise((resolve) => { + dispatcher.dispatch({ origin: 'http://example.test', path: '/redirected', method: 'GET' }, { + onError: resolve, + } as Dispatcher.DispatchHandler); + }); + + assert.match(error.message, /redirect downgraded HTTPS/); + assert.equal(approveDownloadSourceUrl.mock.calls.length, 0); + await dispatcher.dispose(); +}); + +test('caps the decoded public response body', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const server = http.createServer((_req, res) => { + res.end(Buffer.alloc(MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES + 1, 0x61)); + }); + try { + const port = await listenOnLoopback(server); + approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); + + await assert.rejects( + executeRunScriptHttpRequest({ + method: 'GET', + url: `http://public.test:${port}/large`, + headers: {}, + publicNetworkOnly: true, + }), + new RegExp(`response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`), + ); + } finally { + await closeLoopbackServer(server); + } +}); diff --git a/src/daemon/adapters/maestro/__tests__/run-script.test.ts b/src/daemon/adapters/maestro/__tests__/run-script.test.ts index 7906506d70..f48692b41f 100644 --- a/src/daemon/adapters/maestro/__tests__/run-script.test.ts +++ b/src/daemon/adapters/maestro/__tests__/run-script.test.ts @@ -19,7 +19,9 @@ output.object = { ready: true } ); try { - expect(executeRunScriptFile({ scriptPath, env: { SERVER_PATH: 'local' } })).toEqual({ + expect( + executeRunScriptFile({ scriptPath, env: { SERVER_PATH: 'local' }, publicNetworkOnly: false }), + ).toEqual({ 'output.text': 'local', 'output.number': '42', 'output.boolean': 'false', @@ -36,9 +38,11 @@ test('executeRunScriptFile rejects output keys that cannot become replay variabl fs.writeFileSync(scriptPath, `output['nested.value'] = 'ambiguous'`); try { - expect(() => executeRunScriptFile({ scriptPath, env: {} })).toThrowError(AppError); + expect(() => + executeRunScriptFile({ scriptPath, env: {}, publicNetworkOnly: false }), + ).toThrowError(AppError); try { - executeRunScriptFile({ scriptPath, env: {} }); + executeRunScriptFile({ scriptPath, env: {}, publicNetworkOnly: false }); } catch (error) { expect(error).toBeInstanceOf(AppError); expect((error as AppError).code).toBe('INVALID_ARGS'); @@ -56,7 +60,7 @@ test('executeRunScriptFile keeps recovery guidance separate from its bounded err fs.writeFileSync(scriptPath, `output.result = json('').value`); try { - executeRunScriptFile({ scriptPath, env: {} }); + executeRunScriptFile({ scriptPath, env: {}, publicNetworkOnly: false }); throw new Error('expected runScript to fail'); } catch (error) { expect(error).toBeInstanceOf(AppError); @@ -88,7 +92,7 @@ output.result = [ ); try { - expect(executeRunScriptFile({ scriptPath, env: {} })).toEqual({ + expect(executeRunScriptFile({ scriptPath, env: {}, publicNetworkOnly: false })).toEqual({ 'output.result': 'false:false:2', }); } finally { @@ -99,14 +103,14 @@ output.result = [ test('executeRunScriptFile blocks non-public HTTP destinations for remote requests', () => { const root = mkdtempForTestSync('agent-device-maestro-run-script-'); const scriptPath = path.join(root, 'setup.js'); - fs.writeFileSync(scriptPath, `output.result = http.post('http://127.0.0.1:1')`); + fs.writeFileSync(scriptPath, `output.result = http.post('http://127.0.0.1:8080')`); try { expect(() => executeRunScriptFile({ scriptPath, env: {}, - networkAccess: 'public-only', + publicNetworkOnly: true, }), ).toThrow(/non-public address/); } finally { diff --git a/src/daemon/adapters/maestro/daemon-runtime-port.ts b/src/daemon/adapters/maestro/daemon-runtime-port.ts index 9c0a7b463c..65ed92cccf 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-port.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port.ts @@ -267,7 +267,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper runScript: async (input, context) => ({ outputEnv: executeRunScriptFile({ scriptPath: resolveScriptPath(input.file, context, options.sourcePath), - networkAccess: options.baseReq.internal?.networkAccess, + publicNetworkOnly: options.baseReq.internal?.publicNetworkOnly === true, env: { ...context.env, ...(input.env ? stringifyEnvironment(input.env) : {}), diff --git a/src/daemon/adapters/maestro/run-script-execution.ts b/src/daemon/adapters/maestro/run-script-execution.ts index 907f94e920..353e963db4 100644 --- a/src/daemon/adapters/maestro/run-script-execution.ts +++ b/src/daemon/adapters/maestro/run-script-execution.ts @@ -5,8 +5,7 @@ import vm from 'node:vm'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { runCmdSync } from '@agent-device/host-kit/command'; import { stripUndefined } from '@agent-device/kernel/record'; -import type { DaemonNetworkAccessPolicy } from '../../types.ts'; -import type { RunScriptHttpRequest, RunScriptHttpResponse } from './run-script-http-child.ts'; +import type { RunScriptHttpRequest, RunScriptHttpResponse } from './run-script-http.ts'; const RUN_SCRIPT_TIMEOUT_MS = 30_000; const RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS = 1_000; @@ -19,16 +18,16 @@ const RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS = 1_000; export function executeRunScriptFile(params: { scriptPath: string; env: Record; - networkAccess?: DaemonNetworkAccessPolicy; + publicNetworkOnly: boolean; }): Record { - const { scriptPath, env, networkAccess = 'unrestricted' } = params; + const { scriptPath, env, publicNetworkOnly } = params; const script = fs.readFileSync(scriptPath, 'utf8'); const output: Record = Object.create(null) as Record; try { // The synchronous script budget is independent from the child-process // budget used by http.post below. - vm.runInNewContext(script, buildScriptGlobals(env, output, networkAccess), { + vm.runInNewContext(script, buildScriptGlobals(env, output, publicNetworkOnly), { filename: scriptPath, timeout: RUN_SCRIPT_TIMEOUT_MS, }); @@ -62,7 +61,7 @@ export function executeRunScriptFile(params: { function buildScriptGlobals( env: Record, output: Record, - networkAccess: DaemonNetworkAccessPolicy, + publicNetworkOnly: boolean, ): vm.Context { return { ...env, @@ -70,7 +69,7 @@ function buildScriptGlobals( json: parseRunScriptJson, http: { post: (url: string, options?: { headers?: Record; body?: string }) => - runHttpRequestSync('POST', url, options, networkAccess), + runHttpRequestSync('POST', url, publicNetworkOnly, options), }, }; } @@ -106,11 +105,11 @@ function safeRunScriptJsonReviver(key: string, value: unknown): unknown { function runHttpRequestSync( method: RunScriptHttpRequest['method'], url: string, + publicNetworkOnly: boolean, options?: { headers?: Record; body?: string }, - networkAccess: DaemonNetworkAccessPolicy = 'unrestricted', ): RunScriptHttpResponse { const result = runCmdSync(process.execPath, resolveHttpChildArgs(), { - stdin: JSON.stringify(buildHttpChildInput(method, url, options, networkAccess)), + stdin: JSON.stringify(buildHttpChildInput(method, url, options, publicNetworkOnly)), timeoutMs: RUN_SCRIPT_TIMEOUT_MS, allowFailure: true, }); @@ -168,14 +167,14 @@ function buildHttpChildInput( method: RunScriptHttpRequest['method'], url: string, options: { headers?: Record; body?: string } | undefined, - networkAccess: DaemonNetworkAccessPolicy, + publicNetworkOnly: boolean, ): RunScriptHttpRequest { return { method, url, headers: options?.headers ?? {}, body: options?.body ?? '', - networkAccess, + publicNetworkOnly, }; } diff --git a/src/daemon/adapters/maestro/run-script-http-child.ts b/src/daemon/adapters/maestro/run-script-http-child.ts index e44e869d98..5a91ff25c3 100644 --- a/src/daemon/adapters/maestro/run-script-http-child.ts +++ b/src/daemon/adapters/maestro/run-script-http-child.ts @@ -1,258 +1,17 @@ -import { AppError } from '@agent-device/kernel/errors'; import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; -import type { DaemonNetworkAccessPolicy } from '../../types.ts'; -import { - requestApprovedUrl, - type InstallSourceNetworkResponse, -} from '@agent-device/provision-kit/install-source-network-transport'; -import { approvePublicNetworkUrl } from '@agent-device/provision-kit/install-source-network'; +import type { RunScriptHttpRequest } from './run-script-http.ts'; +import { executeRunScriptHttpRequest } from './run-script-http.ts'; -const MAX_REDIRECTS = 5; -export const MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES = 8 * 1024 * 1024; -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); -const REDIRECT_TO_GET_STATUSES = new Set([301, 302, 303]); -const CROSS_ORIGIN_HEADERS = new Set(['accept', 'user-agent']); - -export type RunScriptHttpRequest = { - method: 'GET' | 'POST'; - url: string; - headers: Record; - body?: string; - networkAccess: DaemonNetworkAccessPolicy; -}; - -export type RunScriptHttpResponse = { - status: number; - body: string; - headers: Record; -}; - -type PublicRequestState = { - url: URL; - headers: Record; - method: 'GET' | 'POST'; - body?: string; -}; - -export async function executeRunScriptHttpRequest( - input: RunScriptHttpRequest, -): Promise { - if (input.networkAccess === 'public-only') return await executePublicRequest(input); - return await executeUnrestrictedRequest(input); -} - -async function executeUnrestrictedRequest( - input: RunScriptHttpRequest, -): Promise { - if (typeof fetch !== 'function') { - throw new Error('global fetch is required for Maestro runScript http helpers'); - } - const response = await fetch(input.url, { - method: input.method, - headers: input.headers, - ...(input.body !== undefined ? { body: input.body } : {}), - }); - return { - status: response.status, - body: await response.text(), - headers: Object.fromEntries(response.headers.entries()), - }; -} - -async function executePublicRequest(input: RunScriptHttpRequest): Promise { - let request: PublicRequestState; - try { - request = { - url: new URL(input.url), - headers: { ...input.headers }, - method: input.method, - ...(input.body !== undefined ? { body: input.body } : {}), - }; - } catch { - throw new AppError('INVALID_ARGS', 'Invalid Maestro runScript HTTP URL'); - } - const signal = AbortSignal.timeout(30_000); - - for (let redirectCount = 0; ; redirectCount += 1) { - const approved = await approvePublicNetworkUrl(request.url, { - signal, - label: 'Maestro runScript URL', - hint: 'Use a public URL.', - }); - const response = await requestApprovedUrl({ - url: request.url, - approvedAddress: approved.address, - family: approved.family, - headers: request.headers, - signal, - method: request.method, - ...(request.body !== undefined ? { body: request.body } : {}), - }); - try { - if (!REDIRECT_STATUSES.has(response.statusCode)) { - return { - status: response.statusCode, - body: await readResponseBody(response), - headers: responseHeadersToRecord(response.headers), - }; - } - response.body.resume?.(); - request = createRedirectRequest(response, request, redirectCount); - } finally { - await response.close(); - } - } -} - -function createRedirectRequest( - response: InstallSourceNetworkResponse, - request: PublicRequestState, - redirectCount: number, -): PublicRequestState { - const location = readHeader(response.headers, 'location'); - if (!location || redirectCount >= MAX_REDIRECTS) { - throw new AppError('COMMAND_FAILED', 'Maestro runScript HTTP redirect limit was exceeded', { - status: response.statusCode, - }); - } - const redirected = new URL(location, request.url); - if (request.url.protocol === 'https:' && redirected.protocol !== 'https:') { - throw new AppError('COMMAND_FAILED', 'Maestro runScript HTTP redirect downgraded HTTPS'); - } - return { - url: redirected, - headers: - redirected.origin === request.url.origin - ? request.headers - : crossOriginHeaders(request.headers), - ...redirectMethod(response.statusCode, request.method, request.body), - }; -} - -function redirectMethod( - statusCode: number, - method: 'GET' | 'POST', - body: string | undefined, -): { method: 'GET' | 'POST'; body?: string } { - if (method === 'GET' || !REDIRECT_TO_GET_STATUSES.has(statusCode)) return { method, body }; - return { method: 'GET' }; -} - -async function readResponseBody(response: InstallSourceNetworkResponse): Promise { - const chunks: Buffer[] = []; - let bytesSeen = 0; - for await (const chunk of response.body as AsyncIterable) { - const buffer = Buffer.from(chunk); - const nextBytesSeen = bytesSeen + buffer.byteLength; - if (nextBytesSeen > MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES) { - throw new AppError( - 'COMMAND_FAILED', - `Maestro runScript HTTP response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`, - { limitBytes: MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, status: response.statusCode }, - ); - } - bytesSeen = nextBytesSeen; - chunks.push(buffer); - } - return Buffer.concat(chunks, bytesSeen).toString('utf8'); -} - -function responseHeadersToRecord( - headers: InstallSourceNetworkResponse['headers'], -): Record { - return Object.fromEntries( - Object.entries(headers).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(', ') : (value ?? ''), - ]), +export async function runScriptHttpChild(): Promise { + const response = await executeRunScriptHttpRequest( + JSON.parse(readFileSync(0, 'utf8')) as RunScriptHttpRequest, ); -} - -function readHeader( - headers: InstallSourceNetworkResponse['headers'], - name: string, -): string | undefined { - const lowerName = name.toLowerCase(); - const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1]; - return Array.isArray(entry) ? entry[0] : entry; -} - -function crossOriginHeaders(headers: Record): Record { - return Object.fromEntries( - Object.entries(headers).filter(([name]) => CROSS_ORIGIN_HEADERS.has(name.toLowerCase())), - ); -} - -function readInput(): unknown { - return JSON.parse(readFileSync(0, 'utf8')) as unknown; -} - -export function parseRunScriptHttpRequest(value: unknown): RunScriptHttpRequest { - const record = parseInputRecord(value); - return { - method: parseMethod(record.method), - url: parseUrl(record.url), - headers: parseHeaders(record.headers), - ...parseBody(record.body), - networkAccess: parseNetworkAccess(record.networkAccess), - }; -} - -function parseInputRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('invalid Maestro runScript HTTP input'); - } - return value as Record; -} - -function parseMethod(value: unknown): 'GET' | 'POST' { - if (value !== 'GET' && value !== 'POST') { - throw new Error('invalid Maestro runScript HTTP method'); - } - return value; -} - -function parseUrl(value: unknown): string { - if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP input'); - return value; -} - -function parseHeaders(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('invalid Maestro runScript HTTP headers'); - } - const headers: Record = {}; - for (const [key, headerValue] of Object.entries(value as Record)) { - if (typeof headerValue !== 'string') throw new Error('invalid Maestro runScript HTTP headers'); - headers[key] = headerValue; - } - return headers; -} - -function parseBody(value: unknown): { body?: string } { - if (value === undefined) return {}; - if (typeof value !== 'string') throw new Error('invalid Maestro runScript HTTP body'); - return { body: value }; -} - -function parseNetworkAccess(value: unknown): DaemonNetworkAccessPolicy { - if (value !== 'unrestricted' && value !== 'public-only') { - throw new Error('invalid Maestro runScript HTTP network policy'); - } - return value; -} - -async function runChild(): Promise { - const response = await executeRunScriptHttpRequest(parseRunScriptHttpRequest(readInput())); process.stdout.write(JSON.stringify(response)); } -const isDirectRun = process.argv[1] - ? pathToFileURL(process.argv[1]).href === import.meta.url - : false; -if (isDirectRun) { - void runChild().catch((error: unknown) => { +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + void runScriptHttpChild().catch((error: unknown) => { console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); process.exitCode = 1; }); diff --git a/src/daemon/adapters/maestro/run-script-http.ts b/src/daemon/adapters/maestro/run-script-http.ts new file mode 100644 index 0000000000..ccf9af1807 --- /dev/null +++ b/src/daemon/adapters/maestro/run-script-http.ts @@ -0,0 +1,204 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { resolveProxyForUrl } from '@agent-device/provision-kit/install-source-network-transport'; +import net from 'node:net'; +import { Agent, Dispatcher, ProxyAgent, fetch as undiciFetch } from 'undici'; +import { approveDownloadSourceUrl } from '@agent-device/provision-kit/install-source-network'; + +export const MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES = 8 * 1024 * 1024; + +export type RunScriptHttpRequest = { + method: 'GET' | 'POST'; + url: string; + headers: Record; + body?: string; + publicNetworkOnly: boolean; +}; + +export type RunScriptHttpResponse = { + status: number; + body: string; + headers: Record; +}; + +export async function executeRunScriptHttpRequest( + input: RunScriptHttpRequest, +): Promise { + if (!input.publicNetworkOnly) return await executeLocalRequest(input); + return await executePublicRequest(input); +} + +async function executeLocalRequest(input: RunScriptHttpRequest): Promise { + if (typeof fetch !== 'function') { + throw new Error('global fetch is required for Maestro runScript http helpers'); + } + const response = await fetch(input.url, fetchInit(input)); + return await serializeResponse(response); +} + +async function executePublicRequest(input: RunScriptHttpRequest): Promise { + const initialUrl = parsePublicUrl(input.url); + const signal = AbortSignal.timeout(30_000); + const dispatcher = new PublicFetchDispatcher(initialUrl.protocol === 'https:', signal); + try { + const response = await undiciFetch(initialUrl, { + ...fetchInit(input), + dispatcher, + signal, + }); + return await serializeResponse( + response as unknown as Response, + MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES, + ); + } catch (error) { + if (error instanceof TypeError && error.cause instanceof AppError) throw error.cause; + throw error; + } finally { + await dispatcher.dispose(); + } +} + +function fetchInit(input: RunScriptHttpRequest) { + return { + method: input.method, + headers: input.headers, + ...(input.body !== undefined ? { body: input.body } : {}), + }; +} + +export class PublicFetchDispatcher extends Dispatcher { + readonly #dispatchers = new Set(); + #httpsRequired: boolean; + readonly #signal: AbortSignal; + + constructor(httpsRequired: boolean, signal: AbortSignal) { + super(); + this.#httpsRequired = httpsRequired; + this.#signal = signal; + } + + override dispatch( + options: Dispatcher.DispatchOptions, + handler: Dispatcher.DispatchHandler, + ): boolean { + void this.dispatchApproved(options, handler).catch((error: unknown) => { + handler.onError?.(error instanceof Error ? error : new Error(String(error))); + }); + return true; + } + + async dispose(): Promise { + await Promise.all([...this.#dispatchers].map(async (dispatcher) => await dispatcher.close())); + } + + private async dispatchApproved( + options: Dispatcher.DispatchOptions, + handler: Dispatcher.DispatchHandler, + ): Promise { + const url = dispatchUrl(options); + if (this.#httpsRequired && url.protocol !== 'https:') { + throw new AppError('COMMAND_FAILED', 'Maestro runScript HTTP redirect downgraded HTTPS'); + } + if (url.protocol === 'https:') this.#httpsRequired = true; + const approved = await approveDownloadSourceUrl(url, this.#signal); + const proxyUrl = resolveProxyForUrl(url); + const dispatcher = proxyUrl + ? proxyDispatcher(proxyUrl, url) + : directDispatcher(approved.address, approved.family); + this.#dispatchers.add(dispatcher); + dispatcher.dispatch( + proxyUrl ? proxyDispatchOptions(options, url, approved.address, approved.family) : options, + handler, + ); + } +} + +function directDispatcher(address: string, family: 4 | 6): Agent { + return new Agent({ + connect: { + lookup: (_hostname, options, callback) => { + if (options.all) { + callback(null, [{ address, family }]); + return; + } + callback(null, address, family); + }, + }, + }); +} + +function proxyDispatcher(proxyUrl: string, destination: URL): ProxyAgent { + const hostname = stripAddressBrackets(destination.hostname); + return new ProxyAgent({ + uri: proxyUrl, + proxyTunnel: true, + requestTls: + destination.protocol === 'https:' && net.isIP(hostname) === 0 + ? { servername: hostname } + : undefined, + }); +} + +function proxyDispatchOptions( + options: Dispatcher.DispatchOptions, + originalUrl: URL, + address: string, + family: 4 | 6, +): Dispatcher.DispatchOptions { + const approved = new URL(originalUrl); + approved.hostname = family === 6 ? `[${stripAddressBrackets(address)}]` : address; + const headers = Array.isArray(options.headers) + ? [...options.headers, 'host', originalUrl.host] + : { ...options.headers, host: originalUrl.host }; + return { ...options, origin: approved.origin, headers }; +} + +function stripAddressBrackets(value: string): string { + return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; +} + +function dispatchUrl(options: Dispatcher.DispatchOptions): URL { + if (!options.origin) throw new AppError('INVALID_ARGS', 'Invalid Maestro runScript HTTP URL'); + return new URL(options.path, options.origin); +} + +function parsePublicUrl(value: string): URL { + try { + return new URL(value); + } catch { + throw new AppError('INVALID_ARGS', 'Invalid Maestro runScript HTTP URL'); + } +} + +async function serializeResponse( + response: Response, + maxBodyBytes?: number, +): Promise { + return { + status: response.status, + body: await readResponseBody(response, maxBodyBytes), + headers: Object.fromEntries(response.headers.entries()), + }; +} + +async function readResponseBody(response: Response, maxBodyBytes?: number): Promise { + if (maxBodyBytes === undefined) return await response.text(); + const chunks: Uint8Array[] = []; + let bytesSeen = 0; + const reader = response.body?.getReader(); + if (!reader) return ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytesSeen += value.byteLength; + if (bytesSeen > maxBodyBytes) { + await reader.cancel(); + throw new AppError( + 'COMMAND_FAILED', + `Maestro runScript HTTP response exceeded ${maxBodyBytes} bytes`, + { limitBytes: maxBodyBytes, status: response.status }, + ); + } + chunks.push(value); + } + return Buffer.concat(chunks, bytesSeen).toString('utf8'); +} diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 1e31a02eeb..52b3317c44 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -33,13 +33,16 @@ import { shouldStreamRequestProgress, } from '../request-progress-protocol.ts'; import { buildDaemonHealthPayload } from '../http-health.ts'; -import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts'; +import { + DAEMON_HTTP_NETWORK_ACCESS_HEADER, + DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + DAEMON_HTTP_TENANT_HEADER, +} from '../http-contract.ts'; import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts'; import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; -import { applyHttpTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -79,6 +82,8 @@ type HttpAuthDecision = | { ok: true; tenantId?: string } | { ok: false; statusCode: number; response: JsonRpcResponse }; +type HttpInstallSource = Exclude; + const MAX_HTTP_RPC_BODY_BYTES = 1024 * 1024; const COMMAND_RPC_METHODS = new Set(['agent_device.command', 'agent-device.command']); const INSTALL_FROM_SOURCE_RPC_METHODS = new Set([ @@ -100,6 +105,35 @@ const LEASE_RPC_METHOD_TO_COMMAND: Record< 'agent_device.lease.release': 'lease_release', 'agent-device.lease.release': 'lease_release', }; + +function restrictRemoteHttpRequest( + request: DaemonRequest, + authHookConfigured: boolean, + networkAccessMarker: string | string[] | undefined, +): DaemonRequest { + if ( + networkAccessMarker !== undefined && + networkAccessMarker !== DAEMON_HTTP_PUBLIC_NETWORK_ACCESS + ) { + throw new AppError('INVALID_ARGS', 'Invalid daemon HTTP network access marker'); + } + if (!authHookConfigured && networkAccessMarker === undefined) return request; + const source = request.meta?.installSource; + const uploadedArtifactId = request.meta?.uploadedArtifactId; + if ( + source?.kind === 'path' && + !(typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) + ) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: path install sources are disabled on the remote HTTP surface', + ); + } + return { + ...request, + internal: { ...request.internal, publicNetworkOnly: true }, + }; +} const SUPPORTED_RPC_METHODS = new Set([ ...COMMAND_RPC_METHODS, ...INSTALL_FROM_SOURCE_RPC_METHODS, @@ -222,7 +256,7 @@ function readGitHubArtifactInteger(record: Record, key: 'artifa return parsed; } -function parseGitHubActionsArtifactSource(record: Record): DaemonInstallSource { +function parseGitHubActionsArtifactSource(record: Record): HttpInstallSource { const owner = readRequiredGitHubArtifactText(record, 'owner'); const repo = readRequiredGitHubArtifactText(record, 'repo'); const hasArtifactId = record.artifactId !== undefined; @@ -291,7 +325,7 @@ function toLeaseDaemonRequest( }; } -function parseInstallSource(params: Record): DaemonInstallSource { +function parseInstallSource(params: Record): HttpInstallSource { const source = params.source; if (!source || typeof source !== 'object') { throw new AppError('INVALID_ARGS', 'Invalid params: source is required'); @@ -321,21 +355,18 @@ function parseInstallSource(params: Record): DaemonInstallSourc return Object.keys(headers).length > 0 ? { kind: 'url', url, headers } : { kind: 'url', url }; } if (record.kind === 'path') { - const artifactPath = typeof record.path === 'string' ? record.path.trim() : ''; - if (!artifactPath) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid params: source.path is required for path sources', - ); - } - return { kind: 'path', path: artifactPath }; + throw new AppError( + 'INVALID_ARGS', + 'Invalid params: source.kind "path" names a file on the daemon host and is not accepted over HTTP', + { hint: 'Use a "url" or "github-actions-artifact" source.' }, + ); } if (record.kind === 'github-actions-artifact') { return parseGitHubActionsArtifactSource(record); } throw new AppError( 'INVALID_ARGS', - 'Invalid params: source.kind must be "url", "path", or "github-actions-artifact"', + 'Invalid params: source.kind must be "url" or "github-actions-artifact"', ); } @@ -709,12 +740,10 @@ export async function createDaemonHttpServer(options: { if (daemonRequest.flags?.tenant !== undefined) { daemonRequest.flags = { ...daemonRequest.flags, tenant: tenantTrust.tenantId }; } - daemonRequest = applyHttpTrustPolicy( + daemonRequest = restrictRemoteHttpRequest( daemonRequest, - resolveHttpTrustPolicy({ - authHookConfigured: authHook !== null, - networkAccessMarker: req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER], - }), + authHook !== null, + req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER], ); let canceledInFlight = false; diff --git a/src/daemon/server/http-trust-policy.test.ts b/src/daemon/server/http-trust-policy.test.ts deleted file mode 100644 index a70a586076..0000000000 --- a/src/daemon/server/http-trust-policy.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { DAEMON_HTTP_PUBLIC_NETWORK_ACCESS } from '../http-contract.ts'; -import type { DaemonRequest } from '../types.ts'; -import { applyHttpTrustPolicy, resolveHttpTrustPolicy } from './http-trust-policy.ts'; - -test('an auth-hook HTTP server uses the public-only trust policy', () => { - assert.deepEqual(resolveHttpTrustPolicy({ authHookConfigured: true }), { - networkAccess: 'public-only', - }); -}); - -test('an HTTP server without an auth hook keeps local unrestricted behavior', () => { - assert.deepEqual(resolveHttpTrustPolicy({ authHookConfigured: false }), { - networkAccess: 'unrestricted', - }); -}); - -test('a proxy network marker selects public-only behavior without an auth hook', () => { - assert.deepEqual( - resolveHttpTrustPolicy({ - authHookConfigured: false, - networkAccessMarker: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, - }), - { networkAccess: 'public-only' }, - ); -}); - -test('an invalid or ambiguous proxy network marker fails closed', () => { - for (const networkAccessMarker of ['unrestricted', ['public-only', 'public-only']]) { - assert.throws( - () => resolveHttpTrustPolicy({ authHookConfigured: false, networkAccessMarker }), - /Invalid daemon HTTP network access marker/, - ); - } -}); - -test('the public-only policy rejects every unbacked host path source', () => { - assert.throws( - () => - applyHttpTrustPolicy( - requestWithMeta({ installSource: { kind: 'path', path: '/etc/passwd' } }), - { networkAccess: 'public-only' }, - ), - /path install sources are disabled on the remote HTTP surface/, - ); -}); - -test('the public-only policy preserves daemon-owned uploaded path sources', () => { - const request = requestWithMeta({ - installSource: { kind: 'path', path: '/client/path.apk' }, - uploadedArtifactId: 'artifact-1', - }); - - assert.deepEqual(applyHttpTrustPolicy(request, { networkAccess: 'public-only' }), { - ...request, - internal: { networkAccess: 'public-only' }, - }); -}); - -function requestWithMeta(meta: DaemonRequest['meta']): DaemonRequest { - return { - command: 'install_source', - positionals: [], - token: 'test-token', - session: 'test-session', - meta, - }; -} diff --git a/src/daemon/server/http-trust-policy.ts b/src/daemon/server/http-trust-policy.ts deleted file mode 100644 index 299fd6f8e6..0000000000 --- a/src/daemon/server/http-trust-policy.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import type { DaemonNetworkAccessPolicy, DaemonRequest } from '../types.ts'; -import { DAEMON_HTTP_PUBLIC_NETWORK_ACCESS } from '../http-contract.ts'; - -export type HttpTrustPolicy = { - networkAccess: DaemonNetworkAccessPolicy; -}; - -export function resolveHttpTrustPolicy(params: { - authHookConfigured: boolean; - networkAccessMarker?: string | string[]; -}): HttpTrustPolicy { - if (params.networkAccessMarker !== undefined) { - if (params.networkAccessMarker !== DAEMON_HTTP_PUBLIC_NETWORK_ACCESS) { - throw new AppError('INVALID_ARGS', 'Invalid daemon HTTP network access marker'); - } - return { networkAccess: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS }; - } - return { networkAccess: params.authHookConfigured ? 'public-only' : 'unrestricted' }; -} - -export function applyHttpTrustPolicy( - request: DaemonRequest, - policy: HttpTrustPolicy, -): DaemonRequest { - if (policy.networkAccess !== 'public-only') return request; - const source = request.meta?.installSource; - const uploadedArtifactId = request.meta?.uploadedArtifactId; - if ( - source?.kind === 'path' && - !(typeof uploadedArtifactId === 'string' && uploadedArtifactId.length > 0) - ) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid params: path install sources are disabled on the remote HTTP surface', - ); - } - - return { - ...request, - internal: { - ...request.internal, - networkAccess: policy.networkAccess, - }, - }; -} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 691f41c433..19a433e71e 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -49,10 +49,8 @@ export type DaemonOpenLifecycle = { beforeDispatch?: (session: SessionState) => Promise; }; -export type DaemonNetworkAccessPolicy = 'unrestricted' | 'public-only'; - type DaemonRequestInternal = { - networkAccess?: DaemonNetworkAccessPolicy; + publicNetworkOnly?: true; openLifecycle?: DaemonOpenLifecycle; /** * Request-owned capability used when a fresh replay discovers its device