mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
c61b6ed407
* feat(remote): add HarmonyOS proxy lease backend Signed-off-by: Ark <artin@cat.ms> * feat(remote): add HarmonyOS proxy lease backend Signed-off-by: Ark <artin@cat.ms> * feat(remote): complete HarmonyOS lease backend wiring Signed-off-by: Ark <artin@cat.ms> * feat(remote): complete HarmonyOS lease backend wiring Signed-off-by: Ark <artin@cat.ms> * fix(remote): accept Harmony runtime hints Signed-off-by: Ark <artin@cat.ms> * fix(remote): complete Harmony runtime lease plumbing Signed-off-by: Ark <artin@cat.ms> * test(wire): acknowledge Harmony lease additions Signed-off-by: Ark <artin@cat.ms> * test(remote): close HarmonyOS lease review gaps Signed-off-by: Ark <artin@cat.ms> * fix(runtime): update HarmonyOS support error text Signed-off-by: Ark <artin@cat.ms> * fix(remote): preserve HarmonyOS runtime and proxy device identity Signed-off-by: Ark <raft-mobile-ark@mail.build> * fix(script): avoid eager contracts import and relocate Harmony test Signed-off-by: Ark <raft-mobile-ark@mail.build> --------- Signed-off-by: Ark <artin@cat.ms> Signed-off-by: Ark <raft-mobile-ark@mail.build> Co-authored-by: Ark <raft-mobile-ark@mail.build>
412 lines
17 KiB
TypeScript
412 lines
17 KiB
TypeScript
/**
|
|
* The daemon RPC wire surface, grouped by the ADR 0006 rule each group serves
|
|
* (#1432).
|
|
*
|
|
* ADR 0006 says exactly when `DAEMON_RPC_PROTOCOL_VERSION` must be bumped, and
|
|
* until this manifest existed nothing checked that it was. The runtime guard
|
|
* (`readRemoteDaemonHealth`) refuses a mismatched peer — but only when someone
|
|
* remembered to bump the constant, so a wire change that skipped the bump left
|
|
* both sides advertising the same protocol while parsing different payloads.
|
|
*
|
|
* The grouping is not decoration: each group quotes the ADR bullet it covers,
|
|
* so a reader can check the manifest against the decision rather than against
|
|
* someone's summary of it. `uncovered` is the honest half — where a bullet is
|
|
* only partly digestible, the group says which part is reviewer-owned and why,
|
|
* instead of implying coverage the digests do not provide.
|
|
*
|
|
* Review P1 (2026-08-10) corrected a real overclaim here: the first version
|
|
* quoted all four bullets but digested only the payload TYPES, leaving the
|
|
* producer and consumer seams — method sets, response serialization, auth
|
|
* projection, upload ticket/308 framing, artifact framing, and the client's
|
|
* own parsers — able to break a skewed peer without moving a listed digest.
|
|
* Both sides of every boundary are now listed, and what remains outside is
|
|
* named in `uncovered` rather than implied to be covered.
|
|
*/
|
|
|
|
export type WireDeclarationRef = {
|
|
/** Repo-relative source file. */
|
|
file: string;
|
|
/** Top-level declaration name, exported or not. */
|
|
name: string;
|
|
};
|
|
|
|
export type WireSurfaceGroup = {
|
|
/** The ADR 0006 "bump it for" bullet this group covers, quoted. */
|
|
adrBullet: string;
|
|
declarations: readonly WireDeclarationRef[];
|
|
/** Part of the bullet the digests deliberately do not cover, and why. */
|
|
uncovered?: string;
|
|
};
|
|
|
|
const KERNEL_CONTRACTS = 'packages/kernel/src/contracts.ts';
|
|
const KERNEL_ERRORS = 'packages/kernel/src/errors.ts';
|
|
const KERNEL_DEVICE = 'packages/kernel/src/device.ts';
|
|
const REQUEST_PROGRESS = 'packages/contracts/src/request-progress.ts';
|
|
const DAEMON_HTTP = 'packages/contracts/src/daemon-http.ts';
|
|
const HTTP_ERRORS = 'src/daemon/http-errors.ts';
|
|
const HTTP_SERVER = 'src/daemon/server/http-server.ts';
|
|
const UPLOAD_HTTP = 'src/daemon/upload-http.ts';
|
|
const ARTIFACT_HTTP = 'src/daemon/downloadable-artifact-http.ts';
|
|
const REQUEST_DIAGNOSTICS_HTTP = 'src/daemon/request-diagnostics-http.ts';
|
|
const SESSION_TENANT_SCOPE = 'src/daemon/session-tenant-scope.ts';
|
|
const HTTP_REQUEST_TARGET = 'src/daemon/http-request-target.ts';
|
|
const REMOTE_REQUEST_DIAGNOSTICS = 'src/remote/remote-request-diagnostics.ts';
|
|
const PROGRESS_PROTOCOL = 'src/daemon/request-progress-protocol.ts';
|
|
const CLIENT_RPC = 'src/daemon-client/daemon-client-rpc.ts';
|
|
const CLIENT_PROGRESS = 'src/daemon-client/daemon-client-progress.ts';
|
|
const CLIENT_TRANSPORT = 'src/daemon-client/daemon-client-transport.ts';
|
|
const UPLOAD_CLIENT = 'src/remote/upload-client.ts';
|
|
const REMOTE_ARTIFACTS = 'src/remote/daemon-artifacts.ts';
|
|
const ARTIFACT_DOWNLOAD = 'src/remote/artifact-download.ts';
|
|
const UPLOAD_STREAM = 'src/remote/upload-stream.ts';
|
|
|
|
function from(file: string, ...names: string[]): WireDeclarationRef[] {
|
|
return names.map((name) => ({ file, name }));
|
|
}
|
|
|
|
export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [
|
|
{
|
|
adrBullet: 'HTTP route requirements for /health, /rpc, /upload, or /artifacts/*.',
|
|
declarations: [
|
|
...from(DAEMON_HTTP, 'DAEMON_HTTP_BASE_PATH', 'buildDaemonHttpUrl', 'buildDaemonHttpBaseUrl'),
|
|
...from(DAEMON_HTTP, 'DaemonHealthPayload', 'buildDaemonHealthPayload'),
|
|
// A shrunk body limit rejects payloads a released client still sends, so
|
|
// it is a route requirement rather than an implementation detail.
|
|
...from(HTTP_SERVER, 'MAX_HTTP_RPC_BODY_BYTES'),
|
|
...from(
|
|
UPLOAD_HTTP,
|
|
'DIRECT_UPLOAD_PATH_PREFIX',
|
|
'UploadHttpRoute',
|
|
'resolveUploadHttpRoute',
|
|
'handleUpload',
|
|
'handleUploadPreflight',
|
|
// Carries the 308 resumable-upload framing (status, headers).
|
|
'handleResumableUpload',
|
|
'handleUploadFinalize',
|
|
'resolveHttpRequestBaseUrl',
|
|
),
|
|
...from(
|
|
ARTIFACT_HTTP,
|
|
'DownloadableArtifactHttpRoute',
|
|
'resolveDownloadableArtifactHttpRoute',
|
|
'readArtifactId',
|
|
'readRequestPathname',
|
|
),
|
|
// Shared by every auxiliary route: how one segment of a request target
|
|
// becomes the id a route matches on.
|
|
...from(HTTP_REQUEST_TARGET, 'decodeUriSegment'),
|
|
// `/sessions/<session>/requests/<requestId>/diagnostics` (#1801): the
|
|
// route a remote caller fetches a failed request's record by. The
|
|
// resolver owns the path shape and the segment vocabulary; the client's
|
|
// URL builder below is its counterpart.
|
|
...from(REQUEST_DIAGNOSTICS_HTTP, 'resolveRequestDiagnosticsHttpRoute'),
|
|
...from(REMOTE_REQUEST_DIAGNOSTICS, 'buildRemoteRequestDiagnosticsUrl'),
|
|
// Consumer side of /health: the client reads this payload and refuses a
|
|
// mismatched peer from it, so a narrowed reader defeats the very check
|
|
// ADR 0006 built. `readRemoteDaemonHealth` is where the comparison lives.
|
|
...from(
|
|
CLIENT_TRANSPORT,
|
|
'RemoteDaemonHealth',
|
|
'RemoteDaemonHealthLink',
|
|
'readHealthPayload',
|
|
'readHealthLink',
|
|
'readDaemonHttpHealth',
|
|
'readRemoteDaemonHealth',
|
|
),
|
|
],
|
|
// What is left is `createDaemonHttpServer`, a 200+ line dispatcher whose
|
|
// body churns for reasons that are not protocol changes. Everything it
|
|
// dispatches WITH — method sets, envelope, error framing, auth projection,
|
|
// request projections — is digested individually above and below, so the
|
|
// uncovered remainder is the wiring plus the two path literals it compares
|
|
// (`/health`, `/rpc`). Those stay reviewer-owned because their failure mode
|
|
// is the loud one: a moved route answers 404 at connect time, before any
|
|
// payload is exchanged. Everything digested here can misparse silently
|
|
// instead, which is what the gate exists to prevent.
|
|
uncovered:
|
|
'createDaemonHttpServer dispatch wiring, and the /health and /rpc path literals inside it: ' +
|
|
'a moved route 404s at connect time rather than misparsing, so it stays reviewer-owned.',
|
|
},
|
|
{
|
|
adrBullet: 'Authentication semantics required to authorize RPC, upload, or artifact requests.',
|
|
declarations: [
|
|
...from(
|
|
DAEMON_HTTP,
|
|
'buildDaemonHttpAuthHeaders',
|
|
'DAEMON_HTTP_TENANT_HEADER',
|
|
'buildDaemonHttpTenantHeaders',
|
|
),
|
|
...from(
|
|
HTTP_SERVER,
|
|
'HttpAuthHookContext',
|
|
'HttpAuthHookResult',
|
|
'HttpAuthHook',
|
|
'HttpAuthDecision',
|
|
'resolveToken',
|
|
'readHeaderValue',
|
|
'enforceDaemonToken',
|
|
'authorizeAuxiliaryHttpRequest',
|
|
),
|
|
...from(UPLOAD_HTTP, 'AuxiliaryHttpAuthorizer', 'buildUploadTicketAuthHeaders'),
|
|
...from(ARTIFACT_HTTP, 'DownloadableArtifactHttpAuthorizer'),
|
|
// The diagnostics route's authorization: the same token/auth-hook gate as
|
|
// the artifact routes, plus the tenant rule that decides which sessions a
|
|
// caller may read a record from (#1801). `isTenantAddressableSessionName`
|
|
// is that rule; the namespace it takes says whether the caller's sessions
|
|
// were partitioned at all, which is what the naming side keys off.
|
|
...from(REQUEST_DIAGNOSTICS_HTTP, 'RequestDiagnosticsHttpAuthorizer'),
|
|
...from(
|
|
SESSION_TENANT_SCOPE,
|
|
'TenantSessionNamespace',
|
|
'isTenantOwnedSessionName',
|
|
'isTenantAddressableSessionName',
|
|
),
|
|
],
|
|
},
|
|
{
|
|
adrBullet:
|
|
'JSON-RPC envelope shape, method naming, request id handling, or command request projection.',
|
|
declarations: [
|
|
...from(
|
|
KERNEL_CONTRACTS,
|
|
'JsonRpcId',
|
|
'JsonRpcRequestEnvelope',
|
|
'jsonRpcRequestSchema',
|
|
'CommandRpcParams',
|
|
'commandRpcParamsSchema',
|
|
'DaemonRequest',
|
|
'DaemonRequestMeta',
|
|
'SessionRuntimeHints',
|
|
'SESSION_RUNTIME_PLATFORMS',
|
|
'SessionRuntimePlatform',
|
|
'daemonRuntimeSchema',
|
|
'DaemonInstallSource',
|
|
'DAEMON_LOCK_POLICIES',
|
|
'DaemonLockPolicy',
|
|
'LEASE_BACKENDS',
|
|
'LeaseBackend',
|
|
'SESSION_ISOLATION_MODES',
|
|
'SessionIsolationMode',
|
|
'RESPONSE_LEVELS',
|
|
'ResponseLevel',
|
|
),
|
|
...from(KERNEL_DEVICE, 'PLATFORM_SELECTORS', 'PlatformSelector'),
|
|
// The CLI's projection of a command into the request the daemon receives.
|
|
// Its embedded flag/option vocabulary is waived rather than listed — see
|
|
// closure-policy.ts: those reach the peer inside DaemonRequest's untyped
|
|
// `flags`/`input` bags, and ADR 0006 calls new flags additive.
|
|
...from('src/commands/cli-grammar/types.ts', 'DaemonCommandRequest'),
|
|
// The lease method vocabulary the client and daemon must agree on.
|
|
...from('packages/contracts/src/lease-scope.ts', 'LeaseRpcCommand'),
|
|
// Producer side: the method vocabulary a released client sends, and the
|
|
// projections that turn each method's params into a DaemonRequest.
|
|
...from(
|
|
HTTP_SERVER,
|
|
'JsonRpcRequest',
|
|
'JsonRpcResponse',
|
|
'COMMAND_RPC_METHODS',
|
|
'INSTALL_FROM_SOURCE_RPC_METHODS',
|
|
'RELEASE_MATERIALIZED_PATHS_RPC_METHODS',
|
|
'LEASE_RPC_METHOD_TO_COMMAND',
|
|
'SUPPORTED_RPC_METHODS',
|
|
'isCommandRpcMethod',
|
|
'methodToDaemonRequest',
|
|
'parseCommandRpcParams',
|
|
'toDaemonRequest',
|
|
'toLeaseDaemonRequest',
|
|
'toInstallFromSourceDaemonRequest',
|
|
'toReleaseMaterializedPathsDaemonRequest',
|
|
),
|
|
// Consumer side: the payload the client actually puts on the wire. A
|
|
// client-only change here breaks an older daemon just as surely.
|
|
...from(
|
|
CLIENT_RPC,
|
|
'buildHttpRpcPayload',
|
|
'isLeaseRpcCommand',
|
|
'leaseRpcMethodForCommand',
|
|
'buildLeaseRpcParams',
|
|
),
|
|
],
|
|
},
|
|
{
|
|
adrBullet:
|
|
'Response, error, artifact, upload, or progress-stream framing that existing clients parse.',
|
|
declarations: [
|
|
...from(
|
|
KERNEL_CONTRACTS,
|
|
'DaemonResponse',
|
|
'DaemonResponseData',
|
|
'ResponseCost',
|
|
'DaemonArtifact',
|
|
'DaemonArtifactType',
|
|
'DaemonArtifactKnownType',
|
|
),
|
|
...from(
|
|
KERNEL_ERRORS,
|
|
'DaemonError',
|
|
'DiagnosticsRecordRef',
|
|
'ErrorCause',
|
|
'readDiagnosticsRecordRef',
|
|
),
|
|
// "These are wire values" — the progress module says so itself: the daemon
|
|
// serializes them onto the response stream and the CLI reconstructs them.
|
|
...from(
|
|
REQUEST_PROGRESS,
|
|
'RequestProgressEvent',
|
|
'ReplayTestSuiteProgressEvent',
|
|
'ReplayTestProgressEvent',
|
|
'CommandProgressEvent',
|
|
),
|
|
...from(
|
|
PROGRESS_PROTOCOL,
|
|
'DaemonProgressEnvelope',
|
|
'DaemonResponseEnvelope',
|
|
'shouldStreamRequestProgress',
|
|
'isDaemonProgressEnvelope',
|
|
'isDaemonResponseEnvelope',
|
|
'serializeDaemonProgressEnvelope',
|
|
'serializeDaemonResponseEnvelope',
|
|
'serializeDaemonRpcResponseEnvelope',
|
|
),
|
|
...from(
|
|
HTTP_SERVER,
|
|
'createRpcError',
|
|
'sendJson',
|
|
'writeProgressEnvelope',
|
|
'writeRpcResponseEnvelope',
|
|
'jsonRpcCodeForNormalizedError',
|
|
),
|
|
...from(
|
|
HTTP_ERRORS,
|
|
'NormalizedHttpError',
|
|
'statusCodeForNormalizedError',
|
|
'sendRestJsonError',
|
|
'failStreamedHttpResponse',
|
|
),
|
|
...from(
|
|
UPLOAD_HTTP,
|
|
'UploadPreflightBody',
|
|
'UploadFinalizeBody',
|
|
'readUploadPreflightBody',
|
|
'readUploadFinalizeBody',
|
|
'sendJson',
|
|
'sendUploadedArtifactResponse',
|
|
),
|
|
// The resumable-upload ticket the preflight response hands back.
|
|
...from('src/daemon/resumable-upload.ts', 'BeginResumableUploadOptions'),
|
|
// `NormalizedHttpError` is `ReturnType<typeof normalizeError>`, so the
|
|
// function and its return type — not a type alias — are what fix the
|
|
// REST error payload a released client parses.
|
|
...from(KERNEL_ERRORS, 'normalizeError', 'NormalizedError', 'NormalizeErrorContext'),
|
|
...from(ARTIFACT_HTTP, 'handleArtifactInventory', 'handleArtifactDownload'),
|
|
// Producer and consumer of the diagnostics record body (#1801): what the
|
|
// daemon streams back (status, content type/length) and what the client
|
|
// accepts before it will name the fetched copy as the caller's log path.
|
|
...from(
|
|
REQUEST_DIAGNOSTICS_HTTP,
|
|
'REQUEST_DIAGNOSTICS_CONTENT_TYPE',
|
|
'RequestDiagnosticsHttpOptions',
|
|
'handleRequestDiagnostics',
|
|
),
|
|
...from(
|
|
REMOTE_REQUEST_DIAGNOSTICS,
|
|
'RemoteDiagnosticsEndpoint',
|
|
'RemoteDaemonErrorPayload',
|
|
'localizeRemoteDaemonError',
|
|
'fetchRemoteRequestDiagnostics',
|
|
),
|
|
// Consumer side: what the client accepts back. A parser narrowed here
|
|
// rejects a released daemon's response without any server change.
|
|
...from(
|
|
CLIENT_RPC,
|
|
'handleDaemonHttpResponseBody',
|
|
'parseDaemonHttpResponseBody',
|
|
'toDaemonHttpRpcError',
|
|
'rejectDaemonHttpRpcError',
|
|
'appErrorFromDaemonError',
|
|
'resolveDaemonHttpResult',
|
|
),
|
|
...from(
|
|
CLIENT_PROGRESS,
|
|
'ProgressResponseFormat',
|
|
'shouldReadDaemonProgressStream',
|
|
'createInvalidDaemonResponseError',
|
|
),
|
|
// Consumer side of the auxiliary /upload boundary: the shapes the client
|
|
// expects back from preflight, direct/resumable, legacy and finalize, and
|
|
// the parser that decides whether a daemon's preflight is usable at all.
|
|
...from(
|
|
UPLOAD_CLIENT,
|
|
'ARTIFACT_HASH_ALGORITHM',
|
|
'UploadResponse',
|
|
'UploadPreflightResponse',
|
|
'UploadPreflightResult',
|
|
'parseUploadPreflightResult',
|
|
'isStringRecord',
|
|
'requestUploadPreflight',
|
|
'uploadDirectArtifact',
|
|
'tryDirectUploadWithResume',
|
|
'shouldRetryDirectUpload',
|
|
'finalizeDirectUpload',
|
|
'uploadLegacyArtifact',
|
|
),
|
|
// The prepared artifact whose fields (sha256, sizeBytes, fileName,
|
|
// artifactType, contentType) ARE the preflight body the daemon parses.
|
|
...from('src/remote/upload-client-artifact.ts', 'PreparedUploadArtifact'),
|
|
// Consumer side of the resumable 308 contract. Listing the daemon's
|
|
// `handleResumableUpload` proves it still PRODUCES 308; it says nothing
|
|
// about the client still CONSUMING the released one. These own which
|
|
// offset headers are accepted (`x-upload-offset`, `upload-offset`,
|
|
// `Range: bytes=0-N`) and what `Content-Range` a resumed PUT emits.
|
|
//
|
|
// `streamFileToHttpRequestAttempt` is listed despite its size, unlike
|
|
// `createDaemonHttpServer` above: that one only dispatches to handlers
|
|
// that are each digested, while this IS the resume state machine — it
|
|
// decides whether a 308 continues the upload and what the next request
|
|
// carries, so a change to its sequencing alone can break a released
|
|
// daemon while every helper below keeps its digest.
|
|
...from(
|
|
UPLOAD_STREAM,
|
|
'MAX_UPLOAD_REDIRECTS',
|
|
'UploadStreamResponse',
|
|
'streamFileToHttpRequest',
|
|
'streamFileToHttpRequestAttempt',
|
|
'buildUploadRequestHeaders',
|
|
'isUploadRedirectStatus',
|
|
'isUploadResumeStatus',
|
|
'parseUploadResumeOffset',
|
|
'parseNonNegativeIntegerHeader',
|
|
'firstHeaderValue',
|
|
),
|
|
// Consumer side of /artifacts/*: URL construction, the inventory/header/
|
|
// body materialization the client performs, and the artifact fields it
|
|
// rewrites on the way through.
|
|
...from(
|
|
REMOTE_ARTIFACTS,
|
|
'DaemonArtifactEndpoint',
|
|
'buildDaemonArtifactUrl',
|
|
'isRemoteDaemon',
|
|
'DownloadRemoteArtifactParams',
|
|
'downloadRemoteArtifact',
|
|
'materializeRemoteArtifacts',
|
|
'resolveMaterializedArtifactPath',
|
|
),
|
|
...from(ARTIFACT_DOWNLOAD, 'RemoteArtifactDownload', 'downloadRemoteArtifactFromUrl'),
|
|
],
|
|
},
|
|
];
|
|
|
|
/** Stable ledger key for a declaration: `<file>#<name>`. */
|
|
export function wireDeclarationKey(ref: WireDeclarationRef): string {
|
|
return `${ref.file}#${ref.name}`;
|
|
}
|
|
|
|
export const WIRE_DECLARATIONS: readonly WireDeclarationRef[] = WIRE_SURFACE.flatMap(
|
|
(group) => group.declarations,
|
|
);
|
|
|
|
/** Files the manifest draws declarations from. */
|
|
export const WIRE_SURFACE_FILES: readonly string[] = [
|
|
...new Set(WIRE_DECLARATIONS.map((ref) => ref.file)),
|
|
].sort();
|