From fab7deae9b505d3adab519074e53909d5c66ea04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 00:17:42 +0200 Subject: [PATCH 1/2] refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon Move the pure wire vocabulary (base path, header names, URL/auth/tenant builders, /health payload) from src/daemon into @agent-device/contracts as the daemon-http subpath, so src/remote and src/cli stop importing daemon server internals. buildDaemonHealthPayload takes the version its caller advertises (R18 keeps host mechanics out of contracts); both callers pass readVersion(). Wire-compat surface, mutation, and ledger references follow the package path. --- .../contracts/src/daemon-http.test.ts | 23 ++++++- packages/contracts/src/daemon-http.ts | 61 +++++++++++++++++++ .../proxy-client-support.ts | 2 +- src/__tests__/daemon-proxy.test.ts | 4 +- src/cli/commands/proxy.ts | 2 +- .../http-server-limrun-uploaded-apps.test.ts | 2 +- .../http-server-tenant-trust.test.ts | 2 +- .../client/__tests__/daemon-client.test.ts | 2 +- src/daemon/client/daemon-client-transport.ts | 7 ++- src/daemon/http-contract.ts | 28 --------- src/daemon/http-health.ts | 28 --------- src/daemon/server/http-server.ts | 7 ++- src/remote/artifact-download.ts | 2 +- src/remote/daemon-proxy.ts | 9 ++- src/remote/remote-request-diagnostics.ts | 2 +- src/remote/upload-client.ts | 2 +- .../daemon-http-server.test.ts | 2 +- test/integration/smoke-daemon-http.test.ts | 2 +- test/wire-compat/README.md | 2 +- test/wire-compat/surface.ts | 14 ++--- test/wire-compat/wire-compat.test.ts | 2 +- test/wire-compat/wire-mutations.test.ts | 2 +- 22 files changed, 117 insertions(+), 90 deletions(-) rename src/daemon/__tests__/http-contract.test.ts => packages/contracts/src/daemon-http.test.ts (68%) create mode 100644 packages/contracts/src/daemon-http.ts delete mode 100644 src/daemon/http-contract.ts delete mode 100644 src/daemon/http-health.ts diff --git a/src/daemon/__tests__/http-contract.test.ts b/packages/contracts/src/daemon-http.test.ts similarity index 68% rename from src/daemon/__tests__/http-contract.test.ts rename to packages/contracts/src/daemon-http.test.ts index 5c37240674..93273d5b66 100644 --- a/src/daemon/__tests__/http-contract.test.ts +++ b/packages/contracts/src/daemon-http.test.ts @@ -1,11 +1,13 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { + buildDaemonHealthPayload, buildDaemonHttpAuthHeaders, buildDaemonHttpBaseUrl, buildDaemonHttpTenantHeaders, buildDaemonHttpUrl, -} from '../http-contract.ts'; + DAEMON_RPC_PROTOCOL_VERSION, +} from './daemon-http.ts'; test('buildDaemonHttpBaseUrl appends the public agent-device base path', () => { assert.equal( @@ -43,3 +45,22 @@ test('buildDaemonHttpTenantHeaders omits blank tenant identities', () => { }); assert.deepEqual(buildDaemonHttpTenantHeaders(''), {}); }); + +test('buildDaemonHealthPayload takes the version from its caller and keeps the payload shape', () => { + assert.deepEqual(buildDaemonHealthPayload('agent-device-daemon', '0.20.9'), { + ok: true, + service: 'agent-device-daemon', + version: '0.20.9', + rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + }); + assert.deepEqual( + buildDaemonHealthPayload('agent-device-proxy', '0.20.9', { upstream: { ok: true } }), + { + ok: true, + service: 'agent-device-proxy', + version: '0.20.9', + rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + upstream: { ok: true }, + }, + ); +}); diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts new file mode 100644 index 0000000000..b4965329ce --- /dev/null +++ b/packages/contracts/src/daemon-http.ts @@ -0,0 +1,61 @@ +// The daemon HTTP wire vocabulary shared by the daemon server, the remote +// proxy, and every client that talks to them: the base path, the tenant and +// network-access header names, the URL/auth/tenant header builders, and the +// /health payload. Client and server must agree on all of it, so neither side +// owns it (ADR 0006). +export const DAEMON_HTTP_BASE_PATH = '/agent-device'; +export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant'; +export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access'; +export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only'; + +export function buildDaemonHttpBaseUrl(baseUrl: string): string { + return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH); +} + +export function buildDaemonHttpUrl(baseUrl: string, route: string): string { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + return new URL(route.replace(/^\/+/, ''), normalizedBase).toString(); +} + +export function buildDaemonHttpAuthHeaders(token: string | undefined): Record { + const normalizedToken = token?.trim(); + if (!normalizedToken) return {}; + return { + authorization: `Bearer ${normalizedToken}`, + 'x-agent-device-token': normalizedToken, + }; +} + +export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Record { + const normalizedTenantId = tenantId?.trim(); + if (!normalizedTenantId) return {}; + return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId }; +} + +// See docs/adr/0006-daemon-rpc-protocol-version.md before changing this value. +// Enforced, not just documented: `test/wire-compat/` digests the declarations +// that cross this boundary and fails when one changes shape without a bump or +// an acknowledged-compatible entry (#1432). +export const DAEMON_RPC_PROTOCOL_VERSION = 2; + +export type DaemonHealthPayload = { + ok: true; + service: 'agent-device-daemon' | 'agent-device-proxy'; + version: string; + rpcProtocolVersion: number; + upstream?: unknown; +}; + +export function buildDaemonHealthPayload( + service: DaemonHealthPayload['service'], + version: string, + options: { upstream?: unknown } = {}, +): DaemonHealthPayload { + return { + ok: true, + service, + version, + rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + ...(options.upstream !== undefined ? { upstream: options.upstream } : {}), + }; +} diff --git a/scripts/ios-snapshot-benchmark/proxy-client-support.ts b/scripts/ios-snapshot-benchmark/proxy-client-support.ts index 426dc027dd..6d8c1b9de2 100644 --- a/scripts/ios-snapshot-benchmark/proxy-client-support.ts +++ b/scripts/ios-snapshot-benchmark/proxy-client-support.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import { performance } from 'node:perf_hooks'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { buildDaemonHttpBaseUrl } from '../../src/daemon/http-contract.ts'; +import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; import { BenchmarkCellAdmissionError, BenchmarkContentionError, diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts index 6ae2ed85bb..6471b9526c 100644 --- a/src/__tests__/daemon-proxy.test.ts +++ b/src/__tests__/daemon-proxy.test.ts @@ -8,8 +8,8 @@ import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-scri import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, -} from '../daemon/http-contract.ts'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts'; + DAEMON_RPC_PROTOCOL_VERSION, +} from '@agent-device/contracts/daemon-http'; import { closeLoopbackServer, listenOnLoopback, diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index e7c1c01b15..0e17fde13c 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '../../remote/daemon-proxy.ts'; -import { buildDaemonHttpBaseUrl } from '../../daemon/http-contract.ts'; +import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; import { ensureDaemon, resolveClientSettings, diff --git a/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts b/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts index 44586b3520..ecf5ef0dff 100644 --- a/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts +++ b/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts @@ -15,7 +15,7 @@ import { limrunTestDependencies } from '../../platform-runtime-gateway.fixtures. import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, -} from '../http-contract.ts'; +} from '@agent-device/contracts/daemon-http'; import { LeaseRegistry } from '../lease-registry.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; diff --git a/src/daemon/__tests__/http-server-tenant-trust.test.ts b/src/daemon/__tests__/http-server-tenant-trust.test.ts index 47282b58d6..c5f829994b 100644 --- a/src/daemon/__tests__/http-server-tenant-trust.test.ts +++ b/src/daemon/__tests__/http-server-tenant-trust.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { createDaemonHttpServer } from '../server/http-server.ts'; import { resolveSessionRequestLogPath } from '../session-store.ts'; import { safeSessionName } from '../session-paths.ts'; -import { DAEMON_HTTP_TENANT_HEADER } from '../http-contract.ts'; +import { DAEMON_HTTP_TENANT_HEADER } from '@agent-device/contracts/daemon-http'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { closeLoopbackServer, diff --git a/src/daemon/client/__tests__/daemon-client.test.ts b/src/daemon/client/__tests__/daemon-client.test.ts index 53475ad39a..c5995f7cf8 100644 --- a/src/daemon/client/__tests__/daemon-client.test.ts +++ b/src/daemon/client/__tests__/daemon-client.test.ts @@ -26,7 +26,7 @@ import { resolveDaemonStartupHint, } from '../daemon-client-metadata.ts'; import { canConnectSocket } from '../daemon-client-transport.ts'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../../http-health.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { resolveDaemonRequestTimeoutMs, resolveRequestTimeoutHint, diff --git a/src/daemon/client/daemon-client-transport.ts b/src/daemon/client/daemon-client-transport.ts index ecc7c13c99..ba68466ee9 100644 --- a/src/daemon/client/daemon-client-transport.ts +++ b/src/daemon/client/daemon-client-transport.ts @@ -10,11 +10,14 @@ import { readDaemonSocketProgressResponse, shouldReadDaemonProgressStream, } from './daemon-client-progress.ts'; -import { buildDaemonHttpAuthHeaders, buildDaemonHttpUrl } from '../http-contract.ts'; +import { + buildDaemonHttpAuthHeaders, + buildDaemonHttpUrl, + DAEMON_RPC_PROTOCOL_VERSION, +} from '@agent-device/contracts/daemon-http'; import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; import { handleRequestTimeout } from './daemon-client-timeout.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../http-health.ts'; import { readVersion } from '@agent-device/host-kit/version'; type ResolvedDaemonTransport = 'socket' | 'http'; diff --git a/src/daemon/http-contract.ts b/src/daemon/http-contract.ts deleted file mode 100644 index 7ab89f0337..0000000000 --- a/src/daemon/http-contract.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const DAEMON_HTTP_BASE_PATH = '/agent-device'; -export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant'; -export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access'; -export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only'; - -export function buildDaemonHttpBaseUrl(baseUrl: string): string { - return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH); -} - -export function buildDaemonHttpUrl(baseUrl: string, route: string): string { - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - return new URL(route.replace(/^\/+/, ''), normalizedBase).toString(); -} - -export function buildDaemonHttpAuthHeaders(token: string | undefined): Record { - const normalizedToken = token?.trim(); - if (!normalizedToken) return {}; - return { - authorization: `Bearer ${normalizedToken}`, - 'x-agent-device-token': normalizedToken, - }; -} - -export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Record { - const normalizedTenantId = tenantId?.trim(); - if (!normalizedTenantId) return {}; - return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId }; -} diff --git a/src/daemon/http-health.ts b/src/daemon/http-health.ts deleted file mode 100644 index 7c07183695..0000000000 --- a/src/daemon/http-health.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readVersion } from '@agent-device/host-kit/version'; - -// See docs/adr/0006-daemon-rpc-protocol-version.md before changing this value. -// Enforced, not just documented: `test/wire-compat/` digests the declarations -// that cross this boundary and fails when one changes shape without a bump or -// an acknowledged-compatible entry (#1432). -export const DAEMON_RPC_PROTOCOL_VERSION = 2; - -export type DaemonHealthPayload = { - ok: true; - service: 'agent-device-daemon' | 'agent-device-proxy'; - version: string; - rpcProtocolVersion: number; - upstream?: unknown; -}; - -export function buildDaemonHealthPayload( - service: DaemonHealthPayload['service'], - options: { upstream?: unknown } = {}, -): DaemonHealthPayload { - return { - ok: true, - service, - version: readVersion(), - rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, - ...(options.upstream !== undefined ? { upstream: options.upstream } : {}), - }; -} diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 1d28a75651..008b4a4b3b 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -32,12 +32,13 @@ import { serializeDaemonRpcResponseEnvelope, shouldStreamRequestProgress, } from '../request-progress-protocol.ts'; -import { buildDaemonHealthPayload } from '../http-health.ts'; import { + buildDaemonHealthPayload, DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, DAEMON_HTTP_TENANT_HEADER, -} from '../http-contract.ts'; +} from '@agent-device/contracts/daemon-http'; +import { readVersion } from '@agent-device/host-kit/version'; import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts'; import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; @@ -577,7 +578,7 @@ export async function createDaemonHttpServer(options: { if (req.method === 'GET' && req.url === '/health') { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(buildDaemonHealthPayload('agent-device-daemon'))); + res.end(JSON.stringify(buildDaemonHealthPayload('agent-device-daemon', readVersion()))); return; } diff --git a/src/remote/artifact-download.ts b/src/remote/artifact-download.ts index 47473564a2..eb42f4fc24 100644 --- a/src/remote/artifact-download.ts +++ b/src/remote/artifact-download.ts @@ -10,7 +10,7 @@ import type { DaemonRequestMeta } from '@agent-device/kernel/contracts'; import { buildDaemonHttpAuthHeaders, buildDaemonHttpTenantHeaders, -} from '../daemon/http-contract.ts'; +} from '@agent-device/contracts/daemon-http'; const REMOTE_ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000; diff --git a/src/remote/daemon-proxy.ts b/src/remote/daemon-proxy.ts index a3548da999..a460e6eb9f 100644 --- a/src/remote/daemon-proxy.ts +++ b/src/remote/daemon-proxy.ts @@ -5,14 +5,15 @@ import { randomUUID } from 'node:crypto'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { readNodeHttpRequestBody, timingSafeStringEqual } from '@agent-device/host-kit/transport'; import { + buildDaemonHealthPayload, DAEMON_HTTP_BASE_PATH, DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, DAEMON_HTTP_TENANT_HEADER, buildDaemonHttpAuthHeaders, buildDaemonHttpUrl, -} from '../daemon/http-contract.ts'; -import { buildDaemonHealthPayload } from '../daemon/http-health.ts'; +} from '@agent-device/contracts/daemon-http'; +import { readVersion } from '@agent-device/host-kit/version'; import { carriesUnbackedHostPathInstallSource, sendHostPathInstallSourceRefused, @@ -97,7 +98,9 @@ async function sendProxyHealth(res: ServerResponse, options: Required): Promise { diff --git a/src/remote/remote-request-diagnostics.ts b/src/remote/remote-request-diagnostics.ts index 8affca9078..6caf4fe59d 100644 --- a/src/remote/remote-request-diagnostics.ts +++ b/src/remote/remote-request-diagnostics.ts @@ -25,7 +25,7 @@ import { buildDaemonHttpAuthHeaders, buildDaemonHttpTenantHeaders, buildDaemonHttpUrl, -} from '../daemon/http-contract.ts'; +} from '@agent-device/contracts/daemon-http'; import { resolveRemoteRequestDiagnosticsPath } from '../daemon/session-store.ts'; const REMOTE_DIAGNOSTICS_FETCH_TIMEOUT_MS = 10_000; diff --git a/src/remote/upload-client.ts b/src/remote/upload-client.ts index caa8c6c422..25232764d6 100644 --- a/src/remote/upload-client.ts +++ b/src/remote/upload-client.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { AppError } from '@agent-device/kernel/errors'; -import { buildDaemonHttpAuthHeaders } from '../daemon/http-contract.ts'; +import { buildDaemonHttpAuthHeaders } from '@agent-device/contracts/daemon-http'; import { prepareUploadArtifact, type PreparedUploadArtifact } from './upload-client-artifact.ts'; import { isRetryableUploadStreamError, streamFileToHttpRequest } from './upload-stream.ts'; import type { UploadProgressSink } from './upload-progress.ts'; diff --git a/test/integration/provider-scenarios/daemon-http-server.test.ts b/test/integration/provider-scenarios/daemon-http-server.test.ts index 576432d81f..2fab8df73f 100644 --- a/test/integration/provider-scenarios/daemon-http-server.test.ts +++ b/test/integration/provider-scenarios/daemon-http-server.test.ts @@ -11,7 +11,7 @@ import { prepareUploadedArtifact, trackDownloadableArtifact, } from '../../../src/daemon/artifact-tracking.ts'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../../../src/daemon/http-health.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; import { emitRequestProgress, diff --git a/test/integration/smoke-daemon-http.test.ts b/test/integration/smoke-daemon-http.test.ts index 53a7e941c6..ce112f8b53 100644 --- a/test/integration/smoke-daemon-http.test.ts +++ b/test/integration/smoke-daemon-http.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts'; import { formatResultDebug } from './cli-json.ts'; diff --git a/test/wire-compat/README.md b/test/wire-compat/README.md index 25d36533d8..9c12ce9856 100644 --- a/test/wire-compat/README.md +++ b/test/wire-compat/README.md @@ -60,7 +60,7 @@ It names the declaration and prints the digest to paste. Decide which ADR 0006 c clearly. Removing wire surface is always this case; an ack cannot cover a removal, because a released peer can still send it. -1. Bump `DAEMON_RPC_PROTOCOL_VERSION` (`src/daemon/http-health.ts`). +1. Bump `DAEMON_RPC_PROTOCOL_VERSION` (`packages/contracts/src/daemon-http.ts`). 2. Set `ledger.json`'s `protocolVersion` to match, and paste the new digests. 3. ADR 0006 also wants a remote-client regression test proving mismatched protocols fail before command RPC. diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index 6e45a744df..540cf672f0 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -42,8 +42,7 @@ 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 HTTP_CONTRACT = 'src/daemon/http-contract.ts'; -const HTTP_HEALTH = 'src/daemon/http-health.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'; @@ -68,13 +67,8 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ { adrBullet: 'HTTP route requirements for /health, /rpc, /upload, or /artifacts/*.', declarations: [ - ...from( - HTTP_CONTRACT, - 'DAEMON_HTTP_BASE_PATH', - 'buildDaemonHttpUrl', - 'buildDaemonHttpBaseUrl', - ), - ...from(HTTP_HEALTH, 'DaemonHealthPayload', 'buildDaemonHealthPayload'), + ...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'), @@ -134,7 +128,7 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ adrBullet: 'Authentication semantics required to authorize RPC, upload, or artifact requests.', declarations: [ ...from( - HTTP_CONTRACT, + DAEMON_HTTP, 'buildDaemonHttpAuthHeaders', 'DAEMON_HTTP_TENANT_HEADER', 'buildDaemonHttpTenantHeaders', diff --git a/test/wire-compat/wire-compat.test.ts b/test/wire-compat/wire-compat.test.ts index d2c1324517..ab1351b65e 100644 --- a/test/wire-compat/wire-compat.test.ts +++ b/test/wire-compat/wire-compat.test.ts @@ -17,7 +17,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { isExternalWireSpecifier, WIRE_CLOSURE_WAIVERS } from './closure-policy.ts'; import { findClosureGaps } from './closure.ts'; import { digestDeclaration } from './declaration-digest.ts'; diff --git a/test/wire-compat/wire-mutations.test.ts b/test/wire-compat/wire-mutations.test.ts index 6337001dca..2ec8bf4ca2 100644 --- a/test/wire-compat/wire-mutations.test.ts +++ b/test/wire-compat/wire-mutations.test.ts @@ -86,7 +86,7 @@ const MUTATIONS: readonly WireMutation[] = [ }, { breakClass: 'auth projection: the client stops sending the bearer form', - file: 'src/daemon/http-contract.ts', + file: 'packages/contracts/src/daemon-http.ts', name: 'buildDaemonHttpAuthHeaders', from: 'authorization: `Bearer ${normalizedToken}`,', to: '', From 2398c78b02420ead256a0ac4e40a6e8f555679f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 00:18:52 +0200 Subject: [PATCH 2/2] chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves Exports map + snapshot gain the daemon-http subpath. The wire ledger re-keys the eight moved declarations (buildDaemonHealthPayload moves with its new caller-supplied version parameter, acked additive). The released-baseline comparison now classifies a baseline declaration that re-appears unchanged at exactly one new path as a move instead of a removal: a file move is not wire surface a released peer stopped sending. A move that changes shape is a change acked at the destination path, and a name still owned by the baseline stays a removal. --- packages/contracts/package.json | 4 + .../layering/contracts-exports.snapshot.json | 1 + scripts/wire-compat/model.test.ts | 125 ++++++++++++++++++ scripts/wire-compat/model.ts | 108 ++++++++++++++- scripts/wire-compat/run.ts | 2 +- test/wire-compat/ledger.json | 23 ++-- 6 files changed, 247 insertions(+), 16 deletions(-) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 979e43600c..2e80568bea 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -163,6 +163,10 @@ "types": "./src/command-platform-execution.ts", "default": "./src/command-platform-execution.ts" }, + "./daemon-http": { + "types": "./src/daemon-http.ts", + "default": "./src/daemon-http.ts" + }, "./daemon-owner-cleanup": { "types": "./src/daemon-owner-cleanup.ts", "default": "./src/daemon-owner-cleanup.ts" diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 65147a854f..8a04d0ce76 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -37,6 +37,7 @@ "@agent-device/contracts/clipboard-runtime", "@agent-device/contracts/command", "@agent-device/contracts/command-platform-execution", + "@agent-device/contracts/daemon-http", "@agent-device/contracts/daemon-owner-cleanup", "@agent-device/contracts/device", "@agent-device/contracts/device-readiness-runtime", diff --git a/scripts/wire-compat/model.test.ts b/scripts/wire-compat/model.test.ts index 4b7972c086..08eca39004 100644 --- a/scripts/wire-compat/model.test.ts +++ b/scripts/wire-compat/model.test.ts @@ -112,6 +112,131 @@ test('a removed wire declaration passes with a bump', () => { assert.deepEqual(result.failures, []); }); +// #2318 moved the daemon HTTP wire contract from src/daemon to +// packages/contracts. A declaration that left its path but was re-declared +// unchanged is a file move, not a removal: a released peer still parses it, +// so it must not force a protocol bump. +test('a declaration moved to a new file unchanged is a move, not a removal', () => { + const movedFrom = 'src/daemon/http-contract.ts#buildDaemonHttpUrl'; + const movedTo = 'packages/contracts/src/daemon-http.ts#buildDaemonHttpUrl'; + const released = ledger({ + declarations: { [movedFrom]: 'sha256:ddd', [META]: 'sha256:bbb' }, + }); + const current = ledger({ declarations: { [movedTo]: 'sha256:ddd', [META]: 'sha256:bbb' } }); + const result = compareWireLedgers({ + baselineTag: 'v0.20.6', + released, + current, + digests: new Map(Object.entries({ [movedTo]: 'sha256:ddd', [META]: 'sha256:bbb' })), + }); + assert.deepEqual(result.moved, [movedFrom]); + assert.deepEqual(result.removed, []); + assert.deepEqual(result.added, []); + assert.deepEqual(result.failures, []); +}); + +// A baseline key that leaves and a same-named key that arrives with a moved +// digest are textually indistinguishable, so the gate reads the pair as a +// CHANGE at the destination (ackable at the new digest) rather than the +// bump-only removal the key-pair alone would suggest. +test('a move that changes the shape is a change, acked at the path it moved to', () => { + const movedFrom = 'src/daemon/http-health.ts#buildDaemonHealthPayload'; + const movedTo = 'packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload'; + const released = ledger({ declarations: { [movedFrom]: 'sha256:old' } }); + const current = ledger({ + declarations: { [movedTo]: 'sha256:new' }, + compatibleChanges: [ + { + declaration: movedTo, + digest: 'sha256:new', + rationale: 'The added parameter is caller-local; the wire payload is unchanged.', + }, + ], + }); + const digests = new Map(Object.entries({ [movedTo]: 'sha256:new' })); + const withoutAck = compareWireLedgers({ + baselineTag: 'v0.20.6', + released, + current: ledger({ declarations: { [movedTo]: 'sha256:new' } }), + digests, + }); + assert.deepEqual(withoutAck.changed, [movedTo]); + assert.deepEqual(withoutAck.removed, []); + assert.equal(withoutAck.failures.length, 1); + const withAck = compareWireLedgers({ baselineTag: 'v0.20.6', released, current, digests }); + assert.deepEqual(withAck.failures, []); +}); + +// Names are not unique across files: while a baseline declaration still owns +// the name at its own path, a same-named declaration elsewhere cannot be +// identified as a move of this one, so the baseline key stays a removal. +test('a name still owned by the baseline is not a move, so it remains a removal', () => { + const serverSendJson = 'src/daemon/server/http-server.ts#sendJson'; + const uploadSendJson = 'src/daemon/upload-http.ts#sendJson'; + const released = ledger({ + declarations: { [serverSendJson]: 'sha256:old', [uploadSendJson]: 'sha256:eee' }, + }); + const current = ledger({ declarations: { [uploadSendJson]: 'sha256:eee' } }); + const result = compareWireLedgers({ + baselineTag: 'v0.20.6', + released, + current, + digests: new Map(Object.entries({ [uploadSendJson]: 'sha256:eee' })), + }); + assert.deepEqual(result.removed, [serverSendJson]); + assert.deepEqual(result.moved, []); + assert.equal(result.failures.length, 1); +}); + +// One destination cannot be two declarations' move. When two same-name +// baseline declarations leave their paths and one same-name path arrives, one +// of them is a real removal, and a removal is bump-only. +test('two same-name removals cannot share one move destination, so both stay removals', () => { + const first = 'src/a.ts#sendJson'; + const second = 'src/b.ts#sendJson'; + const destination = 'src/c.ts#sendJson'; + const released = ledger({ declarations: { [first]: 'sha256:ddd', [second]: 'sha256:ddd' } }); + const current = ledger({ declarations: { [destination]: 'sha256:ddd' } }); + const result = compareWireLedgers({ + baselineTag: 'v0.20.6', + released, + current, + digests: new Map(Object.entries({ [destination]: 'sha256:ddd' })), + }); + assert.deepEqual(result.removed, [first, second]); + assert.deepEqual(result.moved, []); + // The destination is a key the baseline never had, so it is still reported + // as added alongside the removal that fails the gate. + assert.deepEqual(result.added, [destination]); + assert.equal(result.failures.length, 1); +}); + +// The contested-destination rule holds even when the arriving digest matches +// one of the sources and an ack sits at the destination: the other source's +// removal still cannot be covered. +test('a contested destination fails even when acked at the destination', () => { + const first = 'src/a.ts#sendJson'; + const second = 'src/b.ts#sendJson'; + const destination = 'src/c.ts#sendJson'; + const released = ledger({ declarations: { [first]: 'sha256:aaa', [second]: 'sha256:old' } }); + const current = ledger({ + declarations: { [destination]: 'sha256:new' }, + compatibleChanges: [ + { declaration: destination, digest: 'sha256:new', rationale: 'One of them moved here.' }, + ], + }); + const result = compareWireLedgers({ + baselineTag: 'v0.20.6', + released, + current, + digests: new Map(Object.entries({ [destination]: 'sha256:new' })), + }); + assert.deepEqual(result.removed, [first, second]); + assert.deepEqual(result.moved, []); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0]!, /an ack cannot cover it/); +}); + test('a newly added wire declaration is additive and needs nothing', () => { const added = 'packages/kernel/src/contracts.ts#NewEnvelope'; const current = ledger({ diff --git a/scripts/wire-compat/model.ts b/scripts/wire-compat/model.ts index e1ddc42c99..c7861eb952 100644 --- a/scripts/wire-compat/model.ts +++ b/scripts/wire-compat/model.ts @@ -29,7 +29,16 @@ export type WireComparison = { changed: readonly string[]; /** Declarations the baseline had and the current wire surface does not. */ removed: readonly string[]; - /** Declarations added since the baseline; additive, so never a failure. */ + /** + * Baseline declarations that left their path but were re-declared unchanged + * (same name, same digest) at a single new path that no other left + * declaration also claims: a file move, never a failure. + */ + moved: readonly string[]; + /** + * Current keys the baseline never had (pure-move destinations excepted — + * their source is reported in `moved` instead). Additive, so never a failure. + */ added: readonly string[]; /** Whether the protocol version advanced since the baseline. */ bumped: boolean; @@ -43,12 +52,20 @@ export function compareWireLedgers(input: WireComparisonInput): WireComparison { const changed: string[] = []; const removed: string[] = []; + const moved: string[] = []; + const movedDestinations = new Set(); + const moveDestination = soleMoveDestinations(released.declarations, digests); for (const [key, releasedDigest] of Object.entries(released.declarations)) { - const digest = digests.get(key); - if (digest === undefined) removed.push(key); - else if (digest !== releasedDigest) changed.push(key); + const fate = baselineKeyFate(key, releasedDigest, digests, moveDestination); + if (fate.kind === 'changed') changed.push(fate.reportKey); + else if (fate.kind === 'moved') { + moved.push(key); + movedDestinations.add(fate.destination); + } else if (fate.kind === 'removed') removed.push(key); } - const added = Object.keys(current.declarations).filter((key) => !(key in released.declarations)); + const added = Object.keys(current.declarations).filter( + (key) => !(key in released.declarations) && !movedDestinations.has(key), + ); const failures: string[] = []; const stillAt = `still ${current.protocolVersion}`; @@ -85,5 +102,84 @@ export function compareWireLedgers(input: WireComparisonInput): WireComparison { } } - return { changed, removed, added, bumped, failures }; + return { changed, removed, moved, added, bumped, failures }; +} + +type BaselineFate = + | { kind: 'unchanged' } + | { kind: 'changed'; reportKey: string } + | { kind: 'moved'; destination: string } + | { kind: 'removed' }; + +/** What a baseline declaration became in the current surface. */ +function baselineKeyFate( + key: string, + releasedDigest: string, + digests: ReadonlyMap, + moveDestination: ReadonlyMap, +): BaselineFate { + const digest = digests.get(key); + if (digest !== undefined) { + return digest === releasedDigest ? { kind: 'unchanged' } : { kind: 'changed', reportKey: key }; + } + const destination = moveDestination.get(key); + if (destination === undefined) return { kind: 'removed' }; + return digests.get(destination) === releasedDigest + ? { kind: 'moved', destination } + : { kind: 'changed', reportKey: destination }; +} + +/** + * Displaced baseline declarations mapped to the single new path they may have + * moved to — or nothing when the move cannot be identified. + * + * A same-name re-declaration at exactly one new path is a file move, which a + * released peer still parses. One destination cannot be two declarations' + * moves, though: when two same-name baseline declarations left their paths and + * only one same-name new path exists, the other declaration's loss is real and + * only a bump covers it, so the contested destination resolves to removals. A + * same-name re-declaration whose digest MOVED is a change at the destination, + * ackable (digest-pinned, rationale required) rather than bump-forcing: + * textually it is indistinguishable from a removal plus a new same-named + * declaration, and that reading gets the ack escape hatch. Candidate paths are + * limited to ones absent from the baseline: names are not unique across files + * (two files both declare `sendJson`), and a name a baseline declaration still + * owns at its own path cannot identify a move. + */ +function soleMoveDestinations( + releasedDeclarations: Record, + digests: ReadonlyMap, +): ReadonlyMap { + const releasedKeys = new Set(Object.keys(releasedDeclarations)); + const candidates = new Map(); + for (const key of releasedKeys) { + if (digests.has(key)) continue; + const name = declarationName(key); + candidates.set( + key, + [...digests.keys()].filter( + (candidate) => declarationName(candidate) === name && !releasedKeys.has(candidate), + ), + ); + } + const claimCount = new Map(); + for (const matches of candidates.values()) { + if (matches.length === 1) { + const destination = matches[0]!; + claimCount.set(destination, (claimCount.get(destination) ?? 0) + 1); + } + } + const sole = new Map(); + for (const [key, matches] of candidates) { + if (matches.length === 1 && claimCount.get(matches[0]!) === 1) { + sole.set(key, matches[0]!); + } + } + return sole; +} + +/** The declaration name in a `#` key. */ +function declarationName(key: string): string { + const separator = key.lastIndexOf('#'); + return separator >= 0 ? key.slice(separator + 1) : key; } diff --git a/scripts/wire-compat/run.ts b/scripts/wire-compat/run.ts index ff5f86112e..93adef726c 100644 --- a/scripts/wire-compat/run.ts +++ b/scripts/wire-compat/run.ts @@ -88,5 +88,5 @@ process.stdout.write( `Daemon RPC wire surface checked against ${baseline.tag} ` + `(protocol ${result.bumped ? 'bumped' : 'unchanged'}): ${WIRE_DECLARATIONS.length} ` + `declarations, ${result.changed.length} changed, ${result.removed.length} removed, ` + - `${result.added.length} added.\n`, + `${result.added.length} added, ${result.moved.length} moved.\n`, ); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 9d186e6528..2b8f67cbb6 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -1,6 +1,14 @@ { "protocolVersion": 2, "declarations": { + "packages/contracts/src/daemon-http.ts#DAEMON_HTTP_BASE_PATH": "sha256:a1ada25c6f90d9c69c8c836538e8882547bf239559f7c1accacd0654355802dd", + "packages/contracts/src/daemon-http.ts#DAEMON_HTTP_TENANT_HEADER": "sha256:ed49119d232500885f014ac73f6123d298d404c6c176abdae59cf324825927e5", + "packages/contracts/src/daemon-http.ts#DaemonHealthPayload": "sha256:050650184ad3e61d9359bf5866e7fc1e15e13f14f736a8c92ca57cad6d388967", + "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload": "sha256:709899c02b2a5f6ec2a995350ac7b5791d67db12a631b109af5074427291edda", + "packages/contracts/src/daemon-http.ts#buildDaemonHttpAuthHeaders": "sha256:5548a44d6248ed858d19a0b2985ec4ae8a7181bae8294b85edf3c1b3b58e80d4", + "packages/contracts/src/daemon-http.ts#buildDaemonHttpBaseUrl": "sha256:f92697208d9f42ec0dcfb006b6f2dce761bd5307db27ce2e52927fd7742e3a9a", + "packages/contracts/src/daemon-http.ts#buildDaemonHttpTenantHeaders": "sha256:e38a5f0b5ab07ee3a5fe3db229d0de750888660c55bf31692e100002be96de1d", + "packages/contracts/src/daemon-http.ts#buildDaemonHttpUrl": "sha256:d38f1f8877588fbcb1fea8c52a5cb7dc205043e3fc698b3d78fdcf821fa80471", "packages/contracts/src/request-progress.ts#CommandProgressEvent": "sha256:2dc6ff7f3b619d9b917210d47ebf9eee8095563389c8956b4e9f2bd994e1942d", "packages/contracts/src/request-progress.ts#ReplayTestProgressEvent": "sha256:61df00308d0e21f2acfc4f73cb2c07cd0c251e827f1c87628e2d08338e1b5c84", "packages/contracts/src/request-progress.ts#ReplayTestSuiteProgressEvent": "sha256:2d6599bfcf1e94596c61f85a8a986874b59b8c05e82e5e00dd31e381a5b0fe84", @@ -64,18 +72,10 @@ "src/daemon/downloadable-artifact-http.ts#readArtifactId": "sha256:3472ee52d951fdf7ef00ee500298602c68f0e1e3b8c44ff51b09981efabf348c", "src/daemon/downloadable-artifact-http.ts#readRequestPathname": "sha256:6b3a49c72365cf7da4552abef59862c073461f746025e28d409048815dd586f2", "src/daemon/downloadable-artifact-http.ts#resolveDownloadableArtifactHttpRoute": "sha256:9fcdc9619e8d378e8f04ef7b3584863af446e855702ddce83a16ec194a6d98af", - "src/daemon/http-contract.ts#DAEMON_HTTP_BASE_PATH": "sha256:a1ada25c6f90d9c69c8c836538e8882547bf239559f7c1accacd0654355802dd", - "src/daemon/http-contract.ts#DAEMON_HTTP_TENANT_HEADER": "sha256:ed49119d232500885f014ac73f6123d298d404c6c176abdae59cf324825927e5", - "src/daemon/http-contract.ts#buildDaemonHttpAuthHeaders": "sha256:5548a44d6248ed858d19a0b2985ec4ae8a7181bae8294b85edf3c1b3b58e80d4", - "src/daemon/http-contract.ts#buildDaemonHttpBaseUrl": "sha256:f92697208d9f42ec0dcfb006b6f2dce761bd5307db27ce2e52927fd7742e3a9a", - "src/daemon/http-contract.ts#buildDaemonHttpTenantHeaders": "sha256:e38a5f0b5ab07ee3a5fe3db229d0de750888660c55bf31692e100002be96de1d", - "src/daemon/http-contract.ts#buildDaemonHttpUrl": "sha256:d38f1f8877588fbcb1fea8c52a5cb7dc205043e3fc698b3d78fdcf821fa80471", "src/daemon/http-errors.ts#NormalizedHttpError": "sha256:2c2111802fe5f193acbeacc454de9cfab82bc8e4274b896f485e19a14f5518cf", "src/daemon/http-errors.ts#failStreamedHttpResponse": "sha256:7199933eca7244bc801e0601b17cc1aed584bc2353879830fd0686e99f1200c4", "src/daemon/http-errors.ts#sendRestJsonError": "sha256:981abb859649419604d555601b5c4bd6616e78215327e055e5f8023c6c15f556", "src/daemon/http-errors.ts#statusCodeForNormalizedError": "sha256:20ad272162e28425920bd2dcb4da5ec104bd7e8be2028b902b05bfa4da3df966", - "src/daemon/http-health.ts#DaemonHealthPayload": "sha256:050650184ad3e61d9359bf5866e7fc1e15e13f14f736a8c92ca57cad6d388967", - "src/daemon/http-health.ts#buildDaemonHealthPayload": "sha256:5eebf35539d04e3693bb6d7081ffdfcb0d2d8b7d53066e30ca6b4017fa4a55a3", "src/daemon/http-request-target.ts#decodeUriSegment": "sha256:b37622771ea4e61d1a9820169dbdc5809b5c38889bfad79277d7b22f3c3f1ef1", "src/daemon/request-diagnostics-http.ts#REQUEST_DIAGNOSTICS_CONTENT_TYPE": "sha256:16bbd9ae7eaeaaa8c8cbd6e65c4ce786a6c5230c5351288228bbf1a6f11bdcdc", "src/daemon/request-diagnostics-http.ts#RequestDiagnosticsHttpAuthorizer": "sha256:20761c39f434c675bb3405838dded5640bb623737957257e0452ebd46035eef1", @@ -175,6 +175,11 @@ "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:da39a79fa7c1f81e55caf613eedc347c0f3a9a9711b265a4db185677532d9552" }, "compatibleChanges": [ + { + "declaration": "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload", + "digest": "sha256:709899c02b2a5f6ec2a995350ac7b5791d67db12a631b109af5074427291edda", + "rationale": "#2318 moves the daemon HTTP wire contract into @agent-device/contracts and hands buildDaemonHealthPayload the version its caller advertises instead of reading the package version itself. The added parameter is caller-local \u2014 each side passes its own readVersion() \u2014 and the /health payload a released peer sends or parses is byte-identical." + }, { "declaration": "packages/kernel/src/contracts.ts#DaemonArtifactKnownType", "digest": "sha256:21886407c3fbc30d8c0c544503b831c78db2408aeaef1b9b2c6a4139144aef28", @@ -183,7 +188,7 @@ { "declaration": "src/remote/daemon-artifacts.ts#DownloadRemoteArtifactParams", "digest": "sha256:3ec63be2dbdb542e30b19d1500bc16874607823afb7ae392e92b0b535865859e", - "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state — never serialized, never sent to the daemon — that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before." + "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state \u2014 never serialized, never sent to the daemon \u2014 that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before." }, { "declaration": "src/remote/daemon-artifacts.ts#downloadRemoteArtifact",