mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
dcd8b65d4c
* refactor(daemon): split daemon/types.ts into request and session-state modules `src/daemon/types.ts` served two audiences from one file: the dispatch request shape and the daemon's live session record. It also sat in the only daemon type cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState` back — so neither file could be read in isolation. Three modules replace it, each importing only downward: - `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request with no `internal` key and no property path to `SessionState` or `DeviceLease`, so a consumer can read a request's command, flags and public metadata without depending on the session record. - `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which stays unexported) plus the response vocabulary. - `session-state.ts` owns `SessionState` and the shapes only it holds. The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and `session-state.ts`: it owns the frame VALUE (the class stays unexported, so the type remains nominal and unconstructible from outside), while `ref-frame.ts` keeps every lifetime transition and every `session.refFrame` write. No behavior change: every importer moves to the module owning the symbol it uses, with no re-export shim at the old path. `client-normalizers.ts` takes `SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * test(daemon): assert the wire request shape cannot reach session state A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an `internal` key or grows a property path back to `SessionState` or `DeviceLease`. Positive controls over `DaemonRequest` prove the walk finds both when they are there, so a walk that never matches anything cannot pass by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * test(daemon): keep the three over-budget test files at their base length Splitting `daemon/types.ts` turns one combined import into two in every file that used both halves. Three of those test files are already over the 1,000-line tripwire, where the size ratchet allows no growth, so each sheds one line that was carrying nothing: - `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength` assertion an adjacent `toEqual` on an explicit array literal already makes. - `session-replay-repair-transaction.test.ts` names the filtered close actions instead of wrapping the expression across three lines inside `expect`. No assertion is weakened and no test content is removed. Splitting these files along the modules they mirror is the standing remedy, but none of those modules split here, so it stays out of this change and is tracked in #2353. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y * chore(gates): point the daemon modularity and wire-compat gates at the split modules R7 now locates the `SessionState` declaration by the declaration itself rather than by a recorded path: `sessionStateWritePressure` measures the merge-base tree too, and that tree still declares it in `daemon/types.ts` — a path constant would measure it as zero pressure and bank the headroom. R10's external-importer ratchet covers all three modules that replaced `daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to a new outside zone. The recorded membership is unchanged: `client-normalizers.ts` and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only. The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and `DaemonArtifact` by path, so those three keys follow the declarations to `daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the server-side narrowing of the kernel declaration that fixes the wire shape — rather than calling it a re-export alias. The `live-state-shape` and session-resource declaration sites move with `SessionState`; the depgraph lookalike fixture takes a new plausible path now that `daemon/session-state.ts` is the real root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ujrc8LYmvM249WY8921J1Y --------- Co-authored-by: Claude <noreply@anthropic.com>
994 lines
31 KiB
TypeScript
994 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 '@agent-device/contracts/daemon-http';
|
|
import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts';
|
|
import {
|
|
emitRequestProgress,
|
|
getRequestSignal,
|
|
isRequestCanceled,
|
|
} from '@agent-device/host-kit/request';
|
|
|
|
import type { DaemonRequest, DaemonResponse } from '../../../src/daemon/daemon-request.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);
|
|
}
|
|
}
|