mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
76453add71
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) Extend the workspace with packages/* and move the kernel behind an enforced public API: packages/kernel with nine consumer-earned subpath exports (errors, device, snapshot, contracts, collections, rect, redaction, daemon-error, bounds — the last absorbed from utils as Rect vocabulary). Every kernel import repo-wide becomes the @agent-device/kernel/<sub> specifier; kernel tests move to src/__tests__/kernel/ and exercise the package surface. The root declares the package in devDependencies (workspace:*), tsdown bundles it (noExternal) so the published artifact and its runtime dependency manifest are unchanged. Gate rewiring in the same change, per the W0 brief: - R1 kernel-sink retires (physically subsumed); new R11 package-boundaries guards no-root-back-imports, relative tunnelling past exports maps, undeclared workspace deps, and non-exported subpaths, with runtime resolution pins via import.meta.resolve. - resolveImportEdges and mutation ownership follow workspace specifiers through exports maps, keeping R4 cycle checks, depgraph, and derived test ownership connected across the seam (kernel-errors still owns 495 tests). listSourceFiles includes packages/*/src. - kernel becomes an unranked zone; mutation registry, stryker mutate globs, and the mutation-affected workflow path filter move to packages/kernel/src/errors.ts. - check:affected gains packages/ ownership (manifests fail open); vitest and coverage include packages/*/src; fallow ignores packages/** (its resolver cannot follow workspace specifiers). - The affected-selector CI job installs dependencies: its closure now crosses workspace specifiers, and the R8 relative exception is unsafe for production src files (Node ESM does not realpath, so dual specifier/relative loads would instantiate modules twice). The R8 zero-dep set is pinned empty with that rationale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b Review findings on #1494, all five: 1. contracts-schema-public.test.ts reads the kernel source at its packages/ path (fs access invisible to the codemod and typecheck). 2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules symlink resolves @agent-device/* back to the real repo, so mutants in the sandbox never load and vitest.related finds no tests. vitest.mutation.config.ts now aliases each EXPORTED specifier to its source (derived from exports maps, never a wildcard), keeping resolution inside the mutated tree. Validated: kernel-errors module runs end to end (dry run 3,984 tests, mutants killed, exit 0). 3. Layering/depgraph resolve workspace specifiers through the exports-derived map (workspaceSpecifierTargets) instead of reconstructing paths, so '.'-facade packages resolve; the positional fallback remains only for map-less fixtures (P0 pin). 4. Per-package project references implemented: packages/kernel is composite (emitDeclarationOnly -> dist-types, gitignored), the root references it, and typecheck becomes tsc -b — probed to catch type errors on both sides under TypeScript 7 native. 5. R11's relative-route exception now requires membership in an actual R8 zero-dep job closure (zeroDepClosureFiles walks entries), not mere scripts/ placement — closing the dual-instantiation bypass. Also from review discussion: daemon-error moves out of the kernel package to src/client/ — its consumers (cli, client facade) rehydrate wire DaemonErrors client-side; the daemon only produces them. Kernel drops to 8 exported subpaths before any of them ship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * refactor: one exports-map reader for mutation alias and ownership Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The manifest-reading logic already exists as workspaceSpecifierTargets in scripts/layering/package-boundaries.ts, so both the Stryker sandbox alias table and the mutation ownership walker now consume it instead of carrying near-clones. Behavior unchanged; mutation suite 45/45 and changed-code fallow green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: composite kernel without a root references edge FreeRange runs plain `tsc -p tsconfig.json`, and a root `references` entry makes non-build-mode TypeScript demand the referenced project's built declarations (TS6305) — a standing "build first" tax on every plain -p consumer (fr, editors). Keep the per-package composite project and build it in typecheck (`tsc -b packages/kernel` before the root and examples/sdk passes), but drop the root references edge: root consumption resolves through exports to source, identical to runtime and to the bundler. Probed: plain -p green with no prebuilt output; kernel-side type errors still caught by its own build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep * fix: R11 uses the layering parser; mutation config is a fallow entry Review blockers on #1494: - R11's private single-quote regex could miss a double-quoted or re-export route into packages/*/src. specifierSites now delegates to the layering model's parseImports (both quote styles, side-effect imports, re-exports, dynamic imports), with direct regressions for each formerly-invisible form. - vitest.mutation.config.ts becomes a declared fallow entry instead of a tolerated unused-file finding: the full-repo audit now reports it reachable (unused files 2 -> 1; the remainder predates this PR). FreeRange clean-checkout evidence: with packages/kernel/dist-types and every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings on this head — the TS6305 topology died with the root references edge in the previous commit; check:freerange has no build precondition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep --------- Co-authored-by: Claude <noreply@anthropic.com>
990 lines
31 KiB
TypeScript
990 lines
31 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { test } from 'vitest';
|
|
import { AppError } from '@agent-device/kernel/errors';
|
|
import {
|
|
cleanupUploadedArtifact,
|
|
prepareUploadedArtifact,
|
|
trackDownloadableArtifact,
|
|
} from '../../../src/daemon/artifact-tracking.ts';
|
|
import { DAEMON_RPC_PROTOCOL_VERSION } from '../../../src/daemon/http-health.ts';
|
|
import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts';
|
|
import { emitRequestProgress } from '../../../src/request/progress.ts';
|
|
import { getRequestSignal, isRequestCanceled } from '../../../src/request/cancel.ts';
|
|
import type { DaemonRequest, DaemonResponse } from '../../../src/daemon/types.ts';
|
|
import {
|
|
closeLoopbackServer,
|
|
listenOnLoopback,
|
|
skipWhenLoopbackUnavailable,
|
|
} from '../../../src/__tests__/test-utils/loopback.ts';
|
|
import { restoreEnv } from './harness.ts';
|
|
|
|
type RpcResponse = {
|
|
status: number;
|
|
body: {
|
|
result?: DaemonResponse;
|
|
error?: {
|
|
code: number;
|
|
message: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
};
|
|
};
|
|
|
|
type JsonRpcResponseEnvelope = {
|
|
type: 'response';
|
|
response: {
|
|
jsonrpc: '2.0';
|
|
id: string | number | null;
|
|
result?: DaemonResponse;
|
|
error?: {
|
|
code: number;
|
|
message: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
};
|
|
};
|
|
|
|
const RPC_BODY_CAP_BYTES = 1024 * 1024;
|
|
|
|
test('Provider-backed integration daemon HTTP server maps RPC methods, auth, and request cancellation through the real transport', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const observedRequests: DaemonRequest[] = [];
|
|
let observedCanceled: boolean | undefined;
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (req): Promise<DaemonResponse> => {
|
|
observedRequests.push(req);
|
|
if (req.command === 'session_list') {
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
observedCanceled = isRequestCanceled(req.meta?.requestId);
|
|
}
|
|
if (req.command === 'fail_me') {
|
|
throw new AppError('INVALID_ARGS', 'real transport rejected the request', {
|
|
command: req.command,
|
|
});
|
|
}
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
command: req.command,
|
|
session: req.session,
|
|
meta: req.meta,
|
|
flags: req.flags,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
|
|
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
|
assert.equal(health.status, 200);
|
|
const healthPayload = (await health.json()) as Record<string, unknown>;
|
|
assert.equal(healthPayload.ok, true);
|
|
assert.equal(healthPayload.service, 'agent-device-daemon');
|
|
assert.equal(typeof healthPayload.version, 'string');
|
|
assert.equal(healthPayload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION);
|
|
|
|
const command = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-command',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
session: 'default',
|
|
command: 'session_list',
|
|
positionals: [],
|
|
meta: { requestId: 'req-command' },
|
|
},
|
|
});
|
|
assert.equal(command.status, 200);
|
|
assert.equal(command.body.result?.ok, true);
|
|
assert.equal(observedCanceled, false);
|
|
|
|
const installFromUrl = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-install-url',
|
|
method: 'agent_device.install_from_source',
|
|
params: {
|
|
session: 'bootstrap',
|
|
platform: 'android',
|
|
requestId: 'req-install-url',
|
|
retainPaths: true,
|
|
retentionMs: 30000,
|
|
source: {
|
|
kind: 'url',
|
|
url: 'https://example.com/app.apk',
|
|
headers: { authorization: 'Bearer signed-token' },
|
|
},
|
|
},
|
|
});
|
|
assert.equal(installFromUrl.status, 200);
|
|
|
|
const installFromGitHub = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-install-github',
|
|
method: 'agent_device.install_from_source',
|
|
params: {
|
|
session: 'bootstrap',
|
|
platform: 'ios',
|
|
source: {
|
|
kind: 'github-actions-artifact',
|
|
owner: 'acme',
|
|
repo: 'mobile',
|
|
runId: '1234567890',
|
|
artifactName: 'ios-debug',
|
|
},
|
|
},
|
|
});
|
|
assert.equal(installFromGitHub.status, 200);
|
|
|
|
const lease = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-lease',
|
|
method: 'agent_device.lease.allocate',
|
|
params: {
|
|
tenantId: 'Tenant A',
|
|
runId: 'run-1',
|
|
ttlMs: 60000,
|
|
backend: 'android-instance',
|
|
},
|
|
});
|
|
assert.equal(lease.status, 200);
|
|
|
|
const release = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-release',
|
|
method: 'agent_device.release_materialized_paths',
|
|
params: {
|
|
session: 'bootstrap',
|
|
requestId: 'req-release',
|
|
materializationId: 'materialized-1',
|
|
},
|
|
});
|
|
assert.equal(release.status, 200);
|
|
|
|
const failure = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-failure',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'fail_me',
|
|
positionals: [],
|
|
},
|
|
});
|
|
assert.equal(failure.status, 400);
|
|
assert.equal(failure.body.error?.data?.code, 'INVALID_ARGS');
|
|
assert.equal(
|
|
(failure.body.error?.data?.details as Record<string, unknown> | undefined)?.command,
|
|
'fail_me',
|
|
);
|
|
|
|
const unsupported = await callRpc(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-missing',
|
|
method: 'agent_device.missing',
|
|
params: {},
|
|
});
|
|
assert.equal(unsupported.status, 404);
|
|
assert.equal(unsupported.body.error?.code, -32601);
|
|
|
|
const installUrlRequest = observedRequests.find(
|
|
(req) => req.meta?.requestId === 'req-install-url',
|
|
);
|
|
assert.equal(installUrlRequest?.command, 'install_source');
|
|
assert.equal(installUrlRequest?.session, 'bootstrap');
|
|
assert.equal(installUrlRequest?.flags?.platform, 'android');
|
|
assert.equal(installUrlRequest?.meta?.retainMaterializedPaths, true);
|
|
assert.equal(installUrlRequest?.meta?.materializedPathRetentionMs, 30000);
|
|
assert.deepEqual(installUrlRequest?.meta?.installSource, {
|
|
kind: 'url',
|
|
url: 'https://example.com/app.apk',
|
|
headers: { authorization: 'Bearer signed-token' },
|
|
});
|
|
|
|
const githubRequest = observedRequests.find(
|
|
(req) => req.command === 'install_source' && req.flags?.platform === 'ios',
|
|
);
|
|
assert.deepEqual(githubRequest?.meta?.installSource, {
|
|
kind: 'github-actions-artifact',
|
|
owner: 'acme',
|
|
repo: 'mobile',
|
|
runId: 1234567890,
|
|
artifactName: 'ios-debug',
|
|
});
|
|
|
|
const leaseRequest = observedRequests.find((req) => req.command === 'lease_allocate');
|
|
assert.equal(leaseRequest?.meta?.tenantId, 'Tenant A');
|
|
assert.equal(leaseRequest?.meta?.leaseTtlMs, 60000);
|
|
assert.equal(leaseRequest?.meta?.leaseBackend, 'android-instance');
|
|
|
|
const releaseRequest = observedRequests.find(
|
|
(req) => req.command === 'release_materialized_paths',
|
|
);
|
|
assert.equal(releaseRequest?.meta?.materializationId, 'materialized-1');
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server cancels progress streams when the client disconnects', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const requestId = 'req-http-progress-cancel';
|
|
let observedCanceledDuringAbort: boolean | undefined;
|
|
let handlerError: unknown;
|
|
let resolveHandlerDone: () => void = () => {};
|
|
const handlerDone = new Promise<void>((resolve) => {
|
|
resolveHandlerDone = resolve;
|
|
});
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (req): Promise<DaemonResponse> => {
|
|
try {
|
|
assert.equal(req.meta?.requestId, requestId);
|
|
emitRequestProgress({
|
|
type: 'replay-test',
|
|
file: 'cancel.ad',
|
|
status: 'pass',
|
|
index: 1,
|
|
total: 1,
|
|
});
|
|
|
|
const signal = getRequestSignal(requestId);
|
|
assert.ok(signal, 'request abort signal should be registered during streaming');
|
|
await waitForAbort(signal);
|
|
observedCanceledDuringAbort = isRequestCanceled(requestId);
|
|
return { ok: true, data: { canceled: observedCanceledDuringAbort } };
|
|
} catch (error) {
|
|
handlerError = error;
|
|
throw error;
|
|
} finally {
|
|
resolveHandlerDone();
|
|
}
|
|
},
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
await withTimeout(
|
|
Promise.all([
|
|
abortStreamingRpcAfterFirstChunk(port, {
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-cancel-progress',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'session_list',
|
|
meta: { requestId, requestProgress: 'replay-test' },
|
|
},
|
|
}),
|
|
handlerDone,
|
|
]),
|
|
'streaming request cancellation',
|
|
);
|
|
if (handlerError) throw handlerError;
|
|
|
|
assert.equal(observedCanceledDuringAbort, true);
|
|
assert.equal(isRequestCanceled(requestId), false);
|
|
assert.equal(getRequestSignal(requestId), undefined);
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server destroys oversized RPC request bodies', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
let handled = false;
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => {
|
|
handled = true;
|
|
return { ok: true, data: {} };
|
|
},
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const oversizedBody = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-body-cap',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'session_list',
|
|
padding: 'x'.repeat(RPC_BODY_CAP_BYTES),
|
|
},
|
|
});
|
|
assert.ok(oversizedBody.length > RPC_BODY_CAP_BYTES);
|
|
|
|
const outcome = await sendRawRpcBody(port, oversizedBody);
|
|
assert.equal(handled, false);
|
|
if (outcome.kind === 'response') {
|
|
assert.equal(outcome.status, 400);
|
|
assert.equal(JSON.parse(outcome.body).error.code, -32700);
|
|
} else {
|
|
assert.match(outcome.message, /aborted|reset|socket hang up|request too large/i);
|
|
}
|
|
|
|
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
|
assert.equal(health.status, 200);
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server validates malformed JSON-RPC envelopes', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
let handled = false;
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => {
|
|
handled = true;
|
|
return { ok: true, data: {} };
|
|
},
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const cases = [
|
|
{
|
|
name: 'invalid JSON',
|
|
body: '{"jsonrpc":"2.0"',
|
|
status: 400,
|
|
code: -32700,
|
|
},
|
|
{
|
|
name: 'missing jsonrpc',
|
|
body: JSON.stringify({
|
|
id: 'rpc-missing-jsonrpc',
|
|
method: 'agent_device.command',
|
|
params: { command: 'session_list' },
|
|
}),
|
|
status: 400,
|
|
code: -32600,
|
|
},
|
|
{
|
|
name: 'unknown method',
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-unknown-method',
|
|
method: 'agent_device.nope',
|
|
params: {},
|
|
}),
|
|
status: 404,
|
|
code: -32601,
|
|
},
|
|
{
|
|
name: 'non-object params',
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-non-object-params',
|
|
method: 'agent_device.command',
|
|
params: 'session_list',
|
|
}),
|
|
status: 400,
|
|
code: -32602,
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const response = await callRawRpc(port, testCase.body);
|
|
assert.equal(response.status, testCase.status, testCase.name);
|
|
assert.equal(response.body.error?.code, testCase.code, testCase.name);
|
|
}
|
|
assert.equal(handled, false);
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server writes error envelopes after streaming headers are sent', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => {
|
|
emitRequestProgress({
|
|
type: 'replay-test',
|
|
file: 'headers-sent.ad',
|
|
status: 'fail',
|
|
index: 1,
|
|
total: 1,
|
|
message: 'progress before failure',
|
|
});
|
|
throw new AppError('COMMAND_FAILED', 'stream failed after headers');
|
|
},
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-stream-error',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'test',
|
|
meta: { requestId: 'req-stream-error', requestProgress: 'replay-test' },
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('content-type'), 'application/x-ndjson');
|
|
const envelopes = parseNdjson(await response.text());
|
|
assert.equal(envelopes.length, 2);
|
|
assert.equal(envelopes[0]?.type, 'progress');
|
|
const terminal = envelopes[1] as JsonRpcResponseEnvelope;
|
|
assert.equal(terminal.type, 'response');
|
|
assert.equal(terminal.response.error?.code, -32000);
|
|
assert.equal(terminal.response.error?.data?.code, 'COMMAND_FAILED');
|
|
assert.equal(terminal.response.error?.message, 'stream failed after headers');
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server accepts uploads and streams downloadable artifacts', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-http-server-'));
|
|
const downloadablePath = path.join(root, 'screen.png');
|
|
fs.writeFileSync(downloadablePath, 'png-binary');
|
|
const artifactId = trackDownloadableArtifact({
|
|
artifactPath: downloadablePath,
|
|
artifactType: 'screenshot',
|
|
fileName: 'screen.png',
|
|
});
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => ({ ok: true, data: {} }),
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
|
|
const upload = await fetch(`http://127.0.0.1:${port}/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'x-artifact-type': 'file',
|
|
'x-artifact-filename': 'demo.apk',
|
|
'content-type': 'application/octet-stream',
|
|
},
|
|
body: Buffer.from('fake-apk'),
|
|
});
|
|
assert.equal(upload.status, 200);
|
|
const uploadBody = (await upload.json()) as { ok?: boolean; uploadId?: string };
|
|
assert.equal(uploadBody.ok, true);
|
|
assert.equal(typeof uploadBody.uploadId, 'string');
|
|
|
|
const downloaded = await fetch(`http://127.0.0.1:${port}/artifacts/${artifactId}`, {
|
|
headers: { authorization: 'Bearer provider-scenario-token' },
|
|
});
|
|
assert.equal(downloaded.status, 200);
|
|
assert.equal(await downloaded.text(), 'png-binary');
|
|
assert.match(downloaded.headers.get('content-disposition') ?? '', /screen\.png/);
|
|
|
|
const rejectedUpload = await fetch(`http://127.0.0.1:${port}/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer wrong-token',
|
|
'x-artifact-type': 'file',
|
|
'x-artifact-filename': 'demo.apk',
|
|
},
|
|
body: Buffer.from('fake-apk'),
|
|
});
|
|
assert.equal(rejectedUpload.status, 401);
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server resumes direct uploads before finalize', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const content = Buffer.from('fake-apk-resumable-content');
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => ({ ok: true, data: {} }),
|
|
});
|
|
let trackedUploadId = '';
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const preflight = await fetch(`http://127.0.0.1:${port}/upload/preflight`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
uploadAttemptId: 'resume-before-finalize',
|
|
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
fileName: 'demo.apk',
|
|
sizeBytes: content.length,
|
|
artifactType: 'file',
|
|
platform: 'android',
|
|
contentType: 'application/octet-stream',
|
|
}),
|
|
});
|
|
assert.equal(preflight.status, 200);
|
|
const preflightBody = (await preflight.json()) as {
|
|
uploadId?: string;
|
|
upload?: { url?: string; headers?: Record<string, string> };
|
|
};
|
|
assert.equal(typeof preflightBody.uploadId, 'string');
|
|
assert.equal(typeof preflightBody.upload?.url, 'string');
|
|
if (!preflightBody.upload?.url) throw new Error('missing upload url');
|
|
const uploadUrl = preflightBody.upload.url;
|
|
const uploadHeaders = preflightBody.upload.headers ?? {};
|
|
|
|
const firstChunk = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
headers: uploadHeaders,
|
|
body: content.subarray(0, 9),
|
|
});
|
|
assert.equal(firstChunk.status, 308);
|
|
assert.equal(firstChunk.headers.get('x-upload-offset'), '9');
|
|
|
|
const restartFromZero = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
headers: uploadHeaders,
|
|
body: content,
|
|
});
|
|
assert.equal(restartFromZero.status, 308);
|
|
assert.equal(restartFromZero.headers.get('x-upload-offset'), '9');
|
|
|
|
const resumed = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
...uploadHeaders,
|
|
'content-range': `bytes 9-${content.length - 1}/${content.length}`,
|
|
},
|
|
body: content.subarray(9),
|
|
});
|
|
assert.equal(resumed.status, 200);
|
|
|
|
const finalize = await fetch(`http://127.0.0.1:${port}/upload/finalize`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ uploadId: preflightBody.uploadId }),
|
|
});
|
|
assert.equal(finalize.status, 200);
|
|
const finalizeBody = (await finalize.json()) as { uploadId?: string };
|
|
trackedUploadId = finalizeBody.uploadId ?? '';
|
|
assert.equal(typeof trackedUploadId, 'string');
|
|
assert.deepEqual(fs.readFileSync(prepareUploadedArtifact(trackedUploadId)), content);
|
|
} finally {
|
|
if (trackedUploadId) cleanupUploadedArtifact(trackedUploadId);
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server isolates same-artifact upload attempts', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const content = Buffer.from('same-apk-content');
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => ({ ok: true, data: {} }),
|
|
});
|
|
const trackedUploadIds: string[] = [];
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const requestPreflight = async (uploadAttemptId: string) => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/upload/preflight`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
uploadAttemptId,
|
|
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
fileName: 'demo.apk',
|
|
sizeBytes: content.length,
|
|
artifactType: 'file',
|
|
platform: 'android',
|
|
contentType: 'application/octet-stream',
|
|
}),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = (await response.json()) as {
|
|
uploadId?: string;
|
|
upload?: { url?: string; headers?: Record<string, string> };
|
|
};
|
|
assert.equal(typeof body.uploadId, 'string');
|
|
assert.equal(typeof body.upload?.url, 'string');
|
|
if (!body.uploadId || !body.upload?.url) throw new Error('missing upload ticket');
|
|
return {
|
|
uploadId: body.uploadId,
|
|
uploadUrl: body.upload.url,
|
|
uploadHeaders: body.upload.headers ?? {},
|
|
};
|
|
};
|
|
|
|
const first = await requestPreflight('same-artifact-first');
|
|
const second = await requestPreflight('same-artifact-second');
|
|
assert.notEqual(first.uploadId, second.uploadId);
|
|
assert.notEqual(first.uploadUrl, second.uploadUrl);
|
|
|
|
const firstChunk = await fetch(first.uploadUrl, {
|
|
method: 'PUT',
|
|
headers: first.uploadHeaders,
|
|
body: content.subarray(0, 5),
|
|
});
|
|
assert.equal(firstChunk.status, 308);
|
|
assert.equal(firstChunk.headers.get('x-upload-offset'), '5');
|
|
|
|
const secondComplete = await fetch(second.uploadUrl, {
|
|
method: 'PUT',
|
|
headers: second.uploadHeaders,
|
|
body: content,
|
|
});
|
|
assert.equal(secondComplete.status, 200);
|
|
|
|
const secondFinalize = await finalizeUpload(port, second.uploadId);
|
|
trackedUploadIds.push(secondFinalize);
|
|
assert.deepEqual(fs.readFileSync(prepareUploadedArtifact(secondFinalize)), content);
|
|
|
|
const firstComplete = await fetch(first.uploadUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
...first.uploadHeaders,
|
|
'content-range': `bytes 5-${content.length - 1}/${content.length}`,
|
|
},
|
|
body: content.subarray(5),
|
|
});
|
|
assert.equal(firstComplete.status, 200);
|
|
|
|
const firstFinalize = await finalizeUpload(port, first.uploadId);
|
|
trackedUploadIds.push(firstFinalize);
|
|
assert.deepEqual(fs.readFileSync(prepareUploadedArtifact(firstFinalize)), content);
|
|
} finally {
|
|
for (const uploadId of trackedUploadIds) cleanupUploadedArtifact(uploadId);
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP server reports unknown upload tickets as expired', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const server = await createDaemonHttpServer({
|
|
token: 'provider-scenario-token',
|
|
handleRequest: async (): Promise<DaemonResponse> => ({ ok: true, data: {} }),
|
|
});
|
|
|
|
try {
|
|
const port = await listenOnLoopback(server);
|
|
const unknownUploadId = crypto.randomUUID();
|
|
const auth = { authorization: 'Bearer provider-scenario-token' };
|
|
|
|
const directChunk = await fetch(`http://127.0.0.1:${port}/upload/direct/${unknownUploadId}`, {
|
|
method: 'PUT',
|
|
headers: { ...auth, 'content-type': 'application/octet-stream' },
|
|
body: Buffer.from('late-chunk'),
|
|
});
|
|
assert.equal(directChunk.status, 500);
|
|
const directBody = (await directChunk.json()) as {
|
|
ok?: boolean;
|
|
code?: string;
|
|
error?: string;
|
|
};
|
|
assert.equal(directBody.ok, false);
|
|
assert.equal(directBody.code, 'COMMAND_FAILED');
|
|
assert.match(directBody.error ?? '', /not found or expired/);
|
|
|
|
const finalize = await fetch(`http://127.0.0.1:${port}/upload/finalize`, {
|
|
method: 'POST',
|
|
headers: { ...auth, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ uploadId: unknownUploadId }),
|
|
});
|
|
assert.equal(finalize.status, 500);
|
|
const finalizeBody = (await finalize.json()) as {
|
|
ok?: boolean;
|
|
code?: string;
|
|
error?: string;
|
|
};
|
|
assert.equal(finalizeBody.ok, false);
|
|
assert.equal(finalizeBody.code, 'COMMAND_FAILED');
|
|
assert.match(finalizeBody.error ?? '', /not found or expired/);
|
|
} finally {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
});
|
|
|
|
test('Provider-backed integration daemon HTTP auth hook can scope tenants and reject requests', async (t) => {
|
|
if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP integration coverage')) {
|
|
return;
|
|
}
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-http-auth-hook-'));
|
|
const hookPath = path.join(root, 'auth-hook.mjs');
|
|
fs.writeFileSync(
|
|
hookPath,
|
|
`export default function authHook({ headers }) {
|
|
if (headers['x-reject'] === 'yes') return { ok: false, code: 'UNAUTHORIZED', message: 'tenant rejected' };
|
|
return { tenantId: headers['x-tenant'] || 'tenant-hook' };
|
|
}`,
|
|
);
|
|
const previousHook = process.env.AGENT_DEVICE_HTTP_AUTH_HOOK;
|
|
process.env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath;
|
|
|
|
let observedRequest: DaemonRequest | undefined;
|
|
let server: Awaited<ReturnType<typeof createDaemonHttpServer>> | undefined;
|
|
|
|
try {
|
|
server = await createDaemonHttpServer({
|
|
handleRequest: async (req): Promise<DaemonResponse> => {
|
|
observedRequest = req;
|
|
return { ok: true, data: { meta: req.meta } };
|
|
},
|
|
});
|
|
const port = await listenOnLoopback(server);
|
|
const accepted = await callRpc(
|
|
port,
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-auth-hook',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'session_list',
|
|
flags: { sessionIsolation: 'tenant' },
|
|
},
|
|
},
|
|
{ 'x-tenant': 'tenant-hook' },
|
|
);
|
|
assert.equal(accepted.status, 200);
|
|
assert.equal(observedRequest?.meta?.tenantId, 'tenant-hook');
|
|
assert.equal(observedRequest?.meta?.sessionIsolation, 'tenant');
|
|
|
|
const rejected = await callRpc(
|
|
port,
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 'rpc-auth-reject',
|
|
method: 'agent_device.command',
|
|
params: {
|
|
command: 'session_list',
|
|
},
|
|
},
|
|
{ 'x-reject': 'yes' },
|
|
);
|
|
assert.equal(rejected.status, 401);
|
|
assert.equal(rejected.body.error?.data?.message, 'tenant rejected');
|
|
} finally {
|
|
if (server) {
|
|
await closeLoopbackServer(server);
|
|
}
|
|
restoreEnv('AGENT_DEVICE_HTTP_AUTH_HOOK', previousHook);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function callRpc(
|
|
port: number,
|
|
payload: Record<string, unknown>,
|
|
headers: Record<string, string> = { authorization: 'Bearer provider-scenario-token' },
|
|
): Promise<RpcResponse> {
|
|
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
...headers,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: (await response.json()) as RpcResponse['body'],
|
|
};
|
|
}
|
|
|
|
async function finalizeUpload(port: number, uploadId: string): Promise<string> {
|
|
const response = await fetch(`http://127.0.0.1:${port}/upload/finalize`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ uploadId }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = (await response.json()) as { uploadId?: string };
|
|
const finalizedUploadId = body.uploadId;
|
|
if (typeof finalizedUploadId !== 'string') throw new Error('missing finalized upload id');
|
|
return finalizedUploadId;
|
|
}
|
|
|
|
async function callRawRpc(port: number, body: string): Promise<RpcResponse> {
|
|
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
},
|
|
body,
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: (await response.json()) as RpcResponse['body'],
|
|
};
|
|
}
|
|
|
|
function abortStreamingRpcAfterFirstChunk(
|
|
port: number,
|
|
payload: Record<string, unknown>,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
let sawChunk = false;
|
|
let settled = false;
|
|
const settle = (error?: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (error) reject(error);
|
|
else resolve();
|
|
};
|
|
const body = JSON.stringify(payload);
|
|
const req = http.request(
|
|
{
|
|
host: '127.0.0.1',
|
|
port,
|
|
path: '/rpc',
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
'content-length': Buffer.byteLength(body),
|
|
},
|
|
},
|
|
(res) => {
|
|
res.setEncoding('utf8');
|
|
res.once('data', () => {
|
|
sawChunk = true;
|
|
res.destroy();
|
|
req.destroy();
|
|
});
|
|
res.on('close', () => {
|
|
if (sawChunk) settle();
|
|
});
|
|
res.on('error', (error) => {
|
|
if (sawChunk) settle();
|
|
else settle(error);
|
|
});
|
|
},
|
|
);
|
|
req.on('error', (error) => {
|
|
if (sawChunk) settle();
|
|
else settle(error);
|
|
});
|
|
req.end(body);
|
|
});
|
|
}
|
|
|
|
function sendRawRpcBody(
|
|
port: number,
|
|
body: string,
|
|
): Promise<
|
|
{ kind: 'response'; status: number; body: string } | { kind: 'error'; message: string }
|
|
> {
|
|
return new Promise((resolve) => {
|
|
const req = http.request(
|
|
{
|
|
host: '127.0.0.1',
|
|
port,
|
|
path: '/rpc',
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer provider-scenario-token',
|
|
'content-type': 'application/json',
|
|
'content-length': Buffer.byteLength(body),
|
|
},
|
|
},
|
|
(res) => {
|
|
res.setEncoding('utf8');
|
|
let responseBody = '';
|
|
res.on('data', (chunk) => {
|
|
responseBody += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
resolve({ kind: 'response', status: res.statusCode ?? 0, body: responseBody });
|
|
});
|
|
res.on('error', (error) => {
|
|
resolve({ kind: 'error', message: error.message });
|
|
});
|
|
},
|
|
);
|
|
req.on('error', (error) => {
|
|
resolve({ kind: 'error', message: error.message });
|
|
});
|
|
req.end(body);
|
|
});
|
|
}
|
|
|
|
function parseNdjson(body: string): Array<Record<string, unknown>> {
|
|
return body
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
}
|
|
|
|
async function waitForAbort(signal: AbortSignal): Promise<void> {
|
|
if (signal.aborted) return;
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
});
|
|
}
|
|
|
|
async function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for ${label}`));
|
|
}, 1_000);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|