Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ selection, R11/R13 package enumeration, and the composite typecheck project list
> commands), `process` (observing and owning host processes), `diagnostics`, `retry`
> (deadline/backoff/sleep), `archive` (bounded extraction and byte limits), `file` (atomic
> publishes, locks, path resolution), `request` (request-scoped cancellation and progress),
> `transport` (line framing, lazy HTTP/body mechanics, and constant-time secret comparison),
> and `version` (the installed version off disk). Modules under `src/internal/` are reachable
> only through a port, and a port may only hold mechanics a consumer of that capability
> needs — the eager-closure row per port is what keeps that honest.
Expand Down
4 changes: 4 additions & 0 deletions packages/host-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"types": "./src/retry.ts",
"default": "./src/retry.ts"
},
"./transport": {
"types": "./src/transport.ts",
"default": "./src/transport.ts"
},
"./version": {
"types": "./src/version.ts",
"default": "./src/version.ts"
Expand Down
85 changes: 85 additions & 0 deletions packages/host-kit/src/internal/transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import http from 'node:http';
import type { IncomingMessage } from 'node:http';
import https from 'node:https';
import { Readable } from 'node:stream';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
consumeTextLines,
loadNodeHttpRequester,
readNodeHttpRequestBody,
readNodeHttpResponseBody,
timingSafeStringEqual,
} from '../transport.ts';

test('consumeTextLines trims complete lines and retains a partial line', () => {
const first = consumeTextLines('', ' first\n\nsecond');
assert.deepEqual(first.lines, ['first']);
assert.equal(first.buffer, 'second');

const second = consumeTextLines(first.buffer, '-part\n third \n');
assert.deepEqual(second.lines, ['second-part', 'third']);
assert.equal(second.buffer, '');
});

test('loadNodeHttpRequester selects the protocol module and keeps request mutable', async () => {
const httpRequester = await loadNodeHttpRequester('http:');
const httpsRequester = await loadNodeHttpRequester('https:');
assert.equal(httpRequester, http);
assert.equal(httpsRequester, https);

const originalRequest = httpRequester.request;
const stub = (() => {}) as unknown as typeof originalRequest;
const mutableRequester = httpRequester as { request: typeof originalRequest };
try {
mutableRequester.request = stub;
assert.equal(httpRequester.request, stub);
} finally {
mutableRequester.request = originalRequest;
}
});

test('readNodeHttpResponseBody decodes the complete response stream', async () => {
const response = Readable.from(['hello', Buffer.from(' world')]) as unknown as IncomingMessage;
assert.equal(await readNodeHttpResponseBody(response), 'hello world');
});

test('readNodeHttpRequestBody returns bytes and preserves the caller error message at the limit', async () => {
const request = Readable.from([
Buffer.from('hello '),
Buffer.from('world'),
]) as unknown as IncomingMessage;
assert.deepEqual(
await readNodeHttpRequestBody(request, 11, 'body exceeded'),
Buffer.from('hello world'),
);

const oversized = Readable.from([Buffer.from('hello world!')]) as unknown as IncomingMessage;
await assert.rejects(
readNodeHttpRequestBody(oversized, 11, 'body exceeded'),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.message, 'body exceeded');
return true;
},
);
});

test('timingSafeStringEqual handles equal, unequal, and unequal-length secrets', () => {
assert.equal(timingSafeStringEqual('secret', 'secret'), true);
assert.equal(timingSafeStringEqual('secret', 'secreT'), false);
assert.equal(timingSafeStringEqual('secret', 'secret-longer'), false);
assert.equal(timingSafeStringEqual('', ''), true);
});

test('readNodeHttpResponseBody propagates response errors', async () => {
const response = new EventEmitter() as unknown as IncomingMessage;
response.setEncoding = (_encoding: BufferEncoding) => response;
const promise = readNodeHttpResponseBody(response);
const error = new Error('response failed');
response.emit('error', error);
await assert.rejects(promise, error);
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import crypto from 'node:crypto';
import type { IncomingMessage } from 'node:http';
import { AppError } from '@agent-device/kernel/errors';

export function consumeTextLines(
currentBuffer: string,
chunk: string | Buffer,
): { lines: string[]; buffer: string } {
const lines: string[] = [];
let buffer = currentBuffer + chunk.toString();
let idx = buffer.indexOf('\n');
while (idx !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line) lines.push(line);
idx = buffer.indexOf('\n');
}
return { lines, buffer };
}
/** The slice of `node:http` / `node:https` an outbound request needs. */
export type NodeHttpRequester = Pick<typeof import('node:http'), 'request'>;

Expand Down Expand Up @@ -55,3 +71,14 @@ export async function readNodeHttpRequestBody(
}
return Buffer.concat(chunks);
}

/**
* Compares two secret strings in constant time. Hashing both inputs first
* keeps the comparison length-independent, so unequal-length tokens neither
* throw nor leak length via timing.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
const hashA = crypto.createHash('sha256').update(a).digest();
const hashB = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
8 changes: 8 additions & 0 deletions packages/host-kit/src/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
consumeTextLines,
loadNodeHttpRequester,
readNodeHttpRequestBody,
readNodeHttpResponseBody,
timingSafeStringEqual,
type NodeHttpRequester,
} from './internal/transport.ts';
4 changes: 2 additions & 2 deletions scripts/__tests__/test-file-size-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
'src/commands/interaction/runtime/settle.test.ts': 2359,
'src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts': 1963,
'packages/platform-apple/src/runner/__tests__/runner-session.test.ts': 1957,
'src/utils/__tests__/daemon-client.test.ts': 1873,
'src/daemon/client/__tests__/daemon-client.test.ts': 1873,
'packages/platform-android/src/__tests__/snapshot.test.ts': 1435,
'packages/platform-apple/src/runner/__tests__/runner-client.test.ts': 1441,
'src/__tests__/client.test.ts': 1592,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1556,
'src/utils/__tests__/daemon-client-lifecycle.test.ts': 1413,
'src/daemon/client/__tests__/daemon-client-lifecycle.test.ts': 1409,
'packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts': 1280,
'src/__tests__/cli-client-commands.test.ts': 1304,
'src/__tests__/cli-config.test.ts': 1282,
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/package-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ test('the real tree parses, declares, and passes R11', () => {
'@agent-device/host-kit/process',
'@agent-device/host-kit/request',
'@agent-device/host-kit/retry',
'@agent-device/host-kit/transport',
'@agent-device/host-kit/version',
]);
assert.deepEqual([...hostKitPackage.workspaceDependencies].sort(), [
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
'packages/host-kit/src/process.ts': 12,
'packages/host-kit/src/request.ts': 5,
'packages/host-kit/src/retry.ts': 6,
// #2139 keeps framing, lazy HTTP/body mechanics, and secret comparison behind one
// transport port without growing the CLI's eager closure.
'packages/host-kit/src/transport.ts': 4,
'packages/host-kit/src/version.ts': 4,

// --- @agent-device/provision-kit ---
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/platform-runtime-runtime-hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
clearRuntimeHintValues,
} from '../platform-runtime-runtime-hints.ts';
import { applyDeviceDefaultMetroHost, runtimeHintValues } from '../daemon/session-runtime.ts';
import { resolveRuntimeTransportHints } from '../utils/runtime-transport.ts';
import { resolveRuntimeTransportHints } from '../core/runtime-transport-hints.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import {
defaultPrefsPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { DEFAULT_STABLE_TIMEOUT_MS } from '../../../commands/interaction/runtime
// default are bounded, diffable lists — they may only change in the same PR
// that updates them here. Behavioral derivation (envelope arithmetic, wait
// budget parsing, flag overrides) is proven by the pre-existing oracle tests in
// src/utils/__tests__/daemon-client.test.ts, which survived this migration
// src/daemon/client/__tests__/daemon-client.test.ts, which survived this migration
// unchanged.

function settleObservationCommandNames(): string[] {
Expand Down
65 changes: 65 additions & 0 deletions src/core/runtime-transport-hints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { resolveRuntimeTransportHints, trimRuntimeValue } from './runtime-transport-hints.ts';

test('resolves HTTP and HTTPS bundle URLs with their default ports', () => {
assert.deepEqual(
resolveRuntimeTransportHints({ bundleUrl: 'http://metro.example.test/index.bundle' }),
{ host: 'metro.example.test', port: 80, scheme: 'http' },
);
assert.deepEqual(
resolveRuntimeTransportHints({ bundleUrl: 'https://metro.example.test/index.bundle' }),
{ host: 'metro.example.test', port: 443, scheme: 'https' },
);
});

test('explicit host and port take precedence over bundle URL values', () => {
assert.deepEqual(
resolveRuntimeTransportHints({
metroHost: ' explicit.example.test ',
metroPort: 9090,
bundleUrl: 'https://bundle.example.test:8081/index.bundle',
}),
{ host: 'explicit.example.test', port: 9090, scheme: 'https' },
);
});

test('trims values, ignores invalid ports, and returns undefined for incomplete hints', () => {
assert.equal(trimRuntimeValue(' '), undefined);
assert.equal(trimRuntimeValue(' metro.example.test '), 'metro.example.test');
assert.equal(resolveRuntimeTransportHints(undefined), undefined);
assert.equal(resolveRuntimeTransportHints({ metroHost: 'metro.example.test' }), undefined);
assert.equal(resolveRuntimeTransportHints({ metroPort: 0 }), undefined);
assert.deepEqual(
resolveRuntimeTransportHints({ metroHost: ' metro.example.test ', metroPort: 65_536 }),
undefined,
);
});

test('invalid bundle URLs retain the existing typed argument error', () => {
assert.throws(
() => resolveRuntimeTransportHints({ bundleUrl: 'not a URL' }),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.message, 'Invalid runtime bundle URL: not a URL');
return true;
},
);
});

test('unsupported bundle schemes do not complete an otherwise missing transport', () => {
assert.equal(
resolveRuntimeTransportHints({ bundleUrl: 'ftp://metro.example.test/index.bundle' }),
undefined,
);
assert.deepEqual(
resolveRuntimeTransportHints({
metroHost: 'metro.example.test',
metroPort: 8081,
bundleUrl: 'ftp://bundle.example.test/index.bundle',
}),
{ host: 'metro.example.test', port: 8081, scheme: 'http' },
);
});
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';

vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({
...(await importOriginal<typeof import('@agent-device/host-kit/command')>()),
Expand All @@ -18,20 +18,16 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({
sleep: vi.fn(async () => {}),
}));

import { resolveDaemonPaths, type DaemonPaths } from '../../daemon/config.ts';
import {
sendToDaemon,
type DaemonRequest,
type DaemonResponse,
} from '../../daemon/client/daemon-client.ts';
import { attachActiveSessionAddressHint } from '../../daemon/client/daemon-client-lifecycle.ts';
import { computeDaemonCodeSignature } from '../../daemon/code-signature.ts';
import { sendRequest } from '../../daemon/client/daemon-client-transport.ts';
import { resolveDaemonPaths, type DaemonPaths } from '../../config.ts';
import { sendToDaemon, type DaemonRequest, type DaemonResponse } from '../daemon-client.ts';
import { attachActiveSessionAddressHint } from '../daemon-client-lifecycle.ts';
import { computeDaemonCodeSignature } from '../../code-signature.ts';
import { sendRequest } from '../daemon-client-transport.ts';
import {
closeLoopbackServer,
listenOnLoopback,
supportsLoopbackBind,
} from '../../__tests__/test-utils/loopback.ts';
} from '../../../__tests__/test-utils/loopback.ts';
import { AppError } from '@agent-device/kernel/errors';
import {
runCmdDetachedMonitored,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Production-seam coverage for the real request-timeout route.
//
// src/utils/__tests__/daemon-client.test.ts covers `resolveRequestTimeoutHint`
// src/daemon/client/__tests__/daemon-client.test.ts covers `resolveRequestTimeoutHint`
// as a pure formatter, but a pure-formatter test cannot catch a bug in
// CLEANUP ELIGIBILITY: whether `cleanupTimedOutIosRunnerBuilds` (the Apple
// xcodebuild pkill sweep) actually runs. This file spies on the real
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,32 @@ import {
closeLoopbackServer,
listenOnLoopback,
supportsLoopbackBind,
} from '../../__tests__/test-utils/loopback.ts';
} from '../../../__tests__/test-utils/loopback.ts';
import { runCmdBackground } from '@agent-device/host-kit/command';
import {
isProcessAlive,
readProcessCommand,
readProcessStartTime,
waitForProcessExit,
} from '@agent-device/host-kit/process';
import { sendToDaemon } from '../../daemon/client/daemon-client.ts';
import { computeDaemonCodeSignature } from '../../daemon/code-signature.ts';
import { downloadRemoteArtifact } from '../../remote/daemon-artifacts.ts';
import { sendToDaemon } from '../daemon-client.ts';
import { computeDaemonCodeSignature } from '../../code-signature.ts';
import { downloadRemoteArtifact } from '../../../remote/daemon-artifacts.ts';
import {
cleanupFailedDaemonStartupMetadata,
resolveDaemonStartupHint,
} from '../../daemon/client/daemon-client-metadata.ts';
import { canConnectSocket } from '../../daemon/client/daemon-client-transport.ts';
import { DAEMON_RPC_PROTOCOL_VERSION } from '../../daemon/http-health.ts';
} from '../daemon-client-metadata.ts';
import { canConnectSocket } from '../daemon-client-transport.ts';
import { DAEMON_RPC_PROTOCOL_VERSION } from '../../http-health.ts';
import {
resolveDaemonRequestTimeoutMs,
resolveRequestTimeoutHint,
shouldResetDaemonAfterRequestTimeout,
} from '../../daemon/client/daemon-client-timeout.ts';
import { resolveDaemonPaths } from '../../daemon/config.ts';
import { stopProcessForTakeover } from '../../daemon/daemon-process.ts';
} from '../daemon-client-timeout.ts';
import { resolveDaemonPaths } from '../../config.ts';
import { stopProcessForTakeover } from '../../daemon-process.ts';
import { findProjectRoot, readVersion } from '@agent-device/host-kit/version';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';

// readProcessStartTime/readProcessCommand shell out to `ps` with a 1s
// timeout (see host-process.ts). isAgentDeviceDaemonProcess re-reads both for
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/client/daemon-client-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type http from 'node:http';
import type { Socket } from 'node:net';
import { AppError } from '@agent-device/kernel/errors';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { consumeTextLines } from '../../utils/line-stream.ts';
import { consumeTextLines } from '@agent-device/host-kit/transport';
import { markDoctorProgressRendered } from './doctor-progress.ts';
import {
isDaemonProgressEnvelope,
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/client/daemon-client-transport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { RequestProgressSink } from '@agent-device/contracts/progress';
import net from 'node:net';
import { AppError } from '@agent-device/kernel/errors';
import { loadNodeHttpRequester, readNodeHttpResponseBody } from '../../utils/node-http.ts';
import { loadNodeHttpRequester, readNodeHttpResponseBody } from '@agent-device/host-kit/transport';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { DaemonPaths, DaemonTransportPreference } from '../config.ts';
Expand Down
3 changes: 1 addition & 2 deletions src/daemon/human-control-http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type http from 'node:http';
import { AppError, createRequestCanceledError, normalizeError } from '@agent-device/kernel/errors';
import { readNodeHttpRequestBody } from '../utils/node-http.ts';
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
import { readNodeHttpRequestBody, timingSafeStringEqual } from '@agent-device/host-kit/transport';
import { sendRestJsonError } from './http-errors.ts';
import {
HUMAN_CONTROL_HTTP_PREFIX,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* process-level keep-alive (Fix 1's daemon
* teardown guard) is a different architectural layer — a client-side process
* manager, not session/script state — and is covered separately in
* `src/utils/__tests__/daemon-client-lifecycle.test.ts`
* `src/daemon/client/__tests__/daemon-client-lifecycle.test.ts`
* ("keeps an owned ephemeral daemon alive and hints its --state-dir...").
*
* Fix 1 (session-side): a divergence never deletes the session — it stays in
Expand Down
Loading
Loading